From 6b5df7ef4ca3a1293b9dddb4717d9d53ca33ab3e Mon Sep 17 00:00:00 2001 From: Jonathan Ebel Date: Sun, 8 Mar 2026 23:07:07 +0100 Subject: [PATCH] fix: resolve tab close and a startup crash on macOS that occured when first trying to start the project via app.py - Fix tab body click triggering close by computing relative_x against the clicked tab's true left edge instead of the notebook origin (@ui.py line 727) - Fix app crash on startup by catching RuntimeError from tkinterdnd2 when the native tkdnd library fails to load on macOS (@app.py line 35) --- PrepareForMacUser.md | 4 +- app.py | 39 +- markdown_reader/ui.py | 829 +++++++++++++++++++++++++----------------- 3 files changed, 528 insertions(+), 344 deletions(-) diff --git a/PrepareForMacUser.md b/PrepareForMacUser.md index 640eec3..58b29ca 100644 --- a/PrepareForMacUser.md +++ b/PrepareForMacUser.md @@ -8,7 +8,7 @@ Then reactivate your virtual environment: deactivate source venv/bin/activate ``` -#### If you are using Apple Silicon (M1/M2/M3) +#### If you are using Apple Silicon (M1/M2/M3/M4) Run: ```bash @@ -44,4 +44,4 @@ export DYLD_FALLBACK_LIBRARY_PATH=/usr/local/lib Save the file, then run: ```bash source ~/.zshrc -``` \ No newline at end of file +``` diff --git a/app.py b/app.py index 21d704a..1184def 100644 --- a/app.py +++ b/app.py @@ -1,55 +1,64 @@ -import tkinter as tk -from markdown_reader.ui import MarkdownReader -import sys import os +import sys +import tkinter as tk + import ttkbootstrap as ttkb from ttkbootstrap.constants import * +from markdown_reader.ui import MarkdownReader + def handle_open_file(event): """ Handles file open events from macOS. - :param event event: The open file event from macOS. + :param event event: The open file event from macOS. """ - + file_path = event - if os.path.isfile(file_path) and file_path.lower().endswith(('.md', '.markdown', '.html', '.htm', '.pdf')): + if os.path.isfile(file_path) and file_path.lower().endswith( + (".md", ".markdown", ".html", ".htm", ".pdf") + ): app.load_file(file_path) if __name__ == "__main__": try: from tkinterdnd2 import TkinterDnD + root = TkinterDnD.Tk() - + # Apply ttkbootstrap theme to TkinterDnD window app_style = ttkb.Style(theme="darkly") - + print("TkinterDnD enabled - Drag and drop support available") - except ImportError as e: + except (ImportError, RuntimeError) as e: print(f" Warning: tkinterdnd2 not installed, drag-and-drop will be disabled") print(f" Error: {e}") root = ttkb.Window(themename="darkly") - + # Ensure window is resizable root.resizable(width=True, height=True) app = MarkdownReader(root) - + # Handle file open events from macOS Finder - root.createcommand("::tk::mac::OpenDocument", lambda *args: handle_open_file(args[0])) + root.createcommand( + "::tk::mac::OpenDocument", lambda *args: handle_open_file(args[0]) + ) # Handle file opening from command line if len(sys.argv) > 1: for arg in sys.argv[1:]: # Skip macOS system parameters (process serial number) - if arg.startswith('-psn'): + if arg.startswith("-psn"): continue # Convert to absolute path file_path = os.path.abspath(arg) - if os.path.isfile(file_path) and file_path.lower().endswith(('.md', '.markdown', '.html', '.htm', '.pdf')): + if os.path.isfile(file_path) and file_path.lower().endswith( + (".md", ".markdown", ".html", ".htm", ".pdf") + ): app.load_file(file_path) break # Only open the first file - root.mainloop() \ No newline at end of file + root.mainloop() diff --git a/markdown_reader/ui.py b/markdown_reader/ui.py index 53d19b4..63d81c2 100644 --- a/markdown_reader/ui.py +++ b/markdown_reader/ui.py @@ -2,27 +2,31 @@ import re import threading import tkinter as tk -from tkinter import ttk -from tkinter import filedialog, messagebox, simpledialog -from tkinter.scrolledtext import ScrolledText -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler -from markdown_reader.logic import update_preview -from markdown_reader.logic import open_preview_in_browser -from markdown_reader.logic import export_to_html -from markdown_reader.logic import export_to_docx -from markdown_reader.logic import export_to_pdf -from markdown_reader.logic import convert_html_to_markdown -from markdown_reader.logic import convert_pdf_to_markdown -from markdown_reader.logic import convert_pdf_to_markdown_docling -from markdown_reader.logic import translate_markdown_with_ai -from markdown_reader.file_handler import load_file, drop_file -from markdown_reader.utils import get_preview_file import tkinter.font # moved here from inside methods +from tkinter import filedialog, messagebox, simpledialog, ttk +from tkinter.scrolledtext import ScrolledText + +import markdown import ttkbootstrap as ttkb -from ttkbootstrap.constants import * from ttkbootstrap import dialogs -import markdown +from ttkbootstrap.constants import * +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from markdown_reader.file_handler import drop_file, load_file +from markdown_reader.logic import ( + convert_html_to_markdown, + convert_pdf_to_markdown, + convert_pdf_to_markdown_docling, + export_to_docx, + export_to_html, + export_to_pdf, + open_preview_in_browser, + translate_markdown_with_ai, + update_preview, +) +from markdown_reader.utils import get_preview_file + from .plugins.pdf_exporter import export_markdown_to_pdf @@ -32,7 +36,7 @@ class FileChangeHandler(FileSystemEventHandler): :param FileSystemEventHandler: The file system event handler from the watchdog.events library. """ - + def __init__(self, app, filepath): """ :param MarkdownReader app: The instance of the MarkdownReader application. @@ -46,9 +50,9 @@ def on_modified(self, event): """ When the file is modified, reload the file after 100ms. - :param event event: The file modification event. + :param event event: The file modification event. """ - + if os.path.abspath(event.src_path) == self.filepath: self.app.root.after(100, lambda: self.app.load_file(self.filepath)) @@ -57,21 +61,21 @@ class MarkdownReader: """ The class that creates the instance of the Markdown reader application. """ - + def __init__(self, root): """ :param TkinterDnD.Tk root: The window that the application uses as a display. """ - + self.root = root self.root.title("Markdown Reader") self.root.geometry("1280x795") - + # Enable window resizing - force both width and height to be resizable self.root.resizable(width=True, height=True) # Set minimum window size to prevent it from being too small self.root.minsize(800, 600) - + self.dark_mode = False self.preview_file = get_preview_file() self.current_file_path = None @@ -82,16 +86,16 @@ def __init__(self, root): self.current_font_size = 14 self.current_fg_color = "#000000" self.current_bg_color = "#ffffff" - + # Flag to prevent marking as modified during file loading self._loading_file = False - + # Track which tabs have unsaved modifications self.modified_tabs = set() - + # PDF conversion mode: False = PyMuPDF (fast), True = Docling (advanced) self.use_docling_pdf = False - + # IME state tracking per widget self._ime_states = {} @@ -120,17 +124,18 @@ def __init__(self, root): "Chinese (Simplified)", "Chinese (Traditional)", ] - self.ai_provider_var = tk.StringVar(value=os.getenv("AI_PROVIDER", "openrouter").strip().lower() or "openrouter") + self.ai_provider_var = tk.StringVar( + value=os.getenv("AI_PROVIDER", "openrouter").strip().lower() or "openrouter" + ) self.create_widgets() self.bind_events() - def create_widgets(self): """ Sets up the various menus, toolbar, and fonts. """ - + style = ttkb.Style() menubar = tk.Menu(self.root) filemenu = tk.Menu(menubar, tearoff=0) @@ -150,8 +155,10 @@ def create_widgets(self): viewmenu = tk.Menu(menubar, tearoff=0) viewmenu.add_command(label="Toggle Dark Mode", command=self.toggle_dark_mode) - viewmenu.add_command(label="Open Preview in Browser", - command=lambda: open_preview_in_browser(self.preview_file, self)) + viewmenu.add_command( + label="Open Preview in Browser", + command=lambda: open_preview_in_browser(self.preview_file, self), + ) menubar.add_cascade(label="View", menu=viewmenu) editmenu = ttkb.Menu(menubar, tearoff=0) @@ -161,11 +168,11 @@ def create_widgets(self): translatemenu = tk.Menu(editmenu, tearoff=0) translatemenu.add_command( label="Translate Selected Text with AI", - command=lambda: self.translate_with_ai(selected_only=True) + command=lambda: self.translate_with_ai(selected_only=True), ) translatemenu.add_command( label="Translate Full Document with AI", - command=lambda: self.translate_with_ai(selected_only=False) + command=lambda: self.translate_with_ai(selected_only=False), ) editmenu.add_cascade(label="Translate with AI", menu=translatemenu) menubar.add_cascade(label="Edit", menu=editmenu) @@ -199,10 +206,12 @@ def create_widgets(self): toolsmenu.add_checkbutton( label="Use Advanced PDF Conversion (Docling)", variable=self.pdf_mode_var, - command=self.toggle_pdf_mode + command=self.toggle_pdf_mode, ) toolsmenu.add_separator() - toolsmenu.add_command(label="PDF Converter Info", command=self.show_pdf_converter_info) + toolsmenu.add_command( + label="PDF Converter Info", command=self.show_pdf_converter_info + ) menubar.add_cascade(label="Tools", menu=toolsmenu) # ADD THIS NEW BLOCK: @@ -211,69 +220,147 @@ def create_widgets(self): tablemenu.add_separator() tablemenu.add_command(label="Table Syntax Help", command=self.show_table_help) menubar.add_cascade(label="Table", menu=tablemenu) - + self.root.config(menu=menubar) # --- Toolbar --- - style.configure('primary.TFrame') - toolbar = ttkb.Frame(self.root, relief=tk.RAISED, style='primary.TFrame', padding=(5, 5, 0, 5)) + style.configure("primary.TFrame") + toolbar = ttkb.Frame( + self.root, relief=tk.RAISED, style="primary.TFrame", padding=(5, 5, 0, 5) + ) # Style dropdown self.style_var = tk.StringVar(value="Normal text") style_options = ["Normal text", "Heading 1", "Heading 2", "Heading 3"] - style.configure('info.Outline.TMenubutton') + style.configure("info.Outline.TMenubutton") # style_menu = tk.OptionMenu(toolbar, self.style_var, *style_options, command=self.apply_style) - style_menu = ttkb.Menubutton(toolbar, textvariable=self.style_var, style='info.Outline.TMenubutton') + style_menu = ttkb.Menubutton( + toolbar, textvariable=self.style_var, style="info.Outline.TMenubutton" + ) style_menu.config(width=12) style_menu.pack(side=tk.LEFT, padx=2, pady=2) menu_ = tk.Menu(style_menu, tearoff=0) for s in style_options: - menu_.add_radiobutton(label=s, variable=self.style_var, command=lambda s=s: self.apply_style(s)) - style_menu['menu'] = menu_ + menu_.add_radiobutton( + label=s, + variable=self.style_var, + command=lambda s=s: self.apply_style(s), + ) + style_menu["menu"] = menu_ # Font family dropdown fonts = sorted(set(tkinter.font.families())) self.font_var = ttkb.StringVar(value="Consolas") - font_menu = ttkb.Menubutton(toolbar, textvariable=self.font_var, style='info.Outline.TMenubutton') - + font_menu = ttkb.Menubutton( + toolbar, textvariable=self.font_var, style="info.Outline.TMenubutton" + ) + font_menu.config(width=10) font_menu.pack(side=tk.LEFT, padx=2) - + menu = tk.Menu(font_menu, tearoff=0) for f in fonts[:20]: - menu.add_radiobutton(label=f, variable=self.font_var, command=lambda f=f: self.apply_font(f)) - font_menu['menu'] = menu + menu.add_radiobutton( + label=f, variable=self.font_var, command=lambda f=f: self.apply_font(f) + ) + font_menu["menu"] = menu # Font size self.font_size_var = tk.IntVar(value=14) button_width = 3 uniform_padding = (5, 4) # entry config - style.configure('info.TEntry') - ttkb.Button(toolbar, text="-", bootstyle=(DANGER, OUTLINE), width=button_width,padding=uniform_padding, command=lambda: self.change_font_size(-1)).pack(side=tk.LEFT, padx = 5) - ttkb.Entry(toolbar, textvariable=self.font_size_var, width=3, style='info.TEntry', justify='center').pack(side=tk.LEFT) - ttkb.Button(toolbar, text="+", bootstyle=(SUCCESS, OUTLINE), width=button_width,padding=uniform_padding, command=lambda: self.change_font_size(1)).pack(side=tk.LEFT, padx=5) + style.configure("info.TEntry") + ttkb.Button( + toolbar, + text="-", + bootstyle=(DANGER, OUTLINE), + width=button_width, + padding=uniform_padding, + command=lambda: self.change_font_size(-1), + ).pack(side=tk.LEFT, padx=5) + ttkb.Entry( + toolbar, + textvariable=self.font_size_var, + width=3, + style="info.TEntry", + justify="center", + ).pack(side=tk.LEFT) + ttkb.Button( + toolbar, + text="+", + bootstyle=(SUCCESS, OUTLINE), + width=button_width, + padding=uniform_padding, + command=lambda: self.change_font_size(1), + ).pack(side=tk.LEFT, padx=5) # font configuration - + # toggle bold - style.configure('bold.info.TButton', font=("Arial", 9, "bold"), padding=uniform_padding) + style.configure( + "bold.info.TButton", font=("Arial", 9, "bold"), padding=uniform_padding + ) # toggle italic - style.configure('italic.info.TButton', font=("Arial", 9, "italic"), padding=uniform_padding) + style.configure( + "italic.info.TButton", font=("Arial", 9, "italic"), padding=uniform_padding + ) # toggle underline - style.configure('underline.info.TButton', font=("Arial", 9, "underline"), padding=uniform_padding) + style.configure( + "underline.info.TButton", + font=("Arial", 9, "underline"), + padding=uniform_padding, + ) # insert table - style.configure('insert.info.TButton', font=("Arial", 9), padding=uniform_padding) + style.configure( + "insert.info.TButton", font=("Arial", 9), padding=uniform_padding + ) # choose fg color - style.configure('fg.info.TButton', font=("Arial", 9), padding=uniform_padding) + style.configure("fg.info.TButton", font=("Arial", 9), padding=uniform_padding) # highlight - style.configure('bg.info.TButton', font=("Arial", 9), padding=uniform_padding) + style.configure("bg.info.TButton", font=("Arial", 9), padding=uniform_padding) - ttkb.Button(toolbar, text="B", style='bold.info.TButton', width=button_width, command=self.toggle_bold).pack(side=tk.LEFT, padx=5) - ttkb.Button(toolbar, text="I", style='italic.info.TButton', width=button_width, command=self.toggle_italic).pack(side=tk.LEFT, padx=5) - ttkb.Button(toolbar, text="U", style='underline.info.TButton', width=button_width, command=self.toggle_underline).pack(side=tk.LEFT, padx=5) - ttkb.Button(toolbar, text="⊞", style='insert.info.TButton',width=button_width, command=self.insert_table).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="B", + style="bold.info.TButton", + width=button_width, + command=self.toggle_bold, + ).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="I", + style="italic.info.TButton", + width=button_width, + command=self.toggle_italic, + ).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="U", + style="underline.info.TButton", + width=button_width, + command=self.toggle_underline, + ).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="⊞", + style="insert.info.TButton", + width=button_width, + command=self.insert_table, + ).pack(side=tk.LEFT, padx=5) # Text color - ttkb.Button(toolbar, text="A", style='fg.info.TButton', width=button_width, command=self.choose_fg_color).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="A", + style="fg.info.TButton", + width=button_width, + command=self.choose_fg_color, + ).pack(side=tk.LEFT, padx=5) # Highlight color - ttkb.Button(toolbar, text="\u0332", style='bg.info.TButton', width=button_width, command=self.choose_bg_color).pack(side=tk.LEFT, padx=5) + ttkb.Button( + toolbar, + text="\u0332", + style="bg.info.TButton", + width=button_width, + command=self.choose_bg_color, + ).pack(side=tk.LEFT, padx=5) toolbar.pack(fill=tk.X) self.notebook = ttk.Notebook(self.root) @@ -281,31 +368,37 @@ def create_widgets(self): self.editors = [] self.file_paths = [] - + # Create context menu for tabs self.tab_context_menu = tk.Menu(self.root, tearoff=0) - self.tab_context_menu.add_command(label="Close", command=self.close_tab_from_context_menu) - + self.tab_context_menu.add_command( + label="Close", command=self.close_tab_from_context_menu + ) + # Bind click event to detect close button clicks self.notebook.bind("", self.on_tab_click) - + # Bind right-click for context menu - self.notebook.bind("" if self.root.tk.call('tk', 'windowingsystem') == 'aqua' else "", self.show_tab_context_menu) + self.notebook.bind( + "" + if self.root.tk.call("tk", "windowingsystem") == "aqua" + else "", + self.show_tab_context_menu, + ) self.new_file() - def bind_events(self): """ Sets up drag-and-drop support alongside key binds for shortcuts. :raises RuntimeError: If the drag-and-drop binding fails. """ - + try: # Register drag-and-drop support - self.root.drop_target_register('DND_Files') - self.root.dnd_bind('<>', self._on_drop_files) + self.root.drop_target_register("DND_Files") + self.root.dnd_bind("<>", self._on_drop_files) print("✅ Drag-and-drop support enabled") except Exception as e: print(f"⚠️ Drag-and-drop binding failed: {e}") @@ -315,12 +408,17 @@ def bind_events(self): self.root.bind_all("", lambda event: self.save_file()) self.root.bind_all("", lambda event: self.undo_action()) self.root.bind_all("", lambda event: self.redo_action()) - self.root.bind_all("", lambda event: self.translate_with_ai(selected_only=False)) + self.root.bind_all( + "", + lambda event: self.translate_with_ai(selected_only=False), + ) self.root.bind_all("", lambda event: self.new_file()) self.root.bind_all("", lambda event: self.undo_action()) self.root.bind_all("", lambda event: self.redo_action()) - self.root.bind_all("", lambda event: self.translate_with_ai(selected_only=False)) - + self.root.bind_all( + "", + lambda event: self.translate_with_ai(selected_only=False), + ) def _on_drop_files(self, event): """ @@ -334,20 +432,20 @@ def _on_drop_files(self, event): print(f"🔍 Drop event triggered") print(f" Event data type: {type(event.data)}") print(f" Event data: {event.data}") - + try: drop_file(event, self) except Exception as e: print(f"❌ Error handling drop: {e}") - import traceback - traceback.print_exc() + import traceback + traceback.print_exc() def new_file(self): """ Handles opening a new file and deals with relevant IME files. """ - + frame = tk.Frame(self.notebook) base_font = (self.current_font_family, self.current_font_size) text_area = self.get_current_text_area() @@ -365,26 +463,26 @@ def new_file(self): # Setup IME interception wid = str(text_area) - self._ime_states[wid] = {'active': False} - + self._ime_states[wid] = {"active": False} + def intercept_key(event): - """ + """ Detects when a key has been pressed and updates the document accordingly. - + :param event event: The keypress event. :return: A None value. """ state = self._ime_states[wid] - + # Detect IME activation (macOS IME emits empty char with keycode 0) - if event.char == '' and event.keycode == 0: - state['active'] = True + if event.char == "" and event.keycode == 0: + state["active"] = True return None # Deletion should mark modified but must not toggle IME state - if event.keysym in ('BackSpace', 'Delete'): + if event.keysym in ("BackSpace", "Delete"): if not self._loading_file: try: idx = self.notebook.index(self.notebook.select()) @@ -392,10 +490,10 @@ def intercept_key(event): except: pass return None - + # Detect IME deactivation (non-ASCII = committed text) if event.char and ord(event.char) > 127: - state['active'] = False + state["active"] = False # Mark tab as modified when committed Japanese text arrives if not self._loading_file: try: @@ -404,33 +502,33 @@ def intercept_key(event): except: pass return None - + # For lowercase ASCII letters during IME, delete them immediately - if state['active'] and event.char: + if state["active"] and event.char: if event.char.islower() and event.char.isalpha(): # Delete with highest priority (0 delay) text_area.after(0, lambda: delete_ime_char()) # For regular (non-IME) text input, mark as modified - elif event.char and not state['active']: + elif event.char and not state["active"]: if not self._loading_file: try: idx = self.notebook.index(self.notebook.select()) self.mark_tab_modified(idx) except: pass - + return None - + def delete_ime_char(): """ Takes the most recently inserted character, and if its a lowercase letter (IME composition character), delete it. """ - + try: # Get current cursor position - current_index = text_area.index('insert') + current_index = text_area.index("insert") # Calculate previous position - line, col = current_index.split('.') + line, col = current_index.split(".") prev_col = int(col) - 1 if prev_col >= 0: prev_index = f"{line}.{prev_col}" @@ -441,33 +539,37 @@ def delete_ime_char(): text_area.delete(prev_index, current_index) except Exception: pass - + # Bind KeyPress DIRECTLY with priority - text_area.bind('', intercept_key, add=False) - + text_area.bind("", intercept_key, add=False) + # Other bindings come after self.notebook.add(frame, text="") tab_index = len(self.editors) self.notebook.select(tab_index) self.editors.append(text_area) self.file_paths.append(None) - + # Add custom tab with close button self.add_label_and_close_button_to_tab(tab_index, "Untitled") - def open_file(self): """ Handles opening a file and sets up its file handler. """ - file_path = filedialog.askopenfilename(filetypes=[ - ("Markdown files", "*.md *.MD"), - ("HTML files", "*.html *.HTML *.htm *.HTM"), - ("PDF files", "*.pdf *.PDF"), - ("All files", "*.*") - ]) - if file_path and (file_path.lower().endswith(".md") or file_path.lower().endswith((".html", ".htm", ".pdf"))): + file_path = filedialog.askopenfilename( + filetypes=[ + ("Markdown files", "*.md *.MD"), + ("HTML files", "*.html *.HTML *.htm *.HTM"), + ("PDF files", "*.pdf *.PDF"), + ("All files", "*.*"), + ] + ) + if file_path and ( + file_path.lower().endswith(".md") + or file_path.lower().endswith((".html", ".htm", ".pdf")) + ): abs_path = os.path.abspath(file_path) self.md_filepath_list = [] self.md_filepath_list.append(abs_path) @@ -482,7 +584,6 @@ def open_file(self): self.load_file(abs_path) self.start_watching(abs_path) - def load_file(self, path): """ Loads the file from the given path and converts it to Markdown. @@ -491,7 +592,7 @@ def load_file(self, path): :raises RuntimeError: If the file fails to load. """ - + abs_path = os.path.abspath(path) idx = self.notebook.index(self.notebook.select()) text_area = self.get_current_text_area() @@ -501,7 +602,7 @@ def load_file(self, path): try: # Set loading flag to prevent marking as modified self._loading_file = True - + # Check if file is PDF and convert to Markdown if is_pdf: if self.use_docling_pdf: @@ -511,18 +612,18 @@ def load_file(self, path): else: with open(abs_path, "r", encoding="utf-8") as f: content = f.read() - + # Check if file is HTML and convert to Markdown if is_html: content = convert_html_to_markdown(content) - + # Clear the text area and insert new content text_area.delete("1.0", tk.END) text_area.insert(tk.END, content) - + # Reset the modified flag text_area.edit_modified(False) - + # Update tab info and save state if is_html or is_pdf: # Update tab name to show it's converted @@ -542,7 +643,7 @@ def load_file(self, path): finally: # Always clear the loading flag first self._loading_file = False - + # Delay marking tab state to ensure all events are processed # Use after_idle to run after all pending events in the queue if is_html or is_pdf: @@ -552,7 +653,6 @@ def load_file(self, path): # Mark as saved since we just loaded from file self.root.after(100, lambda: self.mark_tab_saved(idx)) - def close_current_tab(self): """ Closes the current tab and lets the notebook forget it. @@ -561,19 +661,18 @@ def close_current_tab(self): if not self.editors: return idx = self.notebook.index(self.notebook.select()) - + # Remove from modified tabs if present if idx in self.modified_tabs: self.modified_tabs.remove(idx) - + # Update indices in modified_tabs for tabs after the closed one self.modified_tabs = {i if i < idx else i - 1 for i in self.modified_tabs} - + self.notebook.forget(idx) del self.editors[idx] del self.file_paths[idx] - def close_all_tabs(self): """ Closes all tabs and empties the notebook. @@ -586,7 +685,6 @@ def close_all_tabs(self): # Clear modified tabs set self.modified_tabs.clear() - def on_tab_click(self, event): """ Handles click events on notebook tabs to detect close button clicks. @@ -599,56 +697,60 @@ def on_tab_click(self, event): try: # Identify which tab was clicked - element = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y) + element = self.notebook.tk.call( + self.notebook._w, "identify", "tab", event.x, event.y + ) if element == "": return - + tab_index = int(element) tab_text = self.notebook.tab(tab_index, "text") - + # Only proceed if × is in the tab text if "×" not in tab_text: return - # Get the tab's bounding box for x position - tab_bbox = self.notebook.bbox(tab_index) - if not tab_bbox: - return - - tab_x = tab_bbox[0] - relative_x = event.x - tab_x - # Use font to measure actual text width import tkinter.font as tkfont + # Get the default font used by ttk.Notebook tabs try: # Try to get the actual font from the style style = ttk.Style() - tab_font = tkfont.Font(font=style.lookup('TNotebook.Tab', 'font')) + tab_font = tkfont.Font(font=style.lookup("TNotebook.Tab", "font")) except: # Fallback to a reasonable default - tab_font = tkfont.Font(family='TkDefaultFont', size=10) - - # Measure the actual width of the tab text - text_width = tab_font.measure(tab_text) + tab_font = tkfont.Font(family="TkDefaultFont", size=10) + # Add padding (tabs usually have padding on both sides) tab_padding = 20 + + # Compute the left x of the clicked tab by summing widths of all preceding tabs + tab_x = 0 + for i in range(tab_index): + prev_text = self.notebook.tab(i, "text") + tab_x += tab_font.measure(prev_text) + tab_padding + + relative_x = event.x - tab_x + + # Measure the actual width of the tab text + text_width = tab_font.measure(tab_text) estimated_tab_width = text_width + tab_padding - + # The " ×" part is approximately 20-25 pixels # Only close if clicking in the rightmost 30 pixels close_button_width = 30 close_threshold = estimated_tab_width - close_button_width - + if relative_x >= close_threshold: self.close_tab_by_index(tab_index) return "break" # Prevent default tab selection behavior - + except Exception as e: print(f"Error in on_tab_click: {e}") import traceback - traceback.print_exc() + traceback.print_exc() def show_tab_context_menu(self, event): """ @@ -658,10 +760,12 @@ def show_tab_context_menu(self, event): :raises RuntimeError: If there is an error when attempting to display the context menu. """ - + try: # Identify which tab was clicked - clicked_tab = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y) + clicked_tab = self.notebook.tk.call( + self.notebook._w, "identify", "tab", event.x, event.y + ) if clicked_tab != "": # Store the clicked tab index for later use self.right_clicked_tab_index = int(clicked_tab) @@ -669,17 +773,17 @@ def show_tab_context_menu(self, event): self.tab_context_menu.post(event.x_root, event.y_root) except Exception as e: print(f"Error showing context menu: {e}") - def close_tab_from_context_menu(self): """ Close the tab that was right-clicked. """ - if hasattr(self, 'right_clicked_tab_index') and self.right_clicked_tab_index < len(self.editors): + if hasattr( + self, "right_clicked_tab_index" + ) and self.right_clicked_tab_index < len(self.editors): self.close_tab_by_index(self.right_clicked_tab_index) - def add_label_and_close_button_to_tab(self, tab_index, tab_text): """ Add a close button (×) to the tab text. @@ -688,33 +792,32 @@ def add_label_and_close_button_to_tab(self, tab_index, tab_text): :param int tab_index: The index of the tab to add the label and close button to. :param string tab_text: The text for the tab label. """ - + # Simply update the tab text with × symbol # The on_tab_click handler will detect clicks in the rightmost region self.notebook.tab(tab_index, text=f"{tab_text} ×") - - + def close_tab_by_index(self, idx): """ Closes the tab at the specified index. :param int idx: The index of the tab to be closed. """ - + if idx < len(self.editors): # Remove from modified tabs if present if idx in self.modified_tabs: self.modified_tabs.remove(idx) - + # Update indices in modified_tabs for tabs after the closed one self.modified_tabs = {i if i < idx else i - 1 for i in self.modified_tabs} - + self.notebook.forget(idx) del self.editors[idx] del self.file_paths[idx] - + # Update tab_widgets dictionary - if hasattr(self, 'tab_widgets'): + if hasattr(self, "tab_widgets"): # Remove the closed tab if idx in self.tab_widgets: del self.tab_widgets[idx] @@ -725,14 +828,13 @@ def close_tab_by_index(self, idx): new_widgets[new_key] = value self.tab_widgets = new_widgets - def start_watching(self, path): """ Sets up a watchdog.observers Observer instance to monitor the file for modifications. :param string path: The file path for the file to be monitored. """ - + if self.observer: self.observer.stop() self.observer.join() @@ -743,16 +845,14 @@ def start_watching(self, path): self.observer.schedule(event_handler, path=watch_dir, recursive=False) self.observer.start() - def on_text_change(self): """ When the text is changed, reapply all formatting and update the preview file. """ - + self.highlight_markdown() self.update_preview() - def toggle_dark_mode(self): """ Toggles between light and dark mode. @@ -765,7 +865,6 @@ def toggle_dark_mode(self): for text_area in self.editors: text_area.config(bg=bg, fg=fg, insertbackground=fg) - def highlight_markdown(self): """ Removes all existing formatting and inserts new tags based on the updated Markdown syntax. @@ -776,7 +875,7 @@ def highlight_markdown(self): - Lists (unordered and ordered). - Tables. """ - + # Get the current editor text_area = self.get_current_text_area() content = text_area.get("1.0", tk.END) @@ -786,28 +885,43 @@ def highlight_markdown(self): text_area.tag_remove(tag, "1.0", tk.END) # Use the selected font for highlighting - import tkinter.font import platform + import tkinter.font + system = platform.system() - if system == "Darwin": # macOS + if system == "Darwin": # macOS default_font = "Menlo" elif system == "Windows": default_font = "Consolas" - else: # Linux + else: # Linux default_font = "Ubuntu Mono" - font_name = getattr(self, 'current_font_family', default_font) - font_size = getattr(self, 'current_font_size', 14) - text_area.tag_configure("heading", foreground="#333333", font=(font_name, font_size + 4, "bold")) + font_name = getattr(self, "current_font_family", default_font) + font_size = getattr(self, "current_font_size", 14) + text_area.tag_configure( + "heading", foreground="#333333", font=(font_name, font_size + 4, "bold") + ) text_area.tag_configure("bold", font=(font_name, font_size, "bold")) text_area.tag_configure("italic", font=(font_name, font_size, "italic")) - text_area.tag_configure("code", foreground="#d19a66", background="#f6f8fa", font=(font_name, font_size)) + text_area.tag_configure( + "code", + foreground="#d19a66", + background="#f6f8fa", + font=(font_name, font_size), + ) text_area.tag_configure("link", foreground="#2aa198", underline=True) - text_area.tag_configure("blockquote", foreground="#6a737d", font=(font_name, font_size, "italic")) - text_area.tag_configure("list", foreground="#b58900", font=(font_name, font_size, "bold")) + text_area.tag_configure( + "blockquote", foreground="#6a737d", font=(font_name, font_size, "italic") + ) + text_area.tag_configure( + "list", foreground="#b58900", font=(font_name, font_size, "bold") + ) # ADD THIS NEW LINE: - text_area.tag_configure("table", foreground="#0066cc", font=(font_name, font_size)) + text_area.tag_configure( + "table", foreground="#0066cc", font=(font_name, font_size) + ) import re + lines = content.splitlines(keepends=True) pos = 0 for line in lines: @@ -837,7 +951,10 @@ def highlight_markdown(self): e = f"{pos + 1}.{m.end(2)}" text_area.tag_add("bold", s, e) # Italic: *text* or _text_ (not bold) - for m in re.finditer(r"(? 15: self._zoom_activated = True @@ -1110,7 +1216,6 @@ def _on_middle_drag(self, event): self.current_font_size = new_size return "break" - def _on_middle_release(self, event): """ Cleans up drag state and refreshes the preview after middle-click zoom. @@ -1120,20 +1225,19 @@ def _on_middle_release(self, event): :return: None to allow default behavior. """ - if hasattr(self, '_zoom_drag_y'): - if hasattr(self, '_zoom_activated') and self._zoom_activated: + if hasattr(self, "_zoom_drag_y"): + if hasattr(self, "_zoom_activated") and self._zoom_activated: self.update_preview() del self._zoom_drag_y del self._zoom_drag_base_size - if hasattr(self, '_zoom_activated'): + if hasattr(self, "_zoom_activated"): del self._zoom_activated - def toggle_bold(self): """ Takes the existing text area and adds/removes the bold Markdown syntax to toggle boldness. """ - + text_area = self.get_current_text_area() if not text_area: return @@ -1142,18 +1246,23 @@ def toggle_bold(self): sel_end = text_area.index("sel.last") selected_text = text_area.get(sel_start, sel_end) # If already bold, remove **, else add ** - if selected_text.startswith("**") and selected_text.endswith("**") and len(selected_text) > 4: + if ( + selected_text.startswith("**") + and selected_text.endswith("**") + and len(selected_text) > 4 + ): new_text = selected_text[2:-2] else: new_text = f"**{selected_text}**" text_area.delete(sel_start, sel_end) text_area.insert(sel_start, new_text) except tk.TclError: - dialogs.Messagebox.show_info("No selection", "Please select text to make bold.") + dialogs.Messagebox.show_info( + "No selection", "Please select text to make bold." + ) return self.update_preview() - def toggle_italic(self): """ Takes the existing text area and adds/removes the italic Markdown syntax to toggle italicness. @@ -1167,19 +1276,27 @@ def toggle_italic(self): sel_end = text_area.index("sel.last") selected_text = text_area.get(sel_start, sel_end) # If already italic, remove *, else add * (single asterisk) - if (selected_text.startswith("*") and selected_text.endswith("*") and len(selected_text) > 2) or \ - (selected_text.startswith("_") and selected_text.endswith("_") and len(selected_text) > 2): + if ( + selected_text.startswith("*") + and selected_text.endswith("*") + and len(selected_text) > 2 + ) or ( + selected_text.startswith("_") + and selected_text.endswith("_") + and len(selected_text) > 2 + ): new_text = selected_text[1:-1] else: new_text = f"*{selected_text}*" text_area.delete(sel_start, sel_end) text_area.insert(sel_start, new_text) except tk.TclError: - dialogs.Messagebox.show_info("No selection", "Please select text to make italic.") + dialogs.Messagebox.show_info( + "No selection", "Please select text to make italic." + ) return self.update_preview() - def toggle_underline(self): """ Takes the existing text area and adds/removes the underline Markdown syntax to toggle underline. @@ -1193,31 +1310,37 @@ def toggle_underline(self): sel_end = text_area.index("sel.last") selected_text = text_area.get(sel_start, sel_end) # Use HTML for underline in Markdown (not standard, but works in many renderers) - if selected_text.startswith("") and selected_text.endswith("") and len(selected_text) > 7: + if ( + selected_text.startswith("") + and selected_text.endswith("") + and len(selected_text) > 7 + ): new_text = selected_text[3:-4] else: new_text = f"{selected_text}" text_area.delete(sel_start, sel_end) text_area.insert(sel_start, new_text) except tk.TclError: - dialogs.Messagebox.show_info("No selection", "Please select text to underline.") + dialogs.Messagebox.show_info( + "No selection", "Please select text to underline." + ) return self.update_preview() - def choose_fg_color(self): """ Prompts the user to select a foreground colour, then applies it to the preview. :raises tk.TclError: If there is no text selection to apply the color to, or if there is an error accessing the text area or its contents. """ - + import re + cd = dialogs.ColorChooserDialog() cd.show() result = cd.result if result: - color_hex = result.hex if hasattr(result, 'hex') else result + color_hex = result.hex if hasattr(result, "hex") else result text_area = self.get_current_text_area() if text_area: try: @@ -1226,11 +1349,17 @@ def choose_fg_color(self): selected_text = text_area.get(sel_start, sel_end) if selected_text.strip() == "" or "\n" in selected_text: - dialogs.Messagebox.show_info("Tip", "Please select single-line non-empty text to set color.") + dialogs.Messagebox.show_info( + "Tip", + "Please select single-line non-empty text to set color.", + ) return cleaned_text = re.sub( - r']+?">(.*?)', r'\1', selected_text, flags=re.DOTALL + r']+?">(.*?)', + r"\1", + selected_text, + flags=re.DOTALL, ) new_text = f'{cleaned_text}' @@ -1240,13 +1369,14 @@ def choose_fg_color(self): self.current_fg_color = color_hex self.update_preview() except tk.TclError: - dialogs.Messagebox.show_info("No selection", "Please select text to color.") - + dialogs.Messagebox.show_info( + "No selection", "Please select text to color." + ) def choose_bg_color(self): """ Prompts the user to select a background colour, then applies it to the preview. - """ + """ cd = dialogs.ColorChooserDialog() cd.show() @@ -1258,13 +1388,12 @@ def choose_bg_color(self): text_area.config(bg=color) self.current_bg_color = color self.update_preview() - def update_preview(self): """ Checks that a file is open, and that there is a loaded text area, then updates the preview file. """ - + if not self.current_file_path: return text_area = self.get_current_text_area() @@ -1272,14 +1401,13 @@ def update_preview(self): return update_preview(self) - def undo_action(self): """ Checks if there is a loaded text area, and that changes have been made, then attempts to undo the most recent change. :raises tk.TclError: If there is an error performing the undo operation, such as if there are no actions to undo. """ - + text_area = self.get_current_text_area() if text_area and text_area.edit_modified(): try: @@ -1287,7 +1415,6 @@ def undo_action(self): except tk.TclError: pass - def redo_action(self): """ Checks if there is a loaded text area, then attempts to redo the most recently undone change. @@ -1314,55 +1441,63 @@ def _prompt_translation_languages(self): dialog.title("AI Translation - Select Languages") dialog.transient(self.root) dialog.grab_set() - + # Center dialog on parent dialog.geometry("400x280") x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 200 y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 140 dialog.geometry(f"+{x}+{y}") - + result = {"source": None, "target": None, "confirmed": False} - + # Source language ttk.Label(dialog, text="Source Language:").pack(pady=(20, 5)) - source_combo = ttk.Combobox(dialog, values=self.translation_languages, state="readonly", width=30) + source_combo = ttk.Combobox( + dialog, values=self.translation_languages, state="readonly", width=30 + ) if self.translation_source_language in self.translation_languages: source_combo.set(self.translation_source_language) else: source_combo.current(0) source_combo.pack(pady=5) - + # Target language ttk.Label(dialog, text="Target Language:").pack(pady=(10, 5)) - target_combo = ttk.Combobox(dialog, values=self.translation_languages, state="readonly", width=30) + target_combo = ttk.Combobox( + dialog, values=self.translation_languages, state="readonly", width=30 + ) if self.translation_target_language in self.translation_languages: target_combo.set(self.translation_target_language) else: target_combo.current(1) target_combo.pack(pady=5) - + def on_ok(): result["source"] = source_combo.get() result["target"] = target_combo.get() result["confirmed"] = True dialog.destroy() - + def on_cancel(): dialog.destroy() - + # Buttons button_frame = ttk.Frame(dialog) button_frame.pack(pady=20) - ttk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side=tk.LEFT, padx=5) - + ttk.Button(button_frame, text="OK", command=on_ok, width=10).pack( + side=tk.LEFT, padx=5 + ) + ttk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack( + side=tk.LEFT, padx=5 + ) + # Bind Enter and Escape keys dialog.bind("", lambda e: on_ok()) dialog.bind("", lambda e: on_cancel()) - + # Wait for dialog to close dialog.wait_window() - + if not result["confirmed"]: return None, None @@ -1385,7 +1520,9 @@ def _get_translation_scope(self, text_area, selected_only): start_idx = text_area.index("sel.first") end_idx = text_area.index("sel.last") except tk.TclError: - dialogs.Messagebox.show_info("No selection", "Please select text before translating.") + dialogs.Messagebox.show_info( + "No selection", "Please select text before translating." + ) return None, None, None else: start_idx = "1.0" @@ -1393,12 +1530,16 @@ def _get_translation_scope(self, text_area, selected_only): source_text = text_area.get(start_idx, end_idx) if not source_text.strip(): - dialogs.Messagebox.show_info("Empty content", "Nothing to translate in the chosen scope.") + dialogs.Messagebox.show_info( + "Empty content", "Nothing to translate in the chosen scope." + ) return None, None, None return start_idx, end_idx, source_text - def _apply_translation_result(self, tab_index, text_area, start_idx, end_idx, translated_text, ambiguity_notes): + def _apply_translation_result( + self, tab_index, text_area, start_idx, end_idx, translated_text, ambiguity_notes + ): """ Applies translated content back to the editor and keeps undo/redo usable. """ @@ -1419,7 +1560,9 @@ def _apply_translation_result(self, tab_index, text_area, start_idx, end_idx, tr "Translation completed with ambiguity notes:\n\n" + note_text, ) except Exception as exc: - dialogs.Messagebox.show_error("Translation Error", f"Failed to apply translation: {exc}") + dialogs.Messagebox.show_error( + "Translation Error", f"Failed to apply translation: {exc}" + ) finally: self.root.config(cursor="") @@ -1432,7 +1575,9 @@ def translate_with_ai(self, selected_only=False): text_area = self.get_current_text_area() if not text_area: - dialogs.Messagebox.show_info("No document", "Please open or create a document first.") + dialogs.Messagebox.show_info( + "No document", "Please open or create a document first." + ) return source_lang, target_lang = self._prompt_translation_languages() @@ -1440,10 +1585,14 @@ def translate_with_ai(self, selected_only=False): return if source_lang.strip().lower() == target_lang.strip().lower(): - dialogs.Messagebox.show_info("Language Selection", "Source and target language are the same.") + dialogs.Messagebox.show_info( + "Language Selection", "Source and target language are the same." + ) return - start_idx, end_idx, source_text = self._get_translation_scope(text_area, selected_only) + start_idx, end_idx, source_text = self._get_translation_scope( + text_area, selected_only + ) if source_text is None: return @@ -1472,7 +1621,14 @@ def worker(): ), ) except Exception as exc: - current_provider = (self.ai_provider_var.get() or os.getenv("AI_PROVIDER", "openrouter")).strip().lower() + current_provider = ( + ( + self.ai_provider_var.get() + or os.getenv("AI_PROVIDER", "openrouter") + ) + .strip() + .lower() + ) self.root.after( 0, lambda: ( @@ -1500,12 +1656,16 @@ def set_ai_provider(self, provider_name): normalized = "anthropic" if normalized not in ("openrouter", "openai", "anthropic"): - dialogs.Messagebox.show_error("Invalid Provider", f"Unsupported provider: {provider_name}") + dialogs.Messagebox.show_error( + "Invalid Provider", f"Unsupported provider: {provider_name}" + ) return self.ai_provider_var.set(normalized) os.environ["AI_PROVIDER"] = normalized - dialogs.Messagebox.show_info("AI Provider", f"AI provider switched to: {normalized}") + dialogs.Messagebox.show_info( + "AI Provider", f"AI provider switched to: {normalized}" + ) def toggle_pdf_mode(self): """ @@ -1534,7 +1694,7 @@ def show_pdf_converter_info(self): "Current Mode: {}\n\n" "You can switch in Tools > Use Advanced PDF Conversion (Docling)" ).format("Docling (Advanced)" if self.use_docling_pdf else "PyMuPDF (Fast)") - + messagebox.showinfo("PDF Converter Information", info_text) def insert_table(self): @@ -1545,7 +1705,7 @@ def insert_table(self): text_area = self.get_current_text_area() if not text_area: return - + # Create dialog dialog = tk.Toplevel(self.root) dialog.title("Insert Table") @@ -1556,11 +1716,11 @@ def insert_table(self): secondary_text_color = "#BDBDBD" input_bg_color = "#2A2A2A" input_fg_color = "#F5F5F5" - + # Configuration frame at top config_frame = tk.Frame(dialog, relief=tk.RAISED, borderwidth=1) config_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) - + tk.Label( config_frame, text="Rows (including header):", @@ -1580,8 +1740,10 @@ def insert_table(self): buttonbackground=input_bg_color, ) rows_spinbox.grid(row=0, column=1, padx=5, pady=5) - - tk.Label(config_frame, text="Columns:", anchor="w", fg=text_color).grid(row=0, column=2, padx=5, pady=5, sticky="w") + + tk.Label(config_frame, text="Columns:", anchor="w", fg=text_color).grid( + row=0, column=2, padx=5, pady=5, sticky="w" + ) cols_var = tk.IntVar(value=3) cols_spinbox = tk.Spinbox( config_frame, @@ -1595,61 +1757,71 @@ def insert_table(self): buttonbackground=input_bg_color, ) cols_spinbox.grid(row=0, column=3, padx=5, pady=5) - + # Canvas frame for table grid canvas_frame = tk.Frame(dialog) canvas_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - + canvas = tk.Canvas(canvas_frame, borderwidth=0) - scrollbar_v = tk.Scrollbar(canvas_frame, orient="vertical", command=canvas.yview) - scrollbar_h = tk.Scrollbar(canvas_frame, orient="horizontal", command=canvas.xview) + scrollbar_v = tk.Scrollbar( + canvas_frame, orient="vertical", command=canvas.yview + ) + scrollbar_h = tk.Scrollbar( + canvas_frame, orient="horizontal", command=canvas.xview + ) scrollable_frame = tk.Frame(canvas) - + scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) - + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar_v.set, xscrollcommand=scrollbar_h.set) - + canvas.pack(side="left", fill="both", expand=True) scrollbar_v.pack(side="right", fill="y") scrollbar_h.pack(side="bottom", fill="x") - + # Store cell entries cell_entries = [] - + def create_table_grid(): """ Creates or recreates the table grid based on current dimensions. """ - + # Clear existing entries for widget in scrollable_frame.winfo_children(): widget.destroy() cell_entries.clear() - + rows = rows_var.get() cols = cols_var.get() - + # Create grid of entry widgets for r in range(rows): row_entries = [] for c in range(cols): # Determine default content if r == 0: - default_text = f"Header {c+1}" + default_text = f"Header {c + 1}" else: - default_text = f"Cell {r}-{c+1}" - + default_text = f"Cell {r}-{c + 1}" + # Create entry with label - cell_frame = tk.Frame(scrollable_frame, relief=tk.RIDGE, borderwidth=1) + cell_frame = tk.Frame( + scrollable_frame, relief=tk.RIDGE, borderwidth=1 + ) cell_frame.grid(row=r, column=c, padx=2, pady=2, sticky="nsew") - - label = tk.Label(cell_frame, text=f"[{r},{c}]", font=("Arial", 8), fg=secondary_text_color) + + label = tk.Label( + cell_frame, + text=f"[{r},{c}]", + font=("Arial", 8), + fg=secondary_text_color, + ) label.pack(anchor="nw", padx=2, pady=2) - + entry = tk.Entry( cell_frame, width=15, @@ -1659,29 +1831,29 @@ def create_table_grid(): ) entry.insert(0, default_text) entry.pack(padx=5, pady=5, fill=tk.BOTH, expand=True) - + row_entries.append(entry) - + cell_entries.append(row_entries) - + # Make columns expandable for c in range(cols): scrollable_frame.columnconfigure(c, weight=1) - + def update_table_grid(*args): """ Updates the table grid when dimensions change. """ - + create_table_grid() - + # Bind spinbox changes to update grid rows_var.trace_add("write", update_table_grid) cols_var.trace_add("write", update_table_grid) - + # Initial table grid create_table_grid() - + def insert_table_content(): """ Generates table markdown from entry widgets and updates the preview file. @@ -1690,33 +1862,39 @@ def insert_table_content(): rows = rows_var.get() cols = cols_var.get() table_lines = [] - + # Header row - header_values = [entry.get().strip() or f"Header {i+1}" for i, entry in enumerate(cell_entries[0])] + header_values = [ + entry.get().strip() or f"Header {i + 1}" + for i, entry in enumerate(cell_entries[0]) + ] header = "| " + " | ".join(header_values) + " |" table_lines.append(header) - + # Separator row separator = "| " + " | ".join(["---" for _ in range(cols)]) + " |" table_lines.append(separator) - + # Data rows for row_idx in range(1, rows): - row_values = [entry.get().strip() or f"Cell {row_idx}-{i+1}" for i, entry in enumerate(cell_entries[row_idx])] + row_values = [ + entry.get().strip() or f"Cell {row_idx}-{i + 1}" + for i, entry in enumerate(cell_entries[row_idx]) + ] data_row = "| " + " | ".join(row_values) + " |" table_lines.append(data_row) - + table_text = "\n" + "\n".join(table_lines) + "\n\n" - + # Insert at cursor position text_area.insert("insert", table_text) dialog.destroy() self.update_preview() - + # Button frame button_frame = tk.Frame(dialog) button_frame.pack(side=tk.BOTTOM, pady=10) - + ttkb.Button( button_frame, text="Insert Table", @@ -1731,25 +1909,25 @@ def insert_table_content(): width=15, bootstyle=(SECONDARY, OUTLINE), ).pack(side=tk.LEFT, padx=5) - + # Set dialog size based on initial table dimensions width = min(800, 180 * cols_var.get() + 120) height = min(650, 80 * rows_var.get() + 200) dialog.geometry(f"{width}x{height}") - + # Center the dialog dialog.update_idletasks() x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2) y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2) dialog.geometry(f"+{x}+{y}") - + dialog.wait_window() def show_table_help(self): """ Shows the help dialog for table syntax. """ - + help_text = """📋 Markdown Table Syntax Guide Basic Table Structure: @@ -1794,15 +1972,15 @@ def export_to_html_dialog(self): """ Shows dialog to export current the markdown document to HTML. """ - + if not self.editors: dialogs.Messagebox.show_info("Info", "No document to export.") return - + # Get current file path to suggest HTML filename idx = self.notebook.index(self.notebook.select()) current_path = self.file_paths[idx] - + # Suggest filename if current_path: base_name = os.path.splitext(os.path.basename(current_path))[0] @@ -1811,33 +1989,32 @@ def export_to_html_dialog(self): else: initial_dir = os.path.expanduser("~") initial_file = "document.html" - + # Show save dialog output_path = filedialog.asksaveasfilename( defaultextension=".html", filetypes=[("HTML files", "*.html"), ("All files", "*.*")], initialdir=initial_dir, initialfile=initial_file, - title="Export to HTML" + title="Export to HTML", ) - + if output_path: export_to_html(self, output_path) - def export_to_docx_dialog(self): """ Shows the dialog to export the current markdown document to Word. """ - + if not self.editors: dialogs.Messagebox.show_info("Info", "No document to export.") return - + # Get current file path to suggest Word filename idx = self.notebook.index(self.notebook.select()) current_path = self.file_paths[idx] - + # Suggest filename if current_path: base_name = os.path.splitext(os.path.basename(current_path))[0] @@ -1846,33 +2023,32 @@ def export_to_docx_dialog(self): else: initial_dir = os.path.expanduser("~") initial_file = "document.docx" - + # Show save dialog output_path = filedialog.asksaveasfilename( defaultextension=".docx", filetypes=[("Word documents", "*.docx"), ("All files", "*.*")], initialdir=initial_dir, initialfile=initial_file, - title="Export to Word" + title="Export to Word", ) - + if output_path: export_to_docx(self, output_path) - def export_to_pdf_dialog(self): """ Shows the dialog to export the current markdown document to PDF. """ - + if not self.editors: dialogs.Messagebox.show_info("Info", "No document to export.") return - + # Get current file path to suggest PDF filename idx = self.notebook.index(self.notebook.select()) current_path = self.file_paths[idx] - + # Suggest filename if current_path: base_name = os.path.splitext(os.path.basename(current_path))[0] @@ -1881,16 +2057,16 @@ def export_to_pdf_dialog(self): else: initial_dir = os.path.expanduser("~") initial_file = "document.pdf" - + # Show save dialog output_path = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF documents", "*.pdf"), ("All files", "*.*")], initialdir=initial_dir, initialfile=initial_file, - title="Export to PDF" + title="Export to PDF", ) - + if not output_path: return @@ -1899,8 +2075,7 @@ def export_to_pdf_dialog(self): # Convert to HTML (preserve fenced code blocks and common markdown features) html_content = markdown.markdown( - md_content, - extensions=["fenced_code", "tables", "nl2br", "sane_lists"] + md_content, extensions=["fenced_code", "tables", "nl2br", "sane_lists"] ) # Determine base URL for resolving relative image paths