Skip to content

Commit 2b6790b

Browse files
committed
kPad Release 1.2.1 "Extensia"
1 parent dea8a6b commit 2b6790b

File tree

2 files changed

+138
-56
lines changed

2 files changed

+138
-56
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ kPad is a Notepad app made in Python and CustomTkinter.
33

44
**If you want to compile for your platform or just run the file, you will need customtkinter installed.**
55

6-
### kPad 1.1.1 (RC2)
6+
### kPad 1.2.1 (RC3.1) Extensia
77

88
## Description
99
kPad is a lightweight and user-friendly text editor designed for quick note-taking and editing. Built using Python and the CustomTkinter library, it offers a modern interface with essential features for everyday text editing tasks.
@@ -17,7 +17,7 @@ You can install CustomTkinter using pip:
1717
python3 -m pip install customtkinter
1818
```
1919

20-
Make sure you have Python 3.13+.
20+
Make sure you have Python 3.9+.
2121

2222
## Features
2323
- Clean and intuitive UI built with CustomTkinter.
@@ -60,7 +60,7 @@ Feel free to contribute or report issues on the **project repository**.
6060

6161
## TODO
6262
- [ ] Add syntax highlighting for Python
63-
- [ ] Implement recent files menu
63+
- [x] Implement recent files menu
6464
- [ ] Add find/search dialog
6565
- [ ] Improve autosave with dirty flag
66-
- [ ] Implement extensions
66+
- [x] Implement extensions

main.py

Lines changed: 134 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import customtkinter as ctk
22
from tkinter.filedialog import asksaveasfilename, askopenfilename
33
from tkinter import Menu
4-
from tkinter.messagebox import showinfo, askyesno
4+
from tkinter.messagebox import showinfo, askyesno, askyesnocancel
55
from tkinter import simpledialog
66
import time, threading
77
import os, json, platform
@@ -12,7 +12,7 @@
1212
# ██╔═██╗░██╔═══╝░██╔══██║██║░░██║
1313
# ██║░╚██╗██║░░░░░██║░░██║██████╔╝
1414
# ╚═╝░░╚═╝╚═╝░░░░░╚═╝░░╚═╝╚═════╝░
15-
# Version 1.2.0 [SOURCE CODE]
15+
# Version 1.2.1 [SOURCE CODE]
1616

1717
if platform.system() == 'Darwin':
1818
config_dir = os.path.expanduser('~/Library/Application Support/kPad')
@@ -37,7 +37,8 @@
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

91106
class 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

369451
App('kPad - Untitled', CONFIGURATION['window_geometry']).mainloop()

0 commit comments

Comments
 (0)