From 4bc64e8517733a32c3b9be877d6d2a6f911ffcd6 Mon Sep 17 00:00:00 2001 From: Tobia Rigon <42972324+TobiaRigon@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:37:08 +0200 Subject: [PATCH 1/2] Refactor GUI and add configurable tokenizer --- __main__.spec | 39 -- promptpack.spec | 7 +- promptpack/cli.py | 5 + promptpack/gui.py | 834 ------------------------------ promptpack/gui/__init__.py | 3 + promptpack/gui/file_selector.py | 173 +++++++ promptpack/gui/main_window.py | 301 +++++++++++ promptpack/gui/preview.py | 206 ++++++++ promptpack/gui/progress.py | 38 ++ promptpack/gui/settings_dialog.py | 137 +++++ promptpack/gui/theme.py | 86 +++ promptpack/settings.py | 3 + promptpack/tokenizer.py | 19 +- promptpack_settings.json | 1 + 14 files changed, 973 insertions(+), 879 deletions(-) delete mode 100644 __main__.spec delete mode 100644 promptpack/gui.py create mode 100644 promptpack/gui/__init__.py create mode 100644 promptpack/gui/file_selector.py create mode 100644 promptpack/gui/main_window.py create mode 100644 promptpack/gui/preview.py create mode 100644 promptpack/gui/progress.py create mode 100644 promptpack/gui/settings_dialog.py create mode 100644 promptpack/gui/theme.py diff --git a/__main__.spec b/__main__.spec deleted file mode 100644 index ba90470..0000000 --- a/__main__.spec +++ /dev/null @@ -1,39 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - - -a = Analysis( - ['promptpack\\__main__.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='__main__', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=['promptpack.ico'], -) diff --git a/promptpack.spec b/promptpack.spec index d4e3d2f..c084515 100644 --- a/promptpack.spec +++ b/promptpack.spec @@ -1,11 +1,12 @@ # -*- mode: python ; coding: utf-8 -*- +block_cipher = None a = Analysis( - ['promptpack.py'], - pathex=[], + ['promptpack/__main__.py'], + pathex=['.'], binaries=[], - datas=[], + datas=[('promptpack/locales/*.json', 'promptpack/locales')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/promptpack/cli.py b/promptpack/cli.py index 8ddb68d..64db4da 100644 --- a/promptpack/cli.py +++ b/promptpack/cli.py @@ -2,6 +2,7 @@ from pathlib import Path from .settings import load_settings from .utils import generate_output +from .tokenizer import set_default_model from pathspec import PathSpec @@ -33,9 +34,13 @@ def main(): parser.add_argument("--no-heading", action="store_true") parser.add_argument("--no-code-block", action="store_true") parser.add_argument("--max-tokens", type=int) + parser.add_argument("--model", choices=["gpt", "claude", "gemini"], help="tokenizer model") parser.add_argument("--progress", action="store_true") args = parser.parse_args() + if args.model: + set_default_model(args.model) + settings = load_settings() export_format = args.format or settings.get("export_format", "md") tree_only = args.tree_only or settings.get("tree_only", False) diff --git a/promptpack/gui.py b/promptpack/gui.py deleted file mode 100644 index 90f0a15..0000000 --- a/promptpack/gui.py +++ /dev/null @@ -1,834 +0,0 @@ -import tkinter as tk -from tkinter import filedialog, messagebox, simpledialog, Toplevel, ttk -from pathlib import Path -from datetime import datetime -from tempfile import NamedTemporaryFile -import webbrowser -import markdown -import threading - -from .settings import load_settings, save_settings -from pathspec import PathSpec -from .utils import apply_icon, estimate_token_count, LANG_MAP, generate_output, sanitize_sensitive_data -from .i18n import load_translations, available_languages - - -class Tooltip: - def __init__(self, widget, text: str): - self.widget = widget - self.text = text - self.tipwindow = None - widget.bind("", self.show) - widget.bind("", self.hide) - - def show(self, _event=None): - if self.tipwindow or not self.text: - return - x = self.widget.winfo_rootx() + 20 - y = self.widget.winfo_rooty() + self.widget.winfo_height() + 10 - self.tipwindow = tw = tk.Toplevel(self.widget) - tw.wm_overrideredirect(True) - tw.geometry(f"+{x}+{y}") - label = ttk.Label(tw, text=self.text, relief="solid", borderwidth=1, background="#ffffe0") - label.pack() - - def hide(self, _event=None): - if self.tipwindow: - self.tipwindow.destroy() - self.tipwindow = None - -EN_TRANSLATIONS = load_translations("eng") - - - -class ListDialog(simpledialog.Dialog): - """Simple entry dialog that supports a custom window icon.""" - - def __init__(self, parent, title, initial_value=""): - self.initial_value = initial_value - self.dialog_title = title - super().__init__(parent, title) - - def body(self, master): - apply_icon(self) - ttk.Label(master, text=f"{self.dialog_title} (comma separated):").pack(padx=5, pady=5) - self.entry = ttk.Entry(master, width=50) - self.entry.pack(padx=5, pady=5) - self.entry.insert(0, self.initial_value) - return self.entry - - def apply(self): - self.result = self.entry.get() - - -class PromptPackApp: - def load_translations(self): - self.translations = load_translations(self.language.get()) - - def t(self, key: str, **kwargs) -> str: - template = self.translations.get(key, EN_TRANSLATIONS.get(key, key)) - return template.format(**kwargs) - - def __init__(self, root: tk.Tk): - self.root = root - root.title("PromptPack") - apply_icon(root) - - - self.settings = load_settings() - - self.export_format = tk.StringVar(value=self.settings.get("export_format", "md")) - self.tree_only = tk.BooleanVar(value=self.settings.get("tree_only", False)) - self.include_heading = tk.BooleanVar(value=self.settings["include_heading"]) - self.use_code_block = tk.BooleanVar(value=self.settings["use_code_block"]) - self.theme = tk.StringVar(value=self.settings.get("theme", "dark")) - self.language = tk.StringVar(value=self.settings.get("language", "eng")) - self.load_translations() - self.enable_preview = tk.BooleanVar(value=False) - - self.start_folder = tk.StringVar(value=self.settings.get("last_start_folder", "")) - self.dest_folder = tk.StringVar() - self.selected_files = set() - self.gitignore_spec = None - - self.preview_window = None - self.preview_text = None - self.progress_frame = None - self.progress_bar = None - self.progress_var = tk.IntVar(value=0) - self.limit_warning_displayed = False - - style = ttk.Style(self.root) - style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) - - self.build_gui() - self.update_texts() - - folder = self.start_folder.get() - if folder: - self.update_default_selected_files(Path(folder)) - saved = { - Path(folder) / Path(p) - for p in self.settings.get("last_selected_files", []) - } - saved_existing = {p for p in saved if p.exists()} - if saved_existing: - self.selected_files = saved_existing - - self.apply_theme() - - def build_gui(self): - # Rende le 3 colonne espandibili per migliore distribuzione - for i in range(3): - self.root.grid_columnconfigure(i, weight=1) - - heading_opts = { - "style": "Heading.TLabel", - "anchor": "center" - } - - # Source section - self.source_heading = ttk.Label(self.root, text=self.t("source"), **heading_opts) - self.source_heading.grid(row=0, column=1, sticky="ew", padx=10, pady=(20, 5)) - - self.source_label = ttk.Label(self.root, text=self.t("source_folder")) - self.source_label.grid(row=1, column=0, sticky="w", padx=10, pady=5) - - entry = ttk.Entry(self.root, textvariable=self.start_folder, width=50) - entry.grid(row=1, column=1, padx=5, pady=5) - - self.browse_start_btn = ttk.Button(self.root, text=self.t("browse"), command=self.browse_start) - self.browse_start_btn.grid(row=1, column=2, padx=5, pady=5) - - self.select_files_btn = ttk.Button(self.root, text=self.t("select_files"), command=self.select_files) - self.select_files_btn.grid(row=2, column=1, pady=5) - - # Preview section - self.preview_heading = ttk.Label(self.root, text=self.t("preview"), **heading_opts) - self.preview_heading.grid(row=3, column=1, sticky="ew", padx=10, pady=(20, 5)) - - self.browser_btn = ttk.Button(self.root, text=self.t("browser"), command=self.preview_in_browser) - self.browser_btn.grid(row=4, column=1, pady=5) - Tooltip(self.browser_btn, self.t("open_browser_tip")) - - self.live_preview_cb = ttk.Checkbutton( - self.root, - text=self.t("live_preview"), - variable=self.enable_preview, - command=self.toggle_preview_window, - ) - self.live_preview_cb.grid(row=4, column=2, sticky="w", padx=5) - - # Output section - self.output_heading = ttk.Label(self.root, text=self.t("output"), **heading_opts) - self.output_heading.grid(row=5, column=1, sticky="ew", padx=10, pady=(20, 5)) - - self.dest_label = ttk.Label(self.root, text=self.t("dest_folder")) - self.dest_label.grid(row=6, column=0, sticky="w", padx=10, pady=5) - - ttk.Entry(self.root, textvariable=self.dest_folder, width=50)\ - .grid(row=6, column=1, padx=5, pady=5) - - self.browse_dest_btn = ttk.Button(self.root, text=self.t("browse"), command=self.browse_dest) - self.browse_dest_btn.grid(row=6, column=2, padx=5, pady=5) - - self.generate_btn = ttk.Button(self.root, text=self.t("generate"), command=self.generate) - self.generate_btn.grid(row=7, column=1, pady=10) - - # Settings gear button - self.gear_button = ttk.Button( - self.root, - text=self.t("settings"), - command=self.configure_settings, - style="Gear.TButton" - ) - self.gear_button.grid(row=8, column=2, sticky="e", pady=5, padx=5) - self.gear_button.bind("", lambda e: self.gear_button.config(cursor="hand2")) - self.gear_button.bind("", lambda e: self.gear_button.config(cursor="")) - Tooltip(self.gear_button, self.t("settings_tip")) - - def update_texts(self): - self.source_heading.config(text=self.t("source")) - self.source_label.config(text=self.t("source_folder")) - self.browse_start_btn.config(text=self.t("browse")) - self.select_files_btn.config(text=self.t("select_files")) - self.preview_heading.config(text=self.t("preview")) - self.browser_btn.config(text=self.t("browser")) - self.live_preview_cb.config(text=self.t("live_preview")) - self.output_heading.config(text=self.t("output")) - self.dest_label.config(text=self.t("dest_folder")) - self.browse_dest_btn.config(text=self.t("browse")) - self.generate_btn.config(text=self.t("generate")) - self.gear_button.config(text=self.t("settings")) - - - - - def apply_theme(self): - if self.theme.get() == "dark": - palette = { - "background": "#2d2d2d", - "foreground": "#dcdcdc", - "activeBackground": "#505050", - "activeForeground": "#ffffff", - } - else: - palette = { - "background": "#ffffff", - "foreground": "#000000", - "activeBackground": "#e0e0e0", - "activeForeground": "#000000", - } - - self.root.configure(bg=palette["background"]) - self.root.tk_setPalette(**palette) - style = ttk.Style(self.root) - style.theme_use("clam") - style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) - style.configure( - ".", - background=palette["background"], - foreground=palette["foreground"], - ) - style.configure( - "TButton", - background=palette["background"], - foreground=palette["foreground"], - ) - style.map( - "TButton", - background=[("active", palette["activeBackground"])], - foreground=[("active", palette["activeForeground"])], - ) - style.configure( - "TEntry", - fieldbackground=palette["background"], - background=palette["background"], - foreground=palette["foreground"], - insertcolor=palette["foreground"], - ) - style.configure( - "TLabel", - background=palette["background"], - foreground=palette["foreground"], - ) - style.configure( - "TCheckbutton", - background=palette["background"], - foreground=palette["foreground"], - ) - style.configure( - "Gear.TButton", - relief="flat", - borderwidth=0, - background=palette["background"], - foreground=palette["foreground"], - padding=2, - ) - style.map("Gear.TButton", background=[], foreground=[]) - - style.map("TCheckbutton", background=[], foreground=[]) - style.configure( - "TRadiobutton", - background=palette["background"], - foreground=palette["foreground"], - ) - style.map("TRadiobutton", background=[], foreground=[]) - style.configure( - "TMenubutton", - background=palette["background"], - foreground=palette["foreground"], - ) - style.map("TMenubutton", background=[], foreground=[]) - - if self.preview_window and self.preview_window.winfo_exists(): - self.preview_window.tk_setPalette(**palette) - if self.preview_text: - self.preview_text.configure(bg=palette["background"], fg=palette["foreground"]) - - def show_progress(self, message: str, maximum: int | None = None): - if self.progress_frame: - self.progress_frame.destroy() - self.progress_var.set(0) - self.progress_frame = ttk.Frame(self.root) - self.progress_frame.grid(row=9, column=0, columnspan=3, pady=(10, 5)) - ttk.Label(self.progress_frame, text=message).pack(padx=10, pady=5) - mode = "indeterminate" if maximum is None else "determinate" - self.progress_bar = ttk.Progressbar( - self.progress_frame, - variable=self.progress_var, - maximum=maximum if maximum is not None else 100, - mode=mode, - ) - self.progress_bar.pack(padx=10, pady=5, fill="x", expand=True) - if mode == "indeterminate": - self.progress_bar.start() - - def update_progress(self, value: int, maximum: int): - if self.progress_bar and self.progress_bar["mode"] == "determinate": - self.progress_bar.config(maximum=maximum) - self.progress_var.set(value) - self.progress_bar.update_idletasks() - - def hide_progress(self): - if self.progress_bar: - if self.progress_bar["mode"] == "indeterminate": - self.progress_bar.stop() - if self.progress_frame: - self.progress_frame.destroy() - self.progress_frame = None - self.progress_bar = None - - def build_preview_async(self, files): - self.show_progress(self.t("preview")) - - def worker(): - text = self.get_preview_text(files) - - def update(): - if self.preview_window is None or not self.preview_window.winfo_exists(): - self.preview_window = Toplevel(self.root) - self.preview_window.title(self.t("preview")) - self.preview_window.resizable(True, True) - apply_icon(self.preview_window) - - frame = ttk.Frame(self.preview_window) - frame.pack(fill="both", expand=True) - - self.preview_text = tk.Text(frame, wrap="word") - yscroll = ttk.Scrollbar(frame, command=self.preview_text.yview) - self.preview_text.configure(yscrollcommand=yscroll.set) - self.preview_text.pack(side="left", fill="both", expand=True) - yscroll.pack(side="right", fill="y") - - ttk.Button( - self.preview_window, - text=self.t("copy"), - command=lambda: self.copy_text_widget(self.preview_text), - ).pack(pady=5) - self.apply_theme() - self.preview_text.delete("1.0", "end") - self.preview_text.insert("1.0", text) - self.hide_progress() - - self.root.after(0, update) - - threading.Thread(target=worker, daemon=True).start() - - def browse_start(self): - folder = filedialog.askdirectory() - if folder: - self.start_folder.set(folder) - self.update_default_selected_files(Path(folder)) - self.settings["last_start_folder"] = folder - self.settings["last_selected_files"] = [] - save_settings(self.settings) - - - def browse_dest(self): - folder = filedialog.askdirectory() - if folder: - self.dest_folder.set(folder) - - def configure_settings(self): - win = Toplevel(self.root) - win.title(self.t("settings_title")) - apply_icon(win) - win.resizable(True, True) - - container = ttk.Frame(win) - container.pack(fill="both", expand=True) - - canvas = tk.Canvas(container, highlightthickness=0) - scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview) - canvas.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side="right", fill="y") - canvas.pack(side="left", fill="both", expand=True) - - scrollable = ttk.Frame(canvas) - canvas.create_window((0, 0), window=scrollable, anchor="nw") - - scrollable.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - style = ttk.Style(win) - style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) - - - def prompt_list(title, key): - dlg = ListDialog(win, title, initial_value=",".join(self.settings[key])) - result = dlg.result - if result is not None: - self.settings[key] = [x.strip() for x in result.split(",") if x.strip()] - ttk.Label(scrollable, text=self.t("default_selection"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) - ttk.Button(scrollable, text=self.t("allowed_exts"), command=lambda: prompt_list(self.t("allowed_exts"), "allowed_exts")).pack(pady=5) - ttk.Button(scrollable, text=self.t("excluded_dirs"), command=lambda: prompt_list(self.t("excluded_dirs"), "excluded_dirs")).pack(pady=5) - ttk.Button(scrollable, text=self.t("excluded_files"), command=lambda: prompt_list(self.t("excluded_files"), "excluded_files")).pack(pady=5) - - ttk.Label(scrollable, text=self.t("output_opts"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) - ttk.Label(scrollable, text=self.t("export_format")).pack(pady=(5, 0)) - ttk.Radiobutton(scrollable, text="TXT", variable=self.export_format, value="txt").pack(pady=2) - ttk.Radiobutton(scrollable, text="Markdown", variable=self.export_format, value="md").pack(pady=2) - ttk.Radiobutton(scrollable, text="JSON", variable=self.export_format, value="json").pack(pady=2) - ttk.Checkbutton(scrollable, text=self.t("include_headings"), variable=self.include_heading).pack(pady=5) - ttk.Checkbutton(scrollable, text=self.t("use_code"), variable=self.use_code_block).pack(pady=5) - ttk.Checkbutton(scrollable, text=self.t("tree_only"), variable=self.tree_only).pack(pady=5) - - ttk.Label(scrollable, text=self.t("token_limit"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) - preset_limits = { - "ChatGPT (16k)": 16000, - "Gemini (32k)": 32000, - "ChatGPT-4 Turbo (128k)": 128000, - "Claude 3 (200k)": 200000, - } - preset_names = list(preset_limits.keys()) + ["Custom"] - current_tokens = self.settings.get("max_tokens", 200000) - preset_label = "Custom" - for name, val in preset_limits.items(): - if val == current_tokens: - preset_label = name - break - self.max_tokens_choice = tk.StringVar(value=preset_label) - self.custom_max_tokens = tk.IntVar(value=current_tokens) - token_frame = ttk.Frame(scrollable) - token_frame.pack(pady=5) - token_menu = ttk.OptionMenu(token_frame, self.max_tokens_choice, preset_label, *preset_names, command=lambda *_: toggle_entry()) - token_menu.pack() - # remove hover highlight from dropdown menu - menu_widget = token_menu["menu"] - bg = "#2d2d2d" if self.theme.get() == "dark" else "#ffffff" - fg = "#dcdcdc" if self.theme.get() == "dark" else "#000000" - menu_widget.configure(bg=bg, fg=fg, activebackground=bg, activeforeground=fg) - - token_entry = ttk.Entry(token_frame, textvariable=self.custom_max_tokens) - if self.max_tokens_choice.get() == "Custom": - token_entry.pack(pady=5) - - def toggle_entry(*_): - if self.max_tokens_choice.get() == "Custom": - token_entry.pack(pady=5) - else: - token_entry.pack_forget() - - - ttk.Label(scrollable, text=self.t("theme"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) - ttk.Radiobutton(scrollable, text=self.t("light"), variable=self.theme, value="light", command=self.apply_theme).pack(pady=5) - ttk.Radiobutton(scrollable, text=self.t("dark"), variable=self.theme, value="dark", command=self.apply_theme).pack(pady=5) - - ttk.Label(scrollable, text=self.t("language"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) - language_options = available_languages() - ttk.OptionMenu(scrollable, self.language, self.language.get(), *language_options, command=lambda *_: None).pack(pady=5) - - def save_and_close(): - new_settings = { - **self.settings, - "export_format": self.export_format.get(), - "tree_only": self.tree_only.get(), - "include_heading": self.include_heading.get(), - "use_code_block": self.use_code_block.get(), - "theme": self.theme.get(), - "language": self.language.get(), - "max_tokens": preset_limits.get(self.max_tokens_choice.get(), self.custom_max_tokens.get()), - } - save_settings(new_settings) - self.settings = new_settings - self.apply_theme() - self.load_translations() - self.update_texts() - win.destroy() - - ttk.Button(scrollable, text=self.t("save"), command=save_and_close).pack(pady=10) - - def is_valid(self, f: Path) -> bool: - return f.suffix in self.settings["allowed_exts"] and f.name not in self.settings["excluded_files"] - - def update_default_selected_files(self, folder_path: Path): - gitignore_file = folder_path / ".gitignore" - if gitignore_file.exists(): - with gitignore_file.open("r", encoding="utf-8") as f: - self.gitignore_spec = PathSpec.from_lines("gitwildmatch", f) - else: - self.gitignore_spec = None - - all_files = folder_path.rglob("*") - spec = self.gitignore_spec - self.selected_files = { - f - for f in all_files - if f.is_file() - and self.is_valid(f) - and not any(excl in f.parts for excl in self.settings["excluded_dirs"]) - and not (spec and spec.match_file(str(f.relative_to(folder_path)))) - } - - def toggle_preview_window(self): - if self.enable_preview.get(): - self.build_preview_async(self.selected_files) - else: - if self.preview_window and self.preview_window.winfo_exists(): - self.preview_window.destroy() - self.preview_window = None - - def preview_in_browser(self): - if not self.selected_files: - messagebox.showwarning(self.t("no_files"), self.t("no_preview")) - return - markdown_text = self.get_preview_text(self.selected_files) - html_body = markdown.markdown(markdown_text, extensions=['fenced_code', 'codehilite']) - - dark_css = """ - - """ - - light_css = """ - - """ - - css = dark_css if self.theme.get() == "dark" else light_css - - html = f"{css}{html_body}" - with NamedTemporaryFile("w", delete=False, suffix=".html", encoding="utf-8") as tmp: - tmp.write(html) - webbrowser.open(f"file://{tmp.name}") - - def copy_text_widget(self, widget: tk.Text): - text = widget.get("1.0", "end-1c") - self.root.clipboard_clear() - self.root.clipboard_append(text) - self.root.update() - messagebox.showinfo(self.t("copied"), self.t("content_copied")) - - def copy_preview(self): - if not self.selected_files: - messagebox.showwarning(self.t("no_files"), self.t("no_preview")) - return - text = self.get_preview_text(self.selected_files) - self.root.clipboard_clear() - self.root.clipboard_append(text) - self.root.update() - messagebox.showinfo(self.t("copied"), self.t("preview_copied")) - - def select_files(self): - folder = self.start_folder.get() - if not folder: - messagebox.showerror(self.t("error"), self.t("select_source_first")) - return - - selector = Toplevel(self.root) - selector.title(self.t("select_files_title")) - apply_icon(selector) - selector.resizable(True, True) - palette = { - "background": "#2d2d2d", - "foreground": "#dcdcdc", - } if self.theme.get() == "dark" else { - "background": "#ffffff", - "foreground": "#000000", - } - selector.configure(bg=palette["background"]) - selector.tk_setPalette(**palette) - style = ttk.Style(selector) - style.theme_use("clam") - style.configure( - ".", - background=palette["background"], - foreground=palette["foreground"], - ) - style.configure( - "Treeview", - background=palette["background"], - fieldbackground=palette["background"], - foreground=palette["foreground"], - ) - - frame = ttk.Frame(selector) - frame.pack(fill=tk.BOTH, expand=True) - frame.columnconfigure(0, weight=1) - frame.rowconfigure(0, weight=1) - - tree = ttk.Treeview(frame, columns=("fullpath", "type")) - tree.heading("#0", text="Name") - tree.heading("type", text="Type") - tree.column("fullpath", width=0, stretch=False) - tree.column("type", width=80) - - vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) - hsb = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview) - tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) - - tree.grid(row=0, column=0, sticky="nsew") - vsb.grid(row=0, column=1, sticky="ns") - hsb.grid(row=1, column=0, sticky="ew") - - token_label = ttk.Label(selector, text="") - token_label.pack(pady=2) - - checkbox_vars = {} - checkbox_items = {} - all_state = tk.BooleanVar(value=False) - - def update_token_label(): - tokens = self.compute_token_count({Path(p) for p, var in checkbox_vars.items() if var.get()}) - max_tokens = self.settings.get("max_tokens", 200000) - token_label.config(text=self.t("tokens", tokens=tokens, max=max_tokens)) - - def insert_items(parent, path: Path): - for p in sorted(path.iterdir()): - if p.is_dir(): - node = tree.insert(parent, 'end', text=p.name, values=(str(p), "dir"), open=False) - insert_items(node, p) - else: - default_checked = ( - self.is_valid(p) - and not any(skip in p.parts for skip in self.settings["excluded_dirs"]) - and not ( - self.gitignore_spec - and self.gitignore_spec.match_file(str(p.relative_to(folder))) - ) - ) - checked = p in self.selected_files or default_checked - var = tk.BooleanVar(value=checked) - item = tree.insert(parent, 'end', text=f"[{'x' if var.get() else ' '}] {p.name}", values=(str(p), "file")) - checkbox_vars[str(p)] = var - checkbox_items[str(p)] = item - - insert_items('', Path(folder)) - update_token_label() - - def update_preview_live(): - if self.enable_preview.get(): - files = {Path(p) for p, var in checkbox_vars.items() if var.get()} - self.build_preview_async(files) - update_token_label() - - def toggle_checkbox(event): - item = tree.identify_row(event.y) - if not item: - return - values = tree.item(item, 'values') - if len(values) < 2: - return - path_str, typ = values - if typ == "file" and path_str in checkbox_vars: - var = checkbox_vars[path_str] - var.set(not var.get()) - new_label = f"[{'x' if var.get() else ' '}] {Path(path_str).name}" - tree.item(item, text=new_label) - update_preview_live() - update_token_label() - - tree.bind("", toggle_checkbox) - - def toggle_all(): - new_val = not all_state.get() - all_state.set(new_val) - for path_str, var in checkbox_vars.items(): - var.set(new_val) - item = checkbox_items[path_str] - tree.item(item, text=f"[{'x' if new_val else ' '}] {Path(path_str).name}") - update_preview_live() - update_token_label() - - ttk.Button(selector, text=self.t("select_deselect"), command=toggle_all).pack(pady=5) - - def confirm_selection(): - self.selected_files = {Path(p) for p, var in checkbox_vars.items() if var.get()} - self.settings["last_start_folder"] = self.start_folder.get() - self.settings["last_selected_files"] = [ - str(Path(p).relative_to(self.start_folder.get())) - for p, var in checkbox_vars.items() if var.get() - ] - save_settings(self.settings) - selector.destroy() - if self.preview_window and self.preview_window.winfo_exists(): - self.preview_window.destroy() - - ttk.Button( - selector, - text=self.t("confirm"), - command=confirm_selection, - ).pack(pady=5) - - def compute_token_count(self, included_files): - preview_lines = self.generate_preview_lines( - self.start_folder.get(), included_files, warn_on_limit=False - ) - full_text = "\n".join(preview_lines) - return estimate_token_count(full_text) - - def get_preview_text(self, included_files): - self.limit_warning_displayed = False - preview_lines = self.generate_preview_lines( - self.start_folder.get(), included_files, warn_on_limit=True - ) - full_text = "\n".join(preview_lines) - token_count = estimate_token_count(full_text) - max_tokens = self.settings.get("max_tokens", 200000) - remaining = max_tokens - token_count - header = self.t("token_header", count=token_count, remaining=remaining, sep="="*40) - return header + full_text - - def generate_preview_lines(self, start_folder, included_files, warn_on_limit=True): - lines = [] - project_name = Path(start_folder).name - date_str = datetime.now().strftime('%Y%m%d') - header = self.t("project_label", name=project_name, date=date_str) + "\n" - lines.append(header) - token_count = estimate_token_count(header) - max_tokens = self.settings.get("max_tokens", 200000) - - for path in sorted(included_files): - rel_path = path.relative_to(start_folder) - if self.tree_only.get(): - line = f"{rel_path.as_posix()}\n" - tokens = estimate_token_count(line) - if token_count + tokens > max_tokens: - break - lines.append(line) - token_count += tokens - continue - try: - content = path.read_text(encoding='utf-8', errors='ignore') - except Exception: - continue - content = sanitize_sensitive_data(content) - chunk = [] - if self.include_heading.get(): - chunk.append(f"## {rel_path.as_posix()}\n") - if self.export_format.get() == "md" and self.use_code_block.get(): - lang = LANG_MAP.get(path.suffix, '') - chunk.append(f"```{lang}\n{content}\n```\n") - else: - chunk.append(f"{content}\n") - text = ''.join(chunk) - tokens = estimate_token_count(text) - if token_count + tokens > max_tokens: - if warn_on_limit and not self.limit_warning_displayed: - messagebox.showwarning( - self.t("limit_reached"), - self.t("limit_msg", max=max_tokens), - ) - self.limit_warning_displayed = True - break - lines.extend(chunk) - token_count += tokens - return lines - - def generate(self): - if not self.start_folder.get() or not self.dest_folder.get(): - messagebox.showerror(self.t("error"), self.t("need_folders")) - return - if not self.selected_files: - messagebox.showerror(self.t("error"), self.t("no_selected")) - return - total = len(self.selected_files) - self.show_progress(self.t("generate"), maximum=total) - - def callback(current, maximum): - self.root.after(0, lambda: self.update_progress(current, maximum)) - - def worker(): - try: - output_paths = generate_output( - self.start_folder.get(), - self.dest_folder.get(), - list(self.selected_files), - self.export_format.get(), - self.tree_only.get(), - self.include_heading.get(), - self.use_code_block.get(), - self.settings.get("max_tokens", 200000), - progress_callback=callback, - ) - msg = "\n".join(str(p) for p in output_paths) - self.root.after(0, lambda: [self.hide_progress(), messagebox.showinfo(self.t("done"), self.t("files_generated", msg=msg))]) - except Exception as e: - self.root.after(0, lambda: [self.hide_progress(), messagebox.showerror(self.t("error"), str(e))]) - - threading.Thread(target=worker, daemon=True).start() diff --git a/promptpack/gui/__init__.py b/promptpack/gui/__init__.py new file mode 100644 index 0000000..2d1a61b --- /dev/null +++ b/promptpack/gui/__init__.py @@ -0,0 +1,3 @@ +from .main_window import PromptPackApp + +__all__ = ["PromptPackApp"] diff --git a/promptpack/gui/file_selector.py b/promptpack/gui/file_selector.py new file mode 100644 index 0000000..7229330 --- /dev/null +++ b/promptpack/gui/file_selector.py @@ -0,0 +1,173 @@ +import tkinter as tk +from tkinter import Toplevel, ttk, messagebox +from pathlib import Path +from ..settings import save_settings +from ..utils import apply_icon +from pathspec import PathSpec + + +def is_valid(app, f: Path) -> bool: + return f.suffix in app.settings["allowed_exts"] and f.name not in app.settings["excluded_files"] + + +def update_default_selected_files(app, folder_path: Path): + gitignore_file = folder_path / ".gitignore" + if gitignore_file.exists(): + with gitignore_file.open("r", encoding="utf-8") as f: + app.gitignore_spec = PathSpec.from_lines("gitwildmatch", f) + else: + app.gitignore_spec = None + + all_files = folder_path.rglob("*") + spec = app.gitignore_spec + app.selected_files = { + f + for f in all_files + if f.is_file() + and is_valid(app, f) + and not any(excl in f.parts for excl in app.settings["excluded_dirs"]) + and not (spec and spec.match_file(str(f.relative_to(folder_path)))) + } + + +def select_files(app): + folder = app.start_folder.get() + if not folder: + messagebox.showerror(app.t("error"), app.t("select_source_first")) + return + + selector = Toplevel(app.root) + selector.title(app.t("select_files_title")) + apply_icon(selector) + selector.resizable(True, True) + palette = { + "background": "#2d2d2d", + "foreground": "#dcdcdc", + } if app.theme.get() == "dark" else { + "background": "#ffffff", + "foreground": "#000000", + } + selector.configure(bg=palette["background"]) + selector.tk_setPalette(**palette) + style = ttk.Style(selector) + style.theme_use("clam") + style.configure( + ".", + background=palette["background"], + foreground=palette["foreground"], + ) + style.configure( + "Treeview", + background=palette["background"], + fieldbackground=palette["background"], + foreground=palette["foreground"], + ) + + frame = ttk.Frame(selector) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + + tree = ttk.Treeview(frame, columns=("fullpath", "type")) + tree.heading("#0", text="Name") + tree.heading("type", text="Type") + tree.column("fullpath", width=0, stretch=False) + tree.column("type", width=80) + + vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) + hsb = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview) + tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + tree.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + + token_label = ttk.Label(selector, text="") + token_label.pack(pady=2) + + checkbox_vars: dict[str, tk.BooleanVar] = {} + checkbox_items = {} + all_state = tk.BooleanVar(value=False) + + def update_token_label(): + tokens = app.compute_token_count({Path(p) for p, var in checkbox_vars.items() if var.get()}) + max_tokens = app.settings.get("max_tokens", 200000) + token_label.config(text=app.t("tokens", tokens=tokens, max=max_tokens)) + + def insert_items(parent, path: Path): + for p in sorted(path.iterdir()): + if p.is_dir(): + node = tree.insert(parent, 'end', text=p.name, values=(str(p), "dir"), open=False) + insert_items(node, p) + else: + default_checked = ( + is_valid(app, p) + and not any(skip in p.parts for skip in app.settings["excluded_dirs"]) + and not ( + app.gitignore_spec + and app.gitignore_spec.match_file(str(p.relative_to(folder))) + ) + ) + checked = p in app.selected_files or default_checked + var = tk.BooleanVar(value=checked) + item = tree.insert(parent, 'end', text=f"[{'x' if var.get() else ' '}] {p.name}", values=(str(p), "file")) + checkbox_vars[str(p)] = var + checkbox_items[str(p)] = item + + insert_items('', Path(folder)) + update_token_label() + + def update_preview_live(): + if app.enable_preview.get(): + files = {Path(p) for p, var in checkbox_vars.items() if var.get()} + app.build_preview_async(files) + update_token_label() + + def toggle_checkbox(event): + item = tree.identify_row(event.y) + if not item: + return + values = tree.item(item, 'values') + if len(values) < 2: + return + path_str, typ = values + if typ == "file" and path_str in checkbox_vars: + var = checkbox_vars[path_str] + var.set(not var.get()) + new_label = f"[{'x' if var.get() else ' '}] {Path(path_str).name}" + tree.item(item, text=new_label) + update_preview_live() + update_token_label() + + tree.bind("", toggle_checkbox) + + def toggle_all(): + new_val = not all_state.get() + all_state.set(new_val) + for path_str, var in checkbox_vars.items(): + var.set(new_val) + item = checkbox_items[path_str] + tree.item(item, text=f"[{'x' if new_val else ' '}] {Path(path_str).name}") + update_preview_live() + update_token_label() + + ttk.Button(selector, text=app.t("select_deselect"), command=toggle_all).pack(pady=5) + + def confirm_selection(): + app.selected_files = {Path(p) for p, var in checkbox_vars.items() if var.get()} + app.settings["last_start_folder"] = app.start_folder.get() + app.settings["last_selected_files"] = [ + str(Path(p).relative_to(app.start_folder.get())) + for p, var in checkbox_vars.items() if var.get() + ] + save_settings(app.settings) + selector.destroy() + if app.preview_window and app.preview_window.winfo_exists(): + app.preview_window.destroy() + + ttk.Button( + selector, + text=app.t("confirm"), + command=confirm_selection, + ).pack(pady=5) + diff --git a/promptpack/gui/main_window.py b/promptpack/gui/main_window.py new file mode 100644 index 0000000..2badc7a --- /dev/null +++ b/promptpack/gui/main_window.py @@ -0,0 +1,301 @@ +import tkinter as tk +from tkinter import filedialog, messagebox, simpledialog, Toplevel, ttk +from pathlib import Path +from datetime import datetime +from tempfile import NamedTemporaryFile +import webbrowser +import markdown +import threading + +from ..settings import load_settings, save_settings +from pathspec import PathSpec +from ..utils import apply_icon, estimate_token_count, LANG_MAP, generate_output, sanitize_sensitive_data +from ..i18n import load_translations, available_languages +from .theme import apply_theme +from .progress import show_progress, update_progress, hide_progress +from .preview import ( + build_preview_async, + preview_in_browser, + copy_preview, + copy_text_widget, + compute_token_count, + get_preview_text, + generate_preview_lines, +) +from .file_selector import ( + select_files, + is_valid, + update_default_selected_files, + toggle_preview_window, +) +from .settings_dialog import configure_settings, ListDialog + + +class Tooltip: + def __init__(self, widget, text: str): + self.widget = widget + self.text = text + self.tipwindow = None + widget.bind("", self.show) + widget.bind("", self.hide) + + def show(self, _event=None): + if self.tipwindow or not self.text: + return + x = self.widget.winfo_rootx() + 20 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 10 + self.tipwindow = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.geometry(f"+{x}+{y}") + label = ttk.Label(tw, text=self.text, relief="solid", borderwidth=1, background="#ffffe0") + label.pack() + + def hide(self, _event=None): + if self.tipwindow: + self.tipwindow.destroy() + self.tipwindow = None + +EN_TRANSLATIONS = load_translations("eng") + + + + + +class PromptPackApp: + def load_translations(self): + self.translations = load_translations(self.language.get()) + + def t(self, key: str, **kwargs) -> str: + template = self.translations.get(key, EN_TRANSLATIONS.get(key, key)) + return template.format(**kwargs) + + def __init__(self, root: tk.Tk): + self.root = root + root.title("PromptPack") + apply_icon(root) + + + self.settings = load_settings() + + self.export_format = tk.StringVar(value=self.settings.get("export_format", "md")) + self.tree_only = tk.BooleanVar(value=self.settings.get("tree_only", False)) + self.include_heading = tk.BooleanVar(value=self.settings["include_heading"]) + self.use_code_block = tk.BooleanVar(value=self.settings["use_code_block"]) + self.theme = tk.StringVar(value=self.settings.get("theme", "dark")) + self.language = tk.StringVar(value=self.settings.get("language", "eng")) + self.load_translations() + self.enable_preview = tk.BooleanVar(value=False) + + self.start_folder = tk.StringVar(value=self.settings.get("last_start_folder", "")) + self.dest_folder = tk.StringVar() + self.selected_files = set() + self.gitignore_spec = None + + self.preview_window = None + self.preview_text = None + self.progress_frame = None + self.progress_bar = None + self.progress_var = tk.IntVar(value=0) + self.limit_warning_displayed = False + + style = ttk.Style(self.root) + style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) + + self.build_gui() + self.update_texts() + + folder = self.start_folder.get() + if folder: + self.update_default_selected_files(Path(folder)) + saved = { + Path(folder) / Path(p) + for p in self.settings.get("last_selected_files", []) + } + saved_existing = {p for p in saved if p.exists()} + if saved_existing: + self.selected_files = saved_existing + + self.apply_theme() + + def build_gui(self): + # Rende le 3 colonne espandibili per migliore distribuzione + for i in range(3): + self.root.grid_columnconfigure(i, weight=1) + + heading_opts = { + "style": "Heading.TLabel", + "anchor": "center" + } + + # Source section + self.source_heading = ttk.Label(self.root, text=self.t("source"), **heading_opts) + self.source_heading.grid(row=0, column=1, sticky="ew", padx=10, pady=(20, 5)) + + self.source_label = ttk.Label(self.root, text=self.t("source_folder")) + self.source_label.grid(row=1, column=0, sticky="w", padx=10, pady=5) + + entry = ttk.Entry(self.root, textvariable=self.start_folder, width=50) + entry.grid(row=1, column=1, padx=5, pady=5) + + self.browse_start_btn = ttk.Button(self.root, text=self.t("browse"), command=self.browse_start) + self.browse_start_btn.grid(row=1, column=2, padx=5, pady=5) + + self.select_files_btn = ttk.Button(self.root, text=self.t("select_files"), command=self.select_files) + self.select_files_btn.grid(row=2, column=1, pady=5) + + # Preview section + self.preview_heading = ttk.Label(self.root, text=self.t("preview"), **heading_opts) + self.preview_heading.grid(row=3, column=1, sticky="ew", padx=10, pady=(20, 5)) + + self.browser_btn = ttk.Button(self.root, text=self.t("browser"), command=self.preview_in_browser) + self.browser_btn.grid(row=4, column=1, pady=5) + Tooltip(self.browser_btn, self.t("open_browser_tip")) + + self.live_preview_cb = ttk.Checkbutton( + self.root, + text=self.t("live_preview"), + variable=self.enable_preview, + command=self.toggle_preview_window, + ) + self.live_preview_cb.grid(row=4, column=2, sticky="w", padx=5) + + # Output section + self.output_heading = ttk.Label(self.root, text=self.t("output"), **heading_opts) + self.output_heading.grid(row=5, column=1, sticky="ew", padx=10, pady=(20, 5)) + + self.dest_label = ttk.Label(self.root, text=self.t("dest_folder")) + self.dest_label.grid(row=6, column=0, sticky="w", padx=10, pady=5) + + ttk.Entry(self.root, textvariable=self.dest_folder, width=50)\ + .grid(row=6, column=1, padx=5, pady=5) + + self.browse_dest_btn = ttk.Button(self.root, text=self.t("browse"), command=self.browse_dest) + self.browse_dest_btn.grid(row=6, column=2, padx=5, pady=5) + + self.generate_btn = ttk.Button(self.root, text=self.t("generate"), command=self.generate) + self.generate_btn.grid(row=7, column=1, pady=10) + + # Settings gear button + self.gear_button = ttk.Button( + self.root, + text=self.t("settings"), + command=self.configure_settings, + style="Gear.TButton" + ) + self.gear_button.grid(row=8, column=2, sticky="e", pady=5, padx=5) + self.gear_button.bind("", lambda e: self.gear_button.config(cursor="hand2")) + self.gear_button.bind("", lambda e: self.gear_button.config(cursor="")) + Tooltip(self.gear_button, self.t("settings_tip")) + + def update_texts(self): + self.source_heading.config(text=self.t("source")) + self.source_label.config(text=self.t("source_folder")) + self.browse_start_btn.config(text=self.t("browse")) + self.select_files_btn.config(text=self.t("select_files")) + self.preview_heading.config(text=self.t("preview")) + self.browser_btn.config(text=self.t("browser")) + self.live_preview_cb.config(text=self.t("live_preview")) + self.output_heading.config(text=self.t("output")) + self.dest_label.config(text=self.t("dest_folder")) + self.browse_dest_btn.config(text=self.t("browse")) + self.generate_btn.config(text=self.t("generate")) + self.gear_button.config(text=self.t("settings")) + + + + + def apply_theme(self): + apply_theme(self) + + def show_progress(self, message: str, maximum: int | None = None): + show_progress(self, message, maximum) + + def update_progress(self, value: int, maximum: int): + update_progress(self, value, maximum) + + def hide_progress(self): + hide_progress(self) + + def build_preview_async(self, files): + build_preview_async(self, files) + + def browse_start(self): + folder = filedialog.askdirectory() + if folder: + self.start_folder.set(folder) + self.update_default_selected_files(Path(folder)) + self.settings["last_start_folder"] = folder + self.settings["last_selected_files"] = [] + save_settings(self.settings) + + + def browse_dest(self): + folder = filedialog.askdirectory() + if folder: + self.dest_folder.set(folder) + + def configure_settings(self): + configure_settings(self) + + def is_valid(self, f: Path) -> bool: + return is_valid(self, f) + + def update_default_selected_files(self, folder_path: Path): + update_default_selected_files(self, folder_path) + + def toggle_preview_window(self): + toggle_preview_window(self) + + def preview_in_browser(self): + preview_in_browser(self) + + def copy_text_widget(self, widget: tk.Text): + copy_text_widget(self, widget) + + def copy_preview(self): + copy_preview(self) + + def select_files(self): + select_files(self) + + def compute_token_count(self, included_files): + return compute_token_count(self, included_files) + + def get_preview_text(self, included_files): + return get_preview_text(self, included_files) + + def generate_preview_lines(self, start_folder, included_files, warn_on_limit=True): + return generate_preview_lines(self, start_folder, included_files, warn_on_limit) + + def generate(self): + if not self.start_folder.get() or not self.dest_folder.get(): + messagebox.showerror(self.t("error"), self.t("need_folders")) + return + if not self.selected_files: + messagebox.showerror(self.t("error"), self.t("no_selected")) + return + total = len(self.selected_files) + self.show_progress(self.t("generate"), maximum=total) + + def callback(current, maximum): + self.root.after(0, lambda: self.update_progress(current, maximum)) + + def worker(): + try: + output_paths = generate_output( + self.start_folder.get(), + self.dest_folder.get(), + list(self.selected_files), + self.export_format.get(), + self.tree_only.get(), + self.include_heading.get(), + self.use_code_block.get(), + self.settings.get("max_tokens", 200000), + progress_callback=callback, + ) + msg = "\n".join(str(p) for p in output_paths) + self.root.after(0, lambda: [self.hide_progress(), messagebox.showinfo(self.t("done"), self.t("files_generated", msg=msg))]) + except Exception as e: + self.root.after(0, lambda: [self.hide_progress(), messagebox.showerror(self.t("error"), str(e))]) + + threading.Thread(target=worker, daemon=True).start() diff --git a/promptpack/gui/preview.py b/promptpack/gui/preview.py new file mode 100644 index 0000000..eb526de --- /dev/null +++ b/promptpack/gui/preview.py @@ -0,0 +1,206 @@ +import threading +import markdown +from pathlib import Path +from tempfile import NamedTemporaryFile +import webbrowser +import tkinter as tk +from tkinter import Toplevel, messagebox, ttk + +from ..utils import apply_icon, sanitize_sensitive_data, LANG_MAP, estimate_token_count + + +def build_preview_async(app, files): + app.show_progress(app.t("preview")) + + def worker(): + text = app.get_preview_text(files) + + def update(): + if app.preview_window is None or not app.preview_window.winfo_exists(): + app.preview_window = Toplevel(app.root) + app.preview_window.title(app.t("preview")) + app.preview_window.resizable(True, True) + apply_icon(app.preview_window) + + frame = ttk.Frame(app.preview_window) + frame.pack(fill="both", expand=True) + + app.preview_text = tk.Text(frame, wrap="word") + yscroll = ttk.Scrollbar(frame, command=app.preview_text.yview) + app.preview_text.configure(yscrollcommand=yscroll.set) + app.preview_text.pack(side="left", fill="both", expand=True) + yscroll.pack(side="right", fill="y") + + ttk.Button( + app.preview_window, + text=app.t("copy"), + command=lambda: copy_text_widget(app, app.preview_text), + ).pack(pady=5) + app.apply_theme() + app.preview_text.delete("1.0", "end") + app.preview_text.insert("1.0", text) + app.hide_progress() + + app.root.after(0, update) + + threading.Thread(target=worker, daemon=True).start() + + +def toggle_preview_window(app): + if app.enable_preview.get(): + build_preview_async(app, app.selected_files) + else: + if app.preview_window and app.preview_window.winfo_exists(): + app.preview_window.destroy() + app.preview_window = None + + +def preview_in_browser(app): + if not app.selected_files: + messagebox.showwarning(app.t("no_files"), app.t("no_preview")) + return + markdown_text = app.get_preview_text(app.selected_files) + html_body = markdown.markdown(markdown_text, extensions=['fenced_code', 'codehilite']) + + dark_css = """ + + """ + + light_css = """ + + """ + + css = dark_css if app.theme.get() == "dark" else light_css + + html = f"{css}{html_body}" + with NamedTemporaryFile("w", delete=False, suffix=".html", encoding="utf-8") as tmp: + tmp.write(html) + webbrowser.open(f"file://{tmp.name}") + + +def copy_text_widget(app, widget: tk.Text): + text = widget.get("1.0", "end-1c") + app.root.clipboard_clear() + app.root.clipboard_append(text) + app.root.update() + messagebox.showinfo(app.t("copied"), app.t("content_copied")) + + +def copy_preview(app): + if not app.selected_files: + messagebox.showwarning(app.t("no_files"), app.t("no_preview")) + return + text = app.get_preview_text(app.selected_files) + app.root.clipboard_clear() + app.root.clipboard_append(text) + app.root.update() + messagebox.showinfo(app.t("copied"), app.t("preview_copied")) + + +def compute_token_count(app, included_files): + preview_lines = generate_preview_lines(app, app.start_folder.get(), included_files, warn_on_limit=False) + full_text = "\n".join(preview_lines) + return estimate_token_count(full_text) + + +def get_preview_text(app, included_files): + app.limit_warning_displayed = False + preview_lines = generate_preview_lines(app, app.start_folder.get(), included_files, warn_on_limit=True) + full_text = "\n".join(preview_lines) + token_count = estimate_token_count(full_text) + max_tokens = app.settings.get("max_tokens", 200000) + remaining = max_tokens - token_count + header = app.t("token_header", count=token_count, remaining=remaining, sep="="*40) + return header + full_text + + +def generate_preview_lines(app, start_folder, included_files, warn_on_limit=True): + lines = [] + project_name = Path(start_folder).name + date_str = datetime.now().strftime('%Y%m%d') + header = app.t("project_label", name=project_name, date=date_str) + "\n" + lines.append(header) + token_count = estimate_token_count(header) + max_tokens = app.settings.get("max_tokens", 200000) + + for path in sorted(included_files): + rel_path = path.relative_to(start_folder) + if app.tree_only.get(): + line = f"{rel_path.as_posix()}\n" + tokens = estimate_token_count(line) + if token_count + tokens > max_tokens: + break + lines.append(line) + token_count += tokens + continue + try: + content = path.read_text(encoding='utf-8', errors='ignore') + except Exception: + continue + content = sanitize_sensitive_data(content) + chunk = [] + if app.include_heading.get(): + chunk.append(f"## {rel_path.as_posix()}\n") + if app.export_format.get() == "md" and app.use_code_block.get(): + lang = LANG_MAP.get(path.suffix, '') + chunk.append(f"```{lang}\n{content}\n```\n") + else: + chunk.append(f"{content}\n") + text = ''.join(chunk) + tokens = estimate_token_count(text) + if token_count + tokens > max_tokens: + if warn_on_limit and not app.limit_warning_displayed: + messagebox.showwarning( + app.t("limit_reached"), + app.t("limit_msg", max=max_tokens), + ) + app.limit_warning_displayed = True + break + lines.extend(chunk) + token_count += tokens + return lines + diff --git a/promptpack/gui/progress.py b/promptpack/gui/progress.py new file mode 100644 index 0000000..9609be7 --- /dev/null +++ b/promptpack/gui/progress.py @@ -0,0 +1,38 @@ +from tkinter import ttk + + +def show_progress(app, message: str, maximum: int | None = None): + if app.progress_frame: + app.progress_frame.destroy() + app.progress_var.set(0) + app.progress_frame = ttk.Frame(app.root) + app.progress_frame.grid(row=9, column=0, columnspan=3, pady=(10, 5)) + ttk.Label(app.progress_frame, text=message).pack(padx=10, pady=5) + mode = "indeterminate" if maximum is None else "determinate" + app.progress_bar = ttk.Progressbar( + app.progress_frame, + variable=app.progress_var, + maximum=maximum if maximum is not None else 100, + mode=mode, + ) + app.progress_bar.pack(padx=10, pady=5, fill="x", expand=True) + if mode == "indeterminate": + app.progress_bar.start() + + +def update_progress(app, value: int, maximum: int): + if app.progress_bar and app.progress_bar["mode"] == "determinate": + app.progress_bar.config(maximum=maximum) + app.progress_var.set(value) + app.progress_bar.update_idletasks() + + +def hide_progress(app): + if app.progress_bar: + if app.progress_bar["mode"] == "indeterminate": + app.progress_bar.stop() + if app.progress_frame: + app.progress_frame.destroy() + app.progress_frame = None + app.progress_bar = None + diff --git a/promptpack/gui/settings_dialog.py b/promptpack/gui/settings_dialog.py new file mode 100644 index 0000000..69240fd --- /dev/null +++ b/promptpack/gui/settings_dialog.py @@ -0,0 +1,137 @@ +import tkinter as tk +from tkinter import Toplevel, ttk, simpledialog +from ..settings import save_settings +from ..utils import apply_icon +from ..i18n import available_languages + + +class ListDialog(simpledialog.Dialog): + """Simple entry dialog that supports a custom window icon.""" + + def __init__(self, parent, title, initial_value=""): + self.initial_value = initial_value + self.dialog_title = title + super().__init__(parent, title) + + def body(self, master): + apply_icon(self) + ttk.Label(master, text=f"{self.dialog_title} (comma separated):").pack(padx=5, pady=5) + self.entry = ttk.Entry(master, width=50) + self.entry.pack(padx=5, pady=5) + self.entry.insert(0, self.initial_value) + return self.entry + + def apply(self): + self.result = self.entry.get() + + +def configure_settings(app): + win = Toplevel(app.root) + win.title(app.t("settings_title")) + apply_icon(win) + win.resizable(True, True) + + container = ttk.Frame(win) + container.pack(fill="both", expand=True) + + canvas = tk.Canvas(container, highlightthickness=0) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + + scrollable = ttk.Frame(canvas) + canvas.create_window((0, 0), window=scrollable, anchor="nw") + + scrollable.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + style = ttk.Style(win) + style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) + + def prompt_list(title, key): + dlg = ListDialog(win, title, initial_value=",".join(app.settings[key])) + result = dlg.result + if result is not None: + app.settings[key] = [x.strip() for x in result.split(",") if x.strip()] + + ttk.Label(scrollable, text=app.t("default_selection"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) + ttk.Button(scrollable, text=app.t("allowed_exts"), command=lambda: prompt_list(app.t("allowed_exts"), "allowed_exts")).pack(pady=5) + ttk.Button(scrollable, text=app.t("excluded_dirs"), command=lambda: prompt_list(app.t("excluded_dirs"), "excluded_dirs")).pack(pady=5) + ttk.Button(scrollable, text=app.t("excluded_files"), command=lambda: prompt_list(app.t("excluded_files"), "excluded_files")).pack(pady=5) + + ttk.Label(scrollable, text=app.t("output_opts"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) + ttk.Label(scrollable, text=app.t("export_format")).pack(pady=(5, 0)) + ttk.Radiobutton(scrollable, text="TXT", variable=app.export_format, value="txt").pack(pady=2) + ttk.Radiobutton(scrollable, text="Markdown", variable=app.export_format, value="md").pack(pady=2) + ttk.Radiobutton(scrollable, text="JSON", variable=app.export_format, value="json").pack(pady=2) + ttk.Checkbutton(scrollable, text=app.t("include_headings"), variable=app.include_heading).pack(pady=5) + ttk.Checkbutton(scrollable, text=app.t("use_code"), variable=app.use_code_block).pack(pady=5) + ttk.Checkbutton(scrollable, text=app.t("tree_only"), variable=app.tree_only).pack(pady=5) + + ttk.Label(scrollable, text=app.t("token_limit"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) + preset_limits = { + "ChatGPT (16k)": 16000, + "Gemini (32k)": 32000, + "ChatGPT-4 Turbo (128k)": 128000, + "Claude 3 (200k)": 200000, + } + preset_names = list(preset_limits.keys()) + ["Custom"] + current_tokens = app.settings.get("max_tokens", 200000) + preset_label = "Custom" + for name, val in preset_limits.items(): + if val == current_tokens: + preset_label = name + break + app.max_tokens_choice = tk.StringVar(value=preset_label) + app.custom_max_tokens = tk.IntVar(value=current_tokens) + token_frame = ttk.Frame(scrollable) + token_frame.pack(pady=5) + token_menu = ttk.OptionMenu(token_frame, app.max_tokens_choice, preset_label, *preset_names, command=lambda *_: toggle_entry()) + token_menu.pack() + # remove hover highlight from dropdown menu + menu_widget = token_menu["menu"] + bg = "#2d2d2d" if app.theme.get() == "dark" else "#ffffff" + fg = "#dcdcdc" if app.theme.get() == "dark" else "#000000" + menu_widget.configure(bg=bg, fg=fg, activebackground=bg, activeforeground=fg) + + token_entry = ttk.Entry(token_frame, textvariable=app.custom_max_tokens) + if app.max_tokens_choice.get() == "Custom": + token_entry.pack(pady=5) + + def toggle_entry(*_): + if app.max_tokens_choice.get() == "Custom": + token_entry.pack(pady=5) + else: + token_entry.pack_forget() + + ttk.Label(scrollable, text=app.t("theme"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) + ttk.Radiobutton(scrollable, text=app.t("light"), variable=app.theme, value="light", command=app.apply_theme).pack(pady=5) + ttk.Radiobutton(scrollable, text=app.t("dark"), variable=app.theme, value="dark", command=app.apply_theme).pack(pady=5) + + ttk.Label(scrollable, text=app.t("language"), style="Heading.TLabel").pack(padx=10, pady=(20, 5)) + language_options = available_languages() + ttk.OptionMenu(scrollable, app.language, app.language.get(), *language_options, command=lambda *_: None).pack(pady=5) + + def save_and_close(): + new_settings = { + **app.settings, + "export_format": app.export_format.get(), + "tree_only": app.tree_only.get(), + "include_heading": app.include_heading.get(), + "use_code_block": app.use_code_block.get(), + "theme": app.theme.get(), + "language": app.language.get(), + "max_tokens": preset_limits.get(app.max_tokens_choice.get(), app.custom_max_tokens.get()), + } + save_settings(new_settings) + app.settings = new_settings + app.apply_theme() + app.load_translations() + app.update_texts() + win.destroy() + + ttk.Button(scrollable, text=app.t("save"), command=save_and_close).pack(pady=10) + diff --git a/promptpack/gui/theme.py b/promptpack/gui/theme.py new file mode 100644 index 0000000..5e788ee --- /dev/null +++ b/promptpack/gui/theme.py @@ -0,0 +1,86 @@ +import tkinter as tk +from tkinter import ttk + + +def apply_theme(app): + """Apply the selected theme to the application widgets.""" + if app.theme.get() == "dark": + palette = { + "background": "#2d2d2d", + "foreground": "#dcdcdc", + "activeBackground": "#505050", + "activeForeground": "#ffffff", + } + else: + palette = { + "background": "#ffffff", + "foreground": "#000000", + "activeBackground": "#e0e0e0", + "activeForeground": "#000000", + } + + app.root.configure(bg=palette["background"]) + app.root.tk_setPalette(**palette) + style = ttk.Style(app.root) + style.theme_use("clam") + style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) + style.configure( + ".", + background=palette["background"], + foreground=palette["foreground"], + ) + style.configure( + "TButton", + background=palette["background"], + foreground=palette["foreground"], + ) + style.map( + "TButton", + background=[("active", palette["activeBackground"])], + foreground=[("active", palette["activeForeground"])], + ) + style.configure( + "TEntry", + fieldbackground=palette["background"], + background=palette["background"], + foreground=palette["foreground"], + insertcolor=palette["foreground"], + ) + style.configure( + "TLabel", + background=palette["background"], + foreground=palette["foreground"], + ) + style.configure( + "TCheckbutton", + background=palette["background"], + foreground=palette["foreground"], + ) + style.configure( + "Gear.TButton", + relief="flat", + borderwidth=0, + background=palette["background"], + foreground=palette["foreground"], + padding=2, + ) + style.map("Gear.TButton", background=[], foreground=[]) + style.map("TCheckbutton", background=[], foreground=[]) + style.configure( + "TRadiobutton", + background=palette["background"], + foreground=palette["foreground"], + ) + style.map("TRadiobutton", background=[], foreground=[]) + style.configure( + "TMenubutton", + background=palette["background"], + foreground=palette["foreground"], + ) + style.map("TMenubutton", background=[], foreground=[]) + + if app.preview_window and app.preview_window.winfo_exists(): + app.preview_window.tk_setPalette(**palette) + if app.preview_text: + app.preview_text.configure(bg=palette["background"], fg=palette["foreground"]) + diff --git a/promptpack/settings.py b/promptpack/settings.py index d4c6b0e..6262316 100644 --- a/promptpack/settings.py +++ b/promptpack/settings.py @@ -15,6 +15,7 @@ "use_code_block": True, "theme": "dark", "language": "eng", + "token_model": "gpt", # Maximum tokens per preview or export file "max_tokens": 200_000, # Remember last source folder and selected files @@ -39,6 +40,8 @@ def load_settings(): data["last_selected_files"] = DEFAULT_SETTINGS["last_selected_files"] if "language" not in data: data["language"] = DEFAULT_SETTINGS["language"] + if "token_model" not in data: + data["token_model"] = DEFAULT_SETTINGS["token_model"] return {**DEFAULT_SETTINGS, **data} except Exception: return DEFAULT_SETTINGS.copy() diff --git a/promptpack/tokenizer.py b/promptpack/tokenizer.py index 6ac7e15..822920e 100644 --- a/promptpack/tokenizer.py +++ b/promptpack/tokenizer.py @@ -2,6 +2,8 @@ from __future__ import annotations +from .settings import load_settings + try: import tiktoken # type: ignore except Exception: # pragma: no cover - optional dependency @@ -44,17 +46,28 @@ def gemini_tokens(text: str) -> int: return info.get("token_count", len(info.get("tokens", []))) + MODEL_DISPATCH = { "gpt": gpt_tokens, "claude": claude_tokens, "gemini": gemini_tokens, } +_DEFAULT_MODEL = load_settings().get("token_model", "gpt") +_OVERRIDE_MODEL: str | None = None + + +def set_default_model(model: str | None) -> None: + """Override default model used by :func:`estimate_token_count`.""" + global _OVERRIDE_MODEL + _OVERRIDE_MODEL = model + -def estimate_token_count(text: str, model: str = "gpt") -> int: +def estimate_token_count(text: str, model: str | None = None) -> int: """Return number of tokens for the chosen model.""" - func = MODEL_DISPATCH.get(model) + chosen_model = model or _OVERRIDE_MODEL or _DEFAULT_MODEL + func = MODEL_DISPATCH.get(chosen_model) if func is None: - raise ValueError(f"Modello sconosciuto: {model}") + raise ValueError(f"Modello sconosciuto: {chosen_model}") return func(text) diff --git a/promptpack_settings.json b/promptpack_settings.json index 8fb3bcf..8d90529 100644 --- a/promptpack_settings.json +++ b/promptpack_settings.json @@ -22,6 +22,7 @@ "use_code_block": true, "theme": "dark", "language": "eng", + "token_model": "gpt", "max_tokens": 200000, "last_start_folder": "", "last_selected_files": [] From 3f2a9730300924a6790910a71083eeaf2f88e91d Mon Sep 17 00:00:00 2001 From: TobiaRigon Date: Wed, 16 Jul 2025 12:57:08 +0200 Subject: [PATCH 2/2] Refactoring --- promptpack/__main__.py | 7 ++++++- promptpack/gui/main_window.py | 3 ++- promptpack/gui/preview.py | 1 + promptpack_settings.json | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/promptpack/__main__.py b/promptpack/__main__.py index 7bee293..90e122f 100644 --- a/promptpack/__main__.py +++ b/promptpack/__main__.py @@ -1,6 +1,11 @@ +import os +import sys import tkinter as tk -from .gui import PromptPackApp +if __package__ is None or __package__ == "": + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from promptpack.gui import PromptPackApp def main(): diff --git a/promptpack/gui/main_window.py b/promptpack/gui/main_window.py index 2badc7a..ec0bd73 100644 --- a/promptpack/gui/main_window.py +++ b/promptpack/gui/main_window.py @@ -21,12 +21,13 @@ compute_token_count, get_preview_text, generate_preview_lines, + toggle_preview_window, + ) from .file_selector import ( select_files, is_valid, update_default_selected_files, - toggle_preview_window, ) from .settings_dialog import configure_settings, ListDialog diff --git a/promptpack/gui/preview.py b/promptpack/gui/preview.py index eb526de..1d757f6 100644 --- a/promptpack/gui/preview.py +++ b/promptpack/gui/preview.py @@ -5,6 +5,7 @@ import webbrowser import tkinter as tk from tkinter import Toplevel, messagebox, ttk +from datetime import datetime from ..utils import apply_icon, sanitize_sensitive_data, LANG_MAP, estimate_token_count diff --git a/promptpack_settings.json b/promptpack_settings.json index 8d90529..b2bb982 100644 --- a/promptpack_settings.json +++ b/promptpack_settings.json @@ -24,6 +24,6 @@ "language": "eng", "token_model": "gpt", "max_tokens": 200000, - "last_start_folder": "", + "last_start_folder": "C:/Users/tobia.rigon/Desktop/vaadin-debug", "last_selected_files": [] } \ No newline at end of file