11import customtkinter as ctk
22from tkinter .filedialog import asksaveasfilename , askopenfilename
33from tkinter import Menu
4- from tkinter .messagebox import showinfo , askyesno
4+ from tkinter .messagebox import showinfo , askyesno , askyesnocancel
55from tkinter import simpledialog
66import time , threading
77import os , json , platform
1212# ██╔═██╗░██╔═══╝░██╔══██║██║░░██║
1313# ██║░╚██╗██║░░░░░██║░░██║██████╔╝
1414# ╚═╝░░╚═╝╚═╝░░░░░╚═╝░░╚═╝╚═════╝░
15- # Version 1.2.0 [SOURCE CODE]
15+ # Version 1.2.1 [SOURCE CODE]
1616
1717if platform .system () == 'Darwin' :
1818 config_dir = os .path .expanduser ('~/Library/Application Support/kPad' )
3737 'font' : 'Menlo' ,
3838 'font_size' : 14 ,
3939 'window_geometry' : [500 , 400 ],
40- 'recent_files' : {'enabled' : True , 'keep_recent_files_count' : 5 , 'recent_file_paths' : []}
40+ 'recent_files' : {'enabled' : True , 'keep_recent_files_count' : 5 , 'recent_file_paths' : []},
41+ 'auto_start_plugins' : []
4142}
4243
4344_fonts = ['Menlo' , 'Monaco' , 'Helvetica' , 'Arial' , 'Times New Roman' , 'Georgia' , 'Avenir' , 'Baskerville' , 'Futura' , 'Verdana' , 'Gill Sans' , 'Courier' , 'Optima' , 'American Typewriter' ]
@@ -46,23 +47,24 @@ class PluginAPI:
4647 def __init__ (self , textbox , appinstance ):
4748 self .textbox = textbox
4849 self ._appinstance = appinstance
50+
4951 def get_text_from_box (self ):
50- return self .textbox .get (" 1.0" , " end-1c" )
52+ return self .textbox .get (' 1.0' , ' end-1c' )
5153 def get_specific_text_from_box (self , start , end ):
5254 return self .textbox .get (start , end )
5355 def clear_text_from_box (self ):
54- self .textbox .delete (" 1.0" , " end" )
56+ self .textbox .delete (' 1.0' , ' end' )
5557 def insert_text_to_start_of_box (self , text ):
56- self .textbox .insert (" 1.0" , text )
58+ self .textbox .insert (' 1.0' , text )
5759 def insert_text_to_end_of_box (self , text ):
58- self .textbox .insert (" end" , text )
60+ self .textbox .insert (' end' , text )
5961 def bind (self , sequence , callback ):
6062 def wrapper (event ):
6163 try :
6264 callback (event )
6365 except TypeError :
6466 callback ()
65- return " break"
67+ return ' break'
6668 self .textbox .bind (sequence , wrapper )
6769 def get_plugin_path (self , plugin_name ):
6870 return os .path .join (plugin_dir , plugin_name )
@@ -79,14 +81,27 @@ def set_theme_file(self, json_path):
7981 if os .path .exists (json_path ):
8082 ctk .set_default_color_theme (json_path )
8183 def show_info (self , text ):
82- showinfo ("Info" , text )
83-
84+ showinfo ('Info' , text )
8485 def show_error (self , text ):
85- showinfo ("Error" , text )
86-
86+ showinfo ('Error' , text )
8787 def log (self , text ):
88- print (f"[PLUGIN LOG] { text } " )
89-
88+ print (f'[PLUGIN LOG] { text } ' )
89+ def run_async (self , cmd , withdaemon = bool ):
90+ thread = threading .Thread (target = cmd , daemon = withdaemon )
91+ return thread
92+ def Widget_Frame (self , parent , ** kwargs ):
93+ fr = ctk .CTkFrame (parent , ** kwargs )
94+ return fr
95+ def Widget_Label (self , parent , text , font = ('' , 13 ), ** kwargs ):
96+ lbl = ctk .CTkLabel (parent , text = text , font = font , ** kwargs )
97+ return lbl
98+ def Widget_Button (self , parent , text , cmd , font = ('' , 13 ), ** kwargs ):
99+ btn = ctk .CTkButton (parent , text = text , command = cmd , font = font , ** kwargs )
100+ return btn
101+ def Widget_Other (self , parent , widget , ** kwargs ):
102+ widg = widget (parent , ** kwargs )
103+ return widg
104+
90105
91106class App (ctk .CTk ):
92107 def __init__ (self , title , geometry ):
@@ -104,29 +119,33 @@ def __load_plugins():
104119 folder_path = os .path .join (plugin_dir , folder )
105120 if not os .path .isdir (folder_path ):
106121 continue
107- logic_path = os .path .join (folder_path , " logic.py" )
122+ logic_path = os .path .join (folder_path , ' logic.py' )
108123 if os .path .exists (logic_path ):
109124 name = folder
110125 try :
111126 spec = importlib .util .spec_from_file_location (name , logic_path )
112127 mod = importlib .util .module_from_spec (spec )
113128 spec .loader .exec_module (mod )
114- meta_path = os .path .join (folder_path , " metadata.json" )
129+ meta_path = os .path .join (folder_path , ' metadata.json' )
115130 metadata = None
116131 if os .path .exists (meta_path ):
117- with open (meta_path , "r" ) as f :
132+ with open (meta_path , 'r' ) as f :
118133 metadata = json .load (f )
119- if hasattr (mod , " action" ):
120- plugins .append ({" module" : mod , " meta" : metadata })
134+ if hasattr (mod , ' action' ):
135+ plugins .append ({' module' : mod , ' meta' : metadata })
121136 except Exception as e :
122- print (f" [PLUGIN ERROR] Failed to load '{ name } ': { e } " )
137+ print (f' [PLUGIN ERROR] Failed to load \ '{ name } \ ' : { e } ' )
123138
124139 return plugins
125-
140+
141+ global PLUGINS_LIST
126142 PLUGINS_LIST = __load_plugins ()
143+ editor_api = PluginAPI (self .textbox , self )
144+ self .plugins_list = PLUGINS_LIST
127145
128146 self .path = None
129147 self .font_size = 14
148+ self .modified = False
130149
131150 def write_to_recent_files ():
132151 if CONFIGURATION ['recent_files' ]['enabled' ]:
@@ -154,21 +173,32 @@ def newfile(event=None):
154173
155174 def save_as (event = None ):
156175 self .path = asksaveasfilename (filetypes = [('Text files' , '.txt' ), ('Other' , '.*.*' )], defaultextension = '.txt' )
157- with open (self .path , 'w' ) as file :
158- file .write (self .textbox .get ('1.0' ))
159- self .title (f'kPad - { self .path } ' )
176+ if not self .path :
177+ return
178+ try :
179+ with open (self .path , 'w' , encoding = 'utf-8' ) as file :
180+ file .write (self .textbox .get ('1.0' , 'end-1c' ))
181+ self .title (f'kPad - { self .path } ' )
182+ self .modified = False
183+ if self .title ().endswith ('*' ):
184+ self .title (self .title ()[:- 1 ])
185+ except Exception :
186+ return
160187
161188 def save_file (event = None ):
162189 if not self .path :
163190 save_as ()
191+ return
164192 with open (self .path , 'w' , encoding = 'utf-8' ) as f :
165193 f .write (self .textbox .get ('1.0' , 'end-1c' ))
166- self .title (f'kPad - { self .path } ' )
194+ self .modified = False
195+ if self .title ().endswith ('*' ):
196+ self .title (self .title ()[:- 1 ])
167197
168198
169199 def open_from_file (event = None , path = None ):
170200 if not path :
171- self .path = askopenfilename (filetypes = [('Text files' , '.txt' ), ('kPad notefile ' , '.kpad ' )], defaultextension = '.txt' )
201+ self .path = askopenfilename (filetypes = [('Text files' , '.txt' ), ('All files ' , '*.* ' )], defaultextension = '.txt' )
172202 if self .path :
173203 self .textbox .delete ('1.0' , 'end' )
174204 with open (self .path , 'r' ) as file :
@@ -245,27 +275,27 @@ def save_config():
245275
246276 def auto_indent (event ):
247277 tb = event .widget
248- cursor_index = tb .index (" insert" )
278+ cursor_index = tb .index (' insert' )
249279 line_number = int (cursor_index .split ('.' )[0 ])
250280 indent = 0
251281 prev_line_num = line_number - 1
252282 while prev_line_num > 0 :
253- prev_line_text = tb .get (f" { prev_line_num } .0" , f" { prev_line_num } .end" )
254- if prev_line_text .strip () != "" :
283+ prev_line_text = tb .get (f' { prev_line_num } .0' , f' { prev_line_num } .end' )
284+ if prev_line_text .strip () != '' :
255285 indent = len (prev_line_text ) - len (prev_line_text .lstrip (' ' ))
256286 if prev_line_text .rstrip ().endswith ((':' , '{' )):
257287 indent += 4
258288 break
259289 prev_line_num -= 1
260- current_line_text = tb .get (f" { line_number } .0" , f" { line_number } .end" )
261- if current_line_text .strip () == "" :
262- tb .insert (" insert" , " " * indent )
263- tb .insert (" insert" , " \n " + " " * indent )
264- return " break"
290+ current_line_text = tb .get (f' { line_number } .0' , f' { line_number } .end' )
291+ if current_line_text .strip () == '' :
292+ tb .insert (' insert' , ' ' * indent )
293+ tb .insert (' insert' , ' \n ' + ' ' * indent )
294+ return ' break'
265295
266296 def add_second_char (char , event = None ):
267297 def insert_char ():
268- cursor = self .textbox .index (" insert" )
298+ cursor = self .textbox .index (' insert' )
269299 if char == '{' :
270300 self .textbox .insert (cursor , '}' )
271301 elif char == '[' :
@@ -301,15 +331,14 @@ def handle_brackets(event):
301331 view_menu .add_separator ()
302332 view_menu .add_checkbutton (label = 'Word Wrap...' , onvalue = True , variable = word_wrap_var , command = toggle_word_wrap )
303333 menu .add_cascade (label = 'View' , menu = view_menu )
304- self .plugins_menu = Menu (menu , tearoff = 0 )
305- menu .add_cascade (label = "Plugins" , menu = self .plugins_menu )
306-
307- editor_api = PluginAPI (self .textbox , self )
308- self .plugins_list = PLUGINS_LIST
334+ plugins_menu = Menu (menu , tearoff = 0 )
335+ menu .add_cascade (label = 'Plugins' , menu = plugins_menu )
309336 for plugin in self .plugins_list :
310- meta = plugin ["meta" ]
311- name = meta .get ("name" ) if meta else plugin ["module" ].__name__
312- self .plugins_menu .add_command (label = name , command = lambda p = plugin : p ["module" ].action (editor_api ))
337+ meta = plugin ['meta' ]
338+ name = meta .get ('name' ) if meta else plugin ['module' ].__name__
339+ plugins_menu .add_command (label = name , command = lambda p = plugin : p ['module' ].action (editor_api ))
340+ plugins_menu .add_separator ()
341+ plugins_menu .add_command (label = 'Show Plugin Information...' , command = lambda : PluginsInfo (self .plugins_list ))
313342
314343 self .configure (menu = menu )
315344
@@ -321,10 +350,6 @@ def update_cursor_info(event=None):
321350 line , col = map (int , pos .split ('.' ))
322351 chars = len (self .textbox .get ('1.0' , 'end-1c' ))
323352 self .stats_line_col .configure (text = f'Ln: { line } Col: { col + 1 } Ch: { chars } ' )
324- if self .path != None :
325- self .title (f'kPad - *{ self .path } ' )
326- else :
327- self .title ('kPad - *Untitled' )
328353
329354 self .title (title )
330355 self .geometry (f'{ geometry [0 ]} x{ geometry [1 ]} ' )
@@ -339,12 +364,11 @@ def update_cursor_info(event=None):
339364 self .textbox .bind ('<ButtonRelease>' , update_cursor_info )
340365 self .textbox .bind ('<Return>' , auto_indent )
341366
342- # Cross-platform key bindings
343367 system = platform .system ()
344- if system == " Darwin" :
345- mod = " Command"
368+ if system == ' Darwin' :
369+ mod = ' Command'
346370 else :
347- mod = " Control"
371+ mod = ' Control'
348372
349373 self .bind (f'<{ mod } -l>' , go_to_line )
350374 self .bind (f'<{ mod } -s>' , save_file )
@@ -362,8 +386,66 @@ def update_cursor_info(event=None):
362386 self .stats_line_col = ctk .CTkLabel (self .stats_text_frame , text = 'Ln: 1 Col: 1 Ch: 0' )
363387 self .stats_line_col .pack (side = ctk .RIGHT )
364388
389+ def on_text_change (event = None ):
390+ if not self .modified :
391+ self .modified = True
392+ if not self .title ().endswith ('*' ):
393+ self .title (self .title () + '*' )
394+
395+ self .textbox .bind ('<Key>' , on_text_change , add = '+' )
396+ self .textbox .bind ('<<Paste>>' , on_text_change , add = '+' )
397+ self .textbox .bind ('<<Cut>>' , on_text_change , add = '+' )
398+ self .textbox .bind ('<Delete>' , on_text_change , add = '+' )
399+
400+ def _on_quit_ ():
401+ if self .modified :
402+ result = askyesnocancel ('Quit' , 'Save changes before quitting?' )
403+ if result is True :
404+ if self .path :
405+ save_file ()
406+ else :
407+ save_as ()
408+ elif result is None :
409+ return
410+ save_config ()
411+ self .destroy ()
412+
413+ for plugin in self .plugins_list :
414+ name = plugin .get ('meta' , {}).get ('name' , plugin ['module' ].__name__ )
415+ if name in CONFIGURATION .get ('auto_start_plugins' , []):
416+ try :
417+ plugin ['module' ].action (editor_api )
418+ except Exception as e :
419+ print (f"[PLUGIN ERROR] Auto-start failed for { name } : { e } " )
420+
365421 self .textbox .pack (fill = 'both' , expand = True )
366- self .protocol ('WM_DELETE_WINDOW' , lambda : [save_file (), save_config (), self .destroy ()])
422+ self .protocol ('WM_DELETE_WINDOW' , _on_quit_ )
423+
424+ class PluginsInfo (ctk .CTkToplevel ):
425+ def __init__ (self , plugins_list ):
426+ super ().__init__ ()
427+
428+ def make_separator ():
429+ ctk .CTkFrame (self .main , height = 3 ).pack (fill = 'x' , pady = 5 , padx = 10 )
367430
431+ self .title ('Plugin Info' )
432+ self .geometry (f'500x600' )
433+
434+ self .main = ctk .CTkScrollableFrame (self )
435+
436+ for plugin in plugins_list :
437+ meta = plugin .get ('meta' , {})
438+ name = meta .get ('name' , plugin ['module' ].__name__ )
439+ author = meta .get ('author' , 'Unknown' )
440+ version = meta .get ('version' , '1.0' )
441+ desc = meta .get ('desc' , 'No description available.' )
442+
443+ ctk .CTkLabel (self .main , text = f'Name: { name } ' , font = ctk .CTkFont (weight = 'bold' )).pack (anchor = 'w' )
444+ ctk .CTkLabel (self .main , text = f'Author: { author } ' ).pack (anchor = 'w' )
445+ ctk .CTkLabel (self .main , text = f'Version: { version } ' ).pack (anchor = 'w' )
446+ ctk .CTkLabel (self .main , text = f'Description: { desc } ' , wraplength = 360 , justify = 'left' ).pack (anchor = 'w' , pady = (0 ,5 ))
447+ ctk .CTkButton (self .main , text = f'Mark { name } as Autostarter...' , command = lambda n = name : CONFIGURATION ['auto_start_plugins' ].append (n )).pack (pady = 5 , anchor = 'w' )
448+ make_separator ()
449+ self .main .pack (fill = 'both' , expand = 'True' )
368450
369451App ('kPad - Untitled' , CONFIGURATION ['window_geometry' ]).mainloop ()
0 commit comments