From 6d4f625bafae3c271daae3ed767fe05bae020b8b Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 10:48:07 +0530 Subject: [PATCH 1/4] refactor: remove the CustomTkinter GUI --- src/norefund/gui/__init__.py | 0 src/norefund/gui/app.py | 84 --- src/norefund/gui/calculator_view.py | 295 -------- src/norefund/gui/compare_view.py | 749 ------------------- src/norefund/gui/dnd.py | 59 -- src/norefund/gui/fit_check_view.py | 450 ------------ src/norefund/gui/formatting.py | 120 ---- src/norefund/gui/main_view.py | 378 ---------- src/norefund/gui/motion.py | 133 ---- src/norefund/gui/native_dialog.py | 99 --- src/norefund/gui/parser_view.py | 729 ------------------- src/norefund/gui/registry_view.py | 312 -------- src/norefund/gui/resources_view.py | 543 -------------- src/norefund/gui/settings_modal.py | 290 -------- src/norefund/gui/theme.py | 311 -------- src/norefund/gui/widgets.py | 1040 --------------------------- 16 files changed, 5592 deletions(-) delete mode 100644 src/norefund/gui/__init__.py delete mode 100644 src/norefund/gui/app.py delete mode 100644 src/norefund/gui/calculator_view.py delete mode 100644 src/norefund/gui/compare_view.py delete mode 100644 src/norefund/gui/dnd.py delete mode 100644 src/norefund/gui/fit_check_view.py delete mode 100644 src/norefund/gui/formatting.py delete mode 100644 src/norefund/gui/main_view.py delete mode 100644 src/norefund/gui/motion.py delete mode 100644 src/norefund/gui/native_dialog.py delete mode 100644 src/norefund/gui/parser_view.py delete mode 100644 src/norefund/gui/registry_view.py delete mode 100644 src/norefund/gui/resources_view.py delete mode 100644 src/norefund/gui/settings_modal.py delete mode 100644 src/norefund/gui/theme.py delete mode 100644 src/norefund/gui/widgets.py diff --git a/src/norefund/gui/__init__.py b/src/norefund/gui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/norefund/gui/app.py b/src/norefund/gui/app.py deleted file mode 100644 index a54b8dd..0000000 --- a/src/norefund/gui/app.py +++ /dev/null @@ -1,84 +0,0 @@ -"""GUI entry point.""" - -from __future__ import annotations - -import tkinter as tk - -import customtkinter as ctk - -from norefund.core.settings import SettingsStore -from norefund.gui.dnd import dnd_root_class -from norefund.gui.main_view import MainView - -_APPEARANCE_MODE = {"system": "System", "light": "Light", "dark": "Dark"} - -# customtkinter's CTk isn't a TkinterDnD.Tk subclass, so when tkinterdnd2 is -# installed we mix it in to get drop-target support; otherwise plain CTk. -_DndMixin = dnd_root_class() -_BaseWindow = (_DndMixin, ctk.CTk) if _DndMixin is not None else (ctk.CTk,) - - -def _maximize(window: ctk.CTk) -> None: - """Open `window` filling the screen, on any OS/window manager, without - raising. Tries the native maximized state first (Windows and most X11 - window managers both support "zoomed"), then the X11 zoomed attribute, - then falls back to an explicit full-screen geometry -- which has no OS - or window-manager dependency and always works. Keeps the title bar and - window controls (this is "maximized", not borderless fullscreen). - - Both native attempts are verified, not just assumed to have worked: - `-zoomed` in particular is accepted without error even when nothing - actually enforces it (e.g. no window manager, or a minimal/tiling one - that ignores the hint), so a bare `try/except TclError` alone would - silently leave the window at its pre-maximize size in that case. - - Caller must have already given the window at least one full `update()` - (not just `update_idletasks()`) before calling this. Requesting the - zoomed state before the window manager has mapped and decorated the - window makes some WMs compute the maximized geometry without knowing - the title bar's height yet, then apply the title bar on top of that - once it does decorate -- pushing it above the screen's top edge, off - screen, taking the minimize/restore/close buttons with it. - """ - try: - window.state("zoomed") - window.update() - if window.state() == "zoomed": - return - except tk.TclError: - pass - try: - window.attributes("-zoomed", True) - window.update() - if window.attributes("-zoomed"): - return - except tk.TclError: - pass - window.geometry(f"{window.winfo_screenwidth()}x{window.winfo_screenheight()}+0+0") - - -class App(*_BaseWindow): - def __init__(self) -> None: - super().__init__() - - settings = SettingsStore().load() - ctk.set_appearance_mode(_APPEARANCE_MODE.get(settings.theme, "System")) - ctk.set_default_color_theme("blue") - - self.title("NoRefund — Token & Cost Analyzer") - # Pre-maximize fallback size/floor -- applied briefly before - # _maximize() takes effect, and minsize is what the window can be - # resized down to afterward, so both need to fit a 1366x768 laptop - # screen (a common resolution this app was previously too tall for). - self.geometry("1440x900") - self.minsize(1024, 640) - # Force the window manager to map and decorate the window at this - # size before we ask it to maximize -- see _maximize()'s docstring. - self.update() - _maximize(self) - - MainView(self).pack(fill="both", expand=True) - - -if __name__ == "__main__": - App().mainloop() diff --git a/src/norefund/gui/calculator_view.py b/src/norefund/gui/calculator_view.py deleted file mode 100644 index 3e4a93c..0000000 --- a/src/norefund/gui/calculator_view.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Manual token/cost calculator — no file I/O, pure recompute on input change.""" - -from __future__ import annotations - -import customtkinter as ctk - -from norefund.core import costing -from norefund.gui import formatting, theme -from norefund.gui.theme import COLORS -from norefund.gui.widgets import ( - ContextBar, - ModelDropdownButton, - bind_mousewheel, - card, - section_label, -) - - -class CalculatorView(ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - - scroll = ctk.CTkScrollableFrame(self, fg_color=COLORS["bg"]) - scroll.pack( - fill="both", expand=True, padx=theme.PAGE_GUTTER, pady=theme.SPACE_5 - ) - bind_mousewheel(scroll) - - ctk.CTkLabel( - scroll, - text="Manually estimate token cost for any LLM before making an API call.", - font=theme.font(theme.FONT_LABEL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_4)) - - self._build_config_card(scroll) - self._build_context_card(scroll) - self._build_cost_card(scroll) - - self._recalculate() - - # ------------------------------------------------------------------ - - def _build_config_card(self, parent) -> None: - card_frame = card(parent) - card_frame.pack(fill="x", pady=(0, theme.SPACE_4)) - inner = ctk.CTkFrame(card_frame, fg_color="transparent") - inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - ctk.CTkLabel( - inner, - text="Model", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._model_dropdown = ModelDropdownButton( - inner, - self.shell.models, - self.shell.models[0], - on_select=self._on_model_change, - ) - self._model_dropdown.pack(fill="x", pady=(0, theme.SPACE_4)) - - grid = ctk.CTkFrame(inner, fg_color="transparent") - grid.pack(fill="x") - grid.columnconfigure((0, 1), weight=1) - - in_col = ctk.CTkFrame(grid, fg_color="transparent") - in_col.grid(row=0, column=0, sticky="ew", padx=(0, theme.SPACE_2)) - ctk.CTkLabel( - in_col, - text="Input tokens", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._input_var = ctk.StringVar(value="0") - input_entry = ctk.CTkEntry( - in_col, - textvariable=self._input_var, - height=theme.CONTROL_MD, - font=theme.mono_font(theme.FONT_TITLE), - fg_color=COLORS["input_bg"], - border_width=0, - ) - input_entry.pack(fill="x") - input_entry.bind("", lambda _e: self._recalculate()) - - out_col = ctk.CTkFrame(grid, fg_color="transparent") - out_col.grid(row=0, column=1, sticky="ew", padx=(theme.SPACE_2, 0)) - ctk.CTkLabel( - out_col, - text="Est. output tokens", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._output_var = ctk.StringVar( - value=str(self.shell.settings.default_output_tokens) - ) - output_entry = ctk.CTkEntry( - out_col, - textvariable=self._output_var, - height=theme.CONTROL_MD, - font=theme.mono_font(theme.FONT_TITLE), - fg_color=COLORS["input_bg"], - border_width=0, - ) - output_entry.pack(fill="x") - output_entry.bind("", lambda _e: self._recalculate()) - - def _build_context_card(self, parent) -> None: - card_frame = card(parent) - card_frame.pack(fill="x", pady=(0, theme.SPACE_4)) - inner = ctk.CTkFrame(card_frame, fg_color="transparent") - inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - header_row = ctk.CTkFrame(inner, fg_color="transparent") - header_row.pack(fill="x", pady=(0, theme.SPACE_2)) - ctk.CTkLabel( - header_row, - text="Context window usage", - font=theme.font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - self._pct_label = ctk.CTkLabel( - header_row, text="—", font=theme.mono_font(theme.FONT_LABEL, "bold") - ) - self._pct_label.pack(side="right") - - self._context_bar = ContextBar(inner) - self._context_bar.pack(fill="x", pady=(0, theme.SPACE_2)) - - self._stat_label = ctk.CTkLabel( - inner, - text="", - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - anchor="w", - ) - self._stat_label.pack(fill="x", pady=(0, theme.SPACE_2)) - - self._status_row = ctk.CTkFrame(inner, fg_color="transparent") - self._status_row.pack(fill="x") - self._status_icon = ctk.CTkLabel(self._status_row, text="") - self._status_icon.pack(side="left", padx=(0, theme.SPACE_2)) - self._status_text = ctk.CTkLabel( - self._status_row, - text="", - font=theme.font(theme.FONT_LABEL), - anchor="w", - ) - self._status_text.pack(side="left") - - def _build_cost_card(self, parent) -> None: - card_frame = card(parent) - card_frame.pack(fill="x", pady=(0, theme.SPACE_4)) - inner = ctk.CTkFrame(card_frame, fg_color="transparent") - inner.pack(fill="both", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - grid = ctk.CTkFrame(inner, fg_color="transparent") - grid.pack(fill="x") - grid.columnconfigure((0, 1, 2), weight=1) - - self._input_cost_label, self._input_rate_label = self._cost_column( - grid, 0, "Input cost", border=False - ) - self._output_cost_label, self._output_rate_label = self._cost_column( - grid, 1, "Output cost", border=True - ) - self._total_cost_label, self._total_rate_label = self._cost_column( - grid, 2, "Total cost", border=True - ) - - ctk.CTkFrame(inner, fg_color=COLORS["border"], height=1).pack( - fill="x", pady=(theme.SPACE_4, theme.SPACE_2) - ) - ctk.CTkLabel( - inner, - text=( - " Prices are estimates based on locally stored " - "pricing data and may not reflect current provider rates." - ), - image=theme.icon_image("warning", size=14, color=COLORS["primary"]), - compound="left", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - wraplength=800, - justify="left", - ).pack(fill="x") - - def _cost_column(self, grid, col: int, label: str, border: bool): - col_frame = ctk.CTkFrame( - grid, - fg_color="transparent", - border_width=1 if border else 0, - border_color=COLORS["border"], - ) - col_frame.grid( - row=0, - column=col, - sticky="nsew", - padx=(theme.SPACE_4 if border else 0, 0), - ) - pad_frame = ctk.CTkFrame(col_frame, fg_color="transparent") - pad_frame.pack(padx=(theme.SPACE_3, 0) if border else 0, fill="x") - section_label(pad_frame, label).pack(fill="x") - value_label = ctk.CTkLabel( - pad_frame, - text="$0.00", - font=theme.mono_font(theme.FONT_DISPLAY, "bold"), - text_color=COLORS["primary"], - anchor="w", - ) - value_label.pack(fill="x") - rate_label = ctk.CTkLabel( - pad_frame, - text="", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ) - rate_label.pack(fill="x") - return value_label, rate_label - - # ------------------------------------------------------------------ - - def _on_model_change(self, _model) -> None: - self._output_var.set(str(self.shell.settings.default_output_tokens)) - self._recalculate() - - def _recalculate(self) -> None: - model = self._model_dropdown.selected_model() - input_tokens = formatting.parse_int(self._input_var.get()) - output_tokens = formatting.parse_int(self._output_var.get()) - - pct = costing.context_usage_pct(input_tokens, model.context_window) - fits = costing.fits_in_context(input_tokens, model.context_window) - in_cost = costing.input_cost(input_tokens, model) - out_cost = costing.output_cost(output_tokens, model) - total = in_cost + out_cost - - color = ( - formatting.context_color(pct) - if self.shell.settings.show_chunk_warnings - else COLORS["primary"] - ) - self._pct_label.configure( - text=formatting.fmt_context_pct(pct), text_color=color - ) - self._context_bar.set_value(pct, color=color) - input_str = formatting.fmt_num(input_tokens) - max_tokens = formatting.fmt_num(model.context_window) - self._stat_label.configure( - text=f"{input_str} input tokens · of {max_tokens} max" - ) - - if fits: - self._status_icon.configure( - image=theme.icon_image("check_circle", size=16, color=COLORS["primary"]) - ) - self._status_text.configure( - text="Fits in one context window", text_color=COLORS["muted_fg"] - ) - else: - exceed_by = max(0, input_tokens - model.context_window) - icon_color = ( - COLORS["destructive"] - if self.shell.settings.show_chunk_warnings - else COLORS["muted_fg"] - ) - self._status_icon.configure( - image=theme.icon_image("x_circle", size=16, color=icon_color) - ) - exceed_str = formatting.fmt_num(exceed_by) - self._status_text.configure( - text=f"Exceeds by {exceed_str} tokens — chunking required", - text_color=COLORS["muted_fg"], - ) - - self._input_cost_label.configure(text=formatting.fmt_cost(in_cost)) - self._input_rate_label.configure( - text=f"${model.input_price_per_million:,.2f} / 1M tokens" - ) - self._output_cost_label.configure(text=formatting.fmt_cost(out_cost)) - self._output_rate_label.configure( - text=f"${model.output_price_per_million:,.2f} / 1M tokens" - ) - self._total_cost_label.configure(text=formatting.fmt_cost(total)) - self._total_rate_label.configure(text=f"{model.currency}") diff --git a/src/norefund/gui/compare_view.py b/src/norefund/gui/compare_view.py deleted file mode 100644 index 8ee3da3..0000000 --- a/src/norefund/gui/compare_view.py +++ /dev/null @@ -1,749 +0,0 @@ -"""Compare — tokenize one input against many models at once, sorted by cost.""" - -from __future__ import annotations - -import threading -from collections.abc import Callable -from datetime import datetime -from pathlib import Path - -import customtkinter as ctk - -from norefund.core.compare import ( - CompareReport, - ModelComparison, - compare_paths, - compare_text, -) -from norefund.core.export import comparison_to_csv, comparison_to_markdown -from norefund.core.parsing import SUPPORTED_EXTENSIONS -from norefund.core.portfolio import ( - PortfolioProjection, - cheapest_that_fits, - project_costs, -) -from norefund.core.report.html import render_html -from norefund.core.report.model import ReportModel -from norefund.core.report.pdf import render_pdf -from norefund.gui import formatting, native_dialog, theme -from norefund.gui.dnd import enable_file_drop -from norefund.gui.theme import COLORS, SUPPORTED_FILETYPES -from norefund.gui.widgets import ( - ContextBar, - DropdownButton, - DropdownItem, - EmptyState, - IconButton, - ModelCheckList, - StatPill, - TabBar, - ThreadSafeSchedulerMixin, - bind_mousewheel, - card, - export_via_dialog, - export_via_dialog_bytes, -) - -_FREQUENCY_ITEMS = [ - DropdownItem(value="daily", label="Per day"), - DropdownItem(value="weekly", label="Per week"), - DropdownItem(value="monthly", label="Per month"), -] -_DEFAULT_RUNS_PER_PERIOD = "100" -_FREQUENCY_LABELS = {item.value: item.label for item in _FREQUENCY_ITEMS} - -_ROW_CONTEXT_BAR_HEIGHT = theme.SPACE_1 + 2 # 6px, denser than the default 8px bar - - -class CompareView(ThreadSafeSchedulerMixin, ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - self._paths: list[Path] = [] - self._report: CompareReport | None = None - self._last_projections: list[PortfolioProjection] = [] - self._last_projection_frequency: str | None = None - self._running = False - self.cancel_event: threading.Event | None = None - self._active_tab = "results" - - self._build_layout() - self._sync_run_button_state() - - # ------------------------------------------------------------------ - # Layout - # ------------------------------------------------------------------ - - def _build_layout(self) -> None: - body = ctk.CTkFrame(self, fg_color=COLORS["bg"]) - body.pack(fill="both", expand=True) - body.columnconfigure(0, weight=0, minsize=360) - body.columnconfigure(1, weight=1) - body.rowconfigure(0, weight=1) - - left = ctk.CTkScrollableFrame(body, fg_color=COLORS["bg"], width=360) - left.grid( - row=0, column=0, sticky="ns", padx=(theme.SPACE_4, theme.SPACE_2), - pady=theme.SPACE_4, - ) - bind_mousewheel(left) - self._build_input_card(left) - self._build_models_card(left) - - right = ctk.CTkFrame(body, fg_color=COLORS["bg"]) - right.grid( - row=0, column=1, sticky="nsew", padx=(theme.SPACE_2, theme.SPACE_4), - pady=theme.SPACE_4, - ) - self._build_results_area(right) - - def _build_input_card(self, parent) -> None: - card_frame = card(parent) - card_frame.pack(fill="x", pady=(0, theme.SPACE_3)) - inner = ctk.CTkFrame(card_frame, fg_color="transparent") - inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - ctk.CTkLabel( - inner, - text="Input", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["fg"], - ).pack(anchor="w", pady=(0, theme.SPACE_2)) - - self._text_box = ctk.CTkTextbox( - inner, - height=150, - fg_color=COLORS["input_bg"], - font=theme.font(theme.FONT_LABEL), - ) - self._text_box.pack(fill="x", pady=(0, theme.SPACE_2)) - enable_file_drop(inner, self._on_files_dropped, suffixes=SUPPORTED_EXTENSIONS) - - picker_row = ctk.CTkFrame(inner, fg_color="transparent") - picker_row.pack(fill="x", pady=(0, theme.SPACE_2)) - IconButton( - picker_row, "Pick File", icon="file_text", command=self._pick_file - ).pack(side="left", padx=(0, theme.SPACE_2)) - IconButton( - picker_row, "Pick Folder", icon="folder_open", command=self._pick_folder - ).pack(side="left") - - self._paths_container = ctk.CTkFrame(inner, fg_color="transparent") - self._paths_container.pack(fill="x") - - out_row = ctk.CTkFrame(inner, fg_color="transparent") - out_row.pack(fill="x", pady=(theme.SPACE_3, 0)) - ctk.CTkLabel( - out_row, - text="Est. output tokens:", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(0, theme.SPACE_2)) - self._output_var = ctk.StringVar( - value=str(self.shell.settings.default_output_tokens) - ) - self._output_entry = ctk.CTkEntry( - out_row, - textvariable=self._output_var, - width=90, - font=theme.mono_font(theme.FONT_BODY), - fg_color=COLORS["input_bg"], - border_width=1, - border_color=COLORS["input_bg"], - ) - self._output_entry.pack(side="left") - self._output_entry.bind( - "", lambda _e: self._on_output_tokens_change() - ) - - self._run_btn = IconButton( - inner, "Compare", icon="zap", variant="primary", command=self._run_compare - ) - self._run_btn.pack(fill="x", pady=(theme.SPACE_3, 0)) - - def _build_models_card(self, parent) -> None: - card_frame = card(parent) - card_frame.pack(fill="x", pady=(0, theme.SPACE_3)) - inner = ctk.CTkFrame(card_frame, fg_color="transparent") - inner.pack(fill="both", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - header = ctk.CTkFrame(inner, fg_color="transparent") - header.pack(fill="x", pady=(0, theme.SPACE_2)) - ctk.CTkLabel( - header, - text="Models", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["fg"], - ).pack(side="left") - - self._check_list = ModelCheckList( - inner, self.shell.models, on_change=self._sync_run_button_state, height=260 - ) - self._check_list.pack(fill="both", expand=True) - - def _build_results_area(self, parent) -> None: - self._build_tabs(parent) - - content = ctk.CTkFrame(parent, fg_color=COLORS["bg"]) - content.pack(fill="both", expand=True) - - self._results_frame = ctk.CTkFrame(content, fg_color=COLORS["bg"]) - self._build_results_tab(self._results_frame) - - self._projection_frame = ctk.CTkFrame(content, fg_color=COLORS["bg"]) - self._build_projection_tab(self._projection_frame) - - for frame in (self._results_frame, self._projection_frame): - frame.place(x=0, y=0, relwidth=1, relheight=1) - self._results_frame.tkraise() - - def _build_tabs(self, parent) -> None: - tab_bar = TabBar( - parent, - [("results", "Results"), ("projection", "Projection")], - self._active_tab, - on_change=self._switch_tab, - ) - tab_bar.pack(fill="x", pady=(0, theme.SPACE_2)) - - def _switch_tab(self, tab_id: str) -> None: - self._active_tab = tab_id - if tab_id == "projection": - self._projection_frame.tkraise() - else: - self._results_frame.tkraise() - - def _build_results_tab(self, parent) -> None: - self._stats_row = ctk.CTkFrame(parent, fg_color="transparent") - self._stats_row.pack(fill="x", pady=(0, theme.SPACE_2)) - - toolbar = ctk.CTkFrame(parent, fg_color="transparent") - toolbar.pack(fill="x", pady=(0, theme.SPACE_2)) - for label, command in ( - ("Export CSV", self._export_csv), - ("Export MD", self._export_md), - ("Export PDF", self._export_pdf), - ("Export HTML", self._export_html), - ): - IconButton(toolbar, label, icon="file_text", command=command).pack( - side="left", padx=(0, theme.SPACE_2) - ) - - self._results_scroll = ctk.CTkScrollableFrame(parent, fg_color=COLORS["bg"]) - self._results_scroll.pack(fill="both", expand=True) - bind_mousewheel(self._results_scroll) - self._show_empty_state() - - def _build_projection_tab(self, parent) -> None: - controls = ctk.CTkFrame(parent, fg_color="transparent") - controls.pack(fill="x", pady=(0, theme.SPACE_2)) - ctk.CTkLabel( - controls, - text="Runs:", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(0, theme.SPACE_2)) - self._runs_var = ctk.StringVar(value=_DEFAULT_RUNS_PER_PERIOD) - runs_entry = ctk.CTkEntry( - controls, - textvariable=self._runs_var, - width=70, - font=theme.mono_font(theme.FONT_BODY), - fg_color=COLORS["input_bg"], - border_width=0, - ) - runs_entry.pack(side="left", padx=(0, theme.SPACE_2)) - runs_entry.bind("", lambda _e: self._render_projection()) - self._frequency_dropdown = DropdownButton( - controls, - _FREQUENCY_ITEMS, - "daily", - on_select=lambda _v: self._render_projection(), - width=140, - ) - self._frequency_dropdown.pack(side="left") - - self._projection_stats_row = ctk.CTkFrame(parent, fg_color="transparent") - self._projection_stats_row.pack(fill="x", pady=(0, theme.SPACE_2)) - - self._projection_scroll = ctk.CTkScrollableFrame(parent, fg_color=COLORS["bg"]) - self._projection_scroll.pack(fill="both", expand=True) - bind_mousewheel(self._projection_scroll) - self._render_projection() - - # ------------------------------------------------------------------ - # Input selection - # ------------------------------------------------------------------ - - def _pick_file(self) -> None: - paths = native_dialog.ask_open_files(SUPPORTED_FILETYPES) - if paths: - self._set_selected_paths([Path(p) for p in paths]) - - def _pick_folder(self) -> None: - folder = native_dialog.ask_directory() - if folder: - self._set_selected_paths([Path(folder)]) - - def _on_files_dropped(self, paths: list[Path]) -> None: - self._set_selected_paths(paths) - - def _set_selected_paths(self, paths: list[Path]) -> None: - self._paths = paths - for child in self._paths_container.winfo_children(): - child.destroy() - for path in paths: - row = ctk.CTkFrame(self._paths_container, fg_color="transparent") - row.pack(fill="x", pady=(0, theme.SPACE_1)) - ctk.CTkLabel( - row, - text="", - image=theme.icon_image("check", size=14, color=COLORS["primary"]), - ).pack(side="left", padx=(0, theme.SPACE_2)) - ctk.CTkLabel( - row, - text=path.name, - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left", fill="x", expand=True) - - # ------------------------------------------------------------------ - # Run / cancel - # ------------------------------------------------------------------ - - def _run_compare(self) -> None: - if self._running: - return - models = self._check_list.selected_models() - if not models: - return - text = self._text_box.get("1.0", "end").strip() - if not self._paths and not text: - return - - self._running = True - self.cancel_event = threading.Event() - self._run_btn.configure( - text="Cancel", - image=theme.icon_image("x", size=16, color=COLORS["primary_fg"]), - command=self._cancel_compare, - state="normal", - ) - output_tokens = formatting.parse_int(self._output_var.get()) - paths = list(self._paths) - cancel_event = self.cancel_event - - threading.Thread( - target=self._compare_worker, - args=(text, paths, models, output_tokens, cancel_event), - daemon=True, - ).start() - - def _cancel_compare(self) -> None: - if self.cancel_event is not None: - self.cancel_event.set() - self._run_btn.configure( - text="Cancelling…", image=theme.blank_icon(size=16), state="disabled" - ) - - def _compare_worker( - self, text, paths, models, output_tokens, cancel_event - ) -> None: - try: - if paths: - report = compare_paths( - paths, models, output_tokens, cancel_event=cancel_event - ) - else: - report = compare_text(text, models, output_tokens) - except Exception as exc: # noqa: BLE001 - self._schedule(self._on_compare_error, str(exc)) - return - self._schedule(self._on_compare_complete, report) - - def _on_compare_complete(self, report: CompareReport) -> None: - if not self.winfo_exists(): - return - self._reset_busy_state() - self._report = report - self._render_results(report) - self._render_projection() - - def _on_compare_error(self, message: str) -> None: - if not self.winfo_exists(): - return - self._reset_busy_state() - self._show_empty_state(message=message) - - def _reset_busy_state(self) -> None: - self._running = False - self.cancel_event = None - if not self.winfo_exists(): - return - self._run_btn.configure( - text="Compare", - image=theme.icon_image("zap", size=16, color=COLORS["primary_fg"]), - command=self._run_compare, - ) - self._sync_run_button_state() - - def _sync_run_button_state(self) -> None: - """Keep the Run/Compare button's enabled state in sync with model - selection, instead of only silently no-oping on click with nothing - selected. Never overrides the busy Cancel/Cancelling state.""" - if self._running: - return - has_selection = bool(self._check_list.selected_models()) - self._run_btn.configure(state="normal" if has_selection else "disabled") - - # ------------------------------------------------------------------ - # What-if: recompute cost only, no re-tokenization - # ------------------------------------------------------------------ - - def _on_output_tokens_change(self) -> None: - self._output_entry.configure( - border_color=( - COLORS["input_bg"] - if formatting.is_valid_int(self._output_var.get()) - else COLORS["destructive"] - ) - ) - if self._report is None: - return - from norefund.core.compare import what_if - - output_tokens = formatting.parse_int(self._output_var.get()) - updated = [ - what_if(r, output_tokens, r.model) for r in self._report.results - ] - self._report = CompareReport( - source_label=self._report.source_label, results=updated - ) - self._render_results(self._report) - self._render_projection() - - # ------------------------------------------------------------------ - # Results rendering - # ------------------------------------------------------------------ - - def _show_empty_state(self, message: str | None = None) -> None: - for child in self._results_scroll.winfo_children(): - child.destroy() - for child in self._stats_row.winfo_children(): - child.destroy() - text = message or ( - "Enter text or pick a file, choose models, and click Compare" - ) - icon = "x_circle" if message else "bar_chart" - EmptyState(self._results_scroll, icon, text).pack(expand=True, pady=40) - - def _render_results(self, report: CompareReport) -> None: - for child in self._results_scroll.winfo_children(): - child.destroy() - for child in self._stats_row.winfo_children(): - child.destroy() - - successful = [r for r in report.results if r.error is None] - StatPill(self._stats_row, "Source", report.source_label).pack( - side="left", padx=(0, theme.SPACE_6) - ) - if successful: - cheapest = min(successful, key=lambda r: r.total_cost) - StatPill( - self._stats_row, "Cheapest", cheapest.model.display_name - ).pack(side="left", padx=theme.SPACE_6) - - sorted_results = sorted( - report.results, key=lambda r: (r.error is not None, r.total_cost) - ) - cheapest_id = cheapest.model.id if successful else None - - for result in sorted_results: - self._build_result_row(result, is_cheapest=result.model.id == cheapest_id) - - def _build_result_row(self, result: ModelComparison, is_cheapest: bool) -> None: - # The cheapest row used to recolor the whole card COLORS["primary"], - # which then made every child that also defaulted to a primary-ish - # color (the context bar, the "muted" text, a fits-icon tinted - # "muted") blend into the background it was sitting on -- most - # visibly, primary_fg-on-primary text is ~2.5:1 contrast, well under - # WCAG AA. A left accent strip signals "cheapest" without touching - # the card surface, so every child keeps its own normal semantic - # color regardless of is_cheapest. - row_card = ctk.CTkFrame( - self._results_scroll, - fg_color=COLORS["card"], - corner_radius=theme.RADIUS_CARD, - ) - row_card.pack(fill="x", pady=theme.SPACE_1) - if is_cheapest: - ctk.CTkFrame( - row_card, fg_color=COLORS["primary"], corner_radius=0, width=4 - ).pack(side="left", fill="y") - inner = ctk.CTkFrame(row_card, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - - top_row = ctk.CTkFrame(inner, fg_color="transparent") - top_row.pack(fill="x") - ctk.CTkLabel( - top_row, - text=result.model.display_name, - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - if is_cheapest: - ctk.CTkLabel( - top_row, - text="cheapest", - image=theme.icon_image("check", size=12, color=COLORS["fg"]), - compound="left", - font=theme.font(theme.FONT_SMALL, "bold"), - text_color=COLORS["fg"], - ).pack(side="right") - - if result.error is not None: - ctk.CTkLabel( - inner, - text=result.error, - image=theme.icon_image( - "x_circle", size=14, color=COLORS["destructive"] - ), - compound="left", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["destructive"], - anchor="w", - wraplength=800, - justify="left", - ).pack(fill="x", pady=(theme.SPACE_1, 0)) - return - - stats = ctk.CTkFrame(inner, fg_color="transparent") - stats.pack(fill="x", pady=(theme.SPACE_2, 0)) - tokens_str = formatting.fmt_num(result.token_count) - if result.tokenizer_is_approximate: - tokens_str += " (approx.)" - ctk.CTkLabel( - stats, - text=f"Tokens: {tokens_str}", - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(0, theme.SPACE_4)) - - bar_cell = ctk.CTkFrame(stats, fg_color="transparent") - bar_cell.pack(side="left", padx=(0, theme.SPACE_4)) - bar = ContextBar(bar_cell, height=_ROW_CONTEXT_BAR_HEIGHT, width=100) - bar.pack() - bar.set_value(result.context_usage_pct) - ctk.CTkLabel( - stats, - text=formatting.fmt_context_pct(result.context_usage_pct), - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(0, theme.SPACE_4)) - - fits_icon = "check_circle" if result.fits_in_context else "x_circle" - fits_color = ( - COLORS["primary"] if result.fits_in_context else COLORS["destructive"] - ) - ctk.CTkLabel( - stats, text="", image=theme.icon_image(fits_icon, size=14, color=fits_color) - ).pack(side="left", padx=(0, theme.SPACE_4)) - - cost_row = ctk.CTkFrame(inner, fg_color="transparent") - cost_row.pack(fill="x", pady=(theme.SPACE_1, 0)) - ctk.CTkLabel( - cost_row, - text=( - f"Input {formatting.fmt_cost(result.input_cost)} · " - f"Output {formatting.fmt_cost(result.output_cost)} · " - f"Total {formatting.fmt_cost(result.total_cost)}" - ), - font=theme.mono_font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - - # ------------------------------------------------------------------ - # Projection tab: extends the last Compare run's per-model token - # counts across a run frequency. Depends on self._report, so it - # re-renders whenever a Compare run completes or output tokens change. - # ------------------------------------------------------------------ - - def _render_projection(self) -> None: - for child in self._projection_scroll.winfo_children(): - child.destroy() - for child in self._projection_stats_row.winfo_children(): - child.destroy() - - corpus_tokens = ( - {r.model.id: r.token_count for r in self._report.results if r.error is None} - if self._report is not None - else {} - ) - if not corpus_tokens: - self._last_projections = [] - self._last_projection_frequency = None - EmptyState( - self._projection_scroll, - "bar_chart", - "Run Compare first to project volume costs", - ).pack(expand=True, pady=40) - return - - models = [ - r.model for r in self._report.results if r.model.id in corpus_tokens - ] - output_tokens = formatting.parse_int(self._output_var.get()) - runs_per_period = formatting.parse_int(self._runs_var.get()) - frequency = self._frequency_dropdown.selected_value() - - projections = project_costs( - corpus_tokens, output_tokens, runs_per_period, frequency, models - ) - self._last_projections = projections - self._last_projection_frequency = ( - f"{runs_per_period:g} runs {_FREQUENCY_LABELS.get(frequency, frequency)}" - ) - cheapest = cheapest_that_fits(projections) - - StatPill( - self._projection_stats_row, "Source", self._report.source_label - ).pack(side="left", padx=(0, theme.SPACE_6)) - if cheapest is not None: - StatPill( - self._projection_stats_row, - "Cheapest monthly", - cheapest.model.display_name, - ).pack(side="left", padx=theme.SPACE_6) - - sorted_projections = sorted( - projections, key=lambda p: (not p.fits_in_context, p.monthly_cost) - ) - cheapest_id = cheapest.model.id if cheapest is not None else None - for projection in sorted_projections: - self._build_projection_row( - projection, is_cheapest=projection.model.id == cheapest_id - ) - - def _build_projection_row( - self, projection: PortfolioProjection, is_cheapest: bool - ) -> None: - # Same left-accent-strip treatment as _build_result_row, and for - # the same reason: a whole-card COLORS["primary"] fill collided - # with primary_fg text/icons sitting on it (~2.5:1 contrast). - row_card = ctk.CTkFrame( - self._projection_scroll, - fg_color=COLORS["card"], - corner_radius=theme.RADIUS_CARD, - ) - row_card.pack(fill="x", pady=theme.SPACE_1) - if is_cheapest: - ctk.CTkFrame( - row_card, fg_color=COLORS["primary"], corner_radius=0, width=4 - ).pack(side="left", fill="y") - inner = ctk.CTkFrame(row_card, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - - top_row = ctk.CTkFrame(inner, fg_color="transparent") - top_row.pack(fill="x") - ctk.CTkLabel( - top_row, - text=projection.model.display_name, - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - if not projection.fits_in_context: - ctk.CTkLabel( - top_row, - text="corpus doesn't fit", - image=theme.icon_image( - "x_circle", size=12, color=COLORS["destructive"] - ), - compound="left", - font=theme.font(theme.FONT_SMALL, "bold"), - text_color=COLORS["destructive"], - ).pack(side="right") - elif is_cheapest: - ctk.CTkLabel( - top_row, - text="cheapest", - image=theme.icon_image("check", size=12, color=COLORS["fg"]), - compound="left", - font=theme.font(theme.FONT_SMALL, "bold"), - text_color=COLORS["fg"], - ).pack(side="right") - - cost_row = ctk.CTkFrame(inner, fg_color="transparent") - cost_row.pack(fill="x", pady=(theme.SPACE_2, 0)) - ctk.CTkLabel( - cost_row, - text=( - f"Per run {formatting.fmt_cost(projection.cost_per_run)} · " - f"Monthly {formatting.fmt_cost(projection.monthly_cost)} · " - f"Annual {formatting.fmt_cost(projection.annual_cost)}" - ), - font=theme.mono_font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - - # ------------------------------------------------------------------ - # Export - # ------------------------------------------------------------------ - - def _do_export( - self, - *, - extension: str, - filetype_label: str, - content_fn: Callable[[], str | bytes], - as_bytes: bool = False, - ) -> None: - exporter = export_via_dialog_bytes if as_bytes else export_via_dialog - exporter( - has_data=self._report is not None, - extension=extension, - filetype_label=filetype_label, - content_fn=content_fn, - ) - - def _export_csv(self) -> None: - self._do_export( - extension="csv", - filetype_label="CSV", - content_fn=lambda: comparison_to_csv(self._report), - ) - - def _export_md(self) -> None: - self._do_export( - extension="md", - filetype_label="Markdown", - content_fn=lambda: comparison_to_markdown(self._report), - ) - - def _build_report(self) -> ReportModel: - return ReportModel( - title="NoRefund Comparison Report", - generated_at=datetime.now(), - comparison=self._report, - portfolio=self._last_projections or None, - portfolio_frequency_label=self._last_projection_frequency, - ) - - def _export_pdf(self) -> None: - self._do_export( - extension="pdf", - filetype_label="PDF", - content_fn=lambda: render_pdf(self._build_report()), - as_bytes=True, - ) - - def _export_html(self) -> None: - self._do_export( - extension="html", - filetype_label="HTML", - content_fn=lambda: render_html(self._build_report()), - ) diff --git a/src/norefund/gui/dnd.py b/src/norefund/gui/dnd.py deleted file mode 100644 index d13c2f0..0000000 --- a/src/norefund/gui/dnd.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Optional drag-and-drop support, built on the optional `tkinterdnd2` dep. - -Every entry point degrades to a no-op when the library isn't installed, so -the app works identically with or without it. -""" - -from __future__ import annotations - -import re -from collections.abc import Callable -from pathlib import Path - -try: - from tkinterdnd2 import DND_FILES, TkinterDnD - - _AVAILABLE = True -except ImportError: - DND_FILES = None - TkinterDnD = None - _AVAILABLE = False - - -def dnd_available() -> bool: - return _AVAILABLE - - -def dnd_root_class(): - """Return the Tk root mixin class to use, or None if unavailable.""" - return TkinterDnD.Tk if _AVAILABLE else None - - -def parse_dropped_paths(data: str) -> list[Path]: - """Parse a Tcl brace-quoted list of paths, as delivered by tkinterdnd2. - - Paths containing spaces are wrapped in {}; others are bare and - whitespace-separated. - """ - tokens = re.findall(r"\{[^}]*\}|\S+", data.strip()) - return [Path(t.strip("{}")) for t in tokens] - - -def enable_file_drop( - widget, on_paths: Callable[[list[Path]], None], *, suffixes: set[str] | None = None -) -> bool: - """Register `widget` as a file drop target. Returns False (no-op) if - tkinterdnd2 isn't installed.""" - if not _AVAILABLE: - return False - - def _on_drop(event) -> None: - paths = parse_dropped_paths(event.data) - if suffixes is not None: - paths = [p for p in paths if p.is_dir() or p.suffix.lower() in suffixes] - if paths: - on_paths(paths) - - widget.drop_target_register(DND_FILES) - widget.dnd_bind("<>", _on_drop) - return True diff --git a/src/norefund/gui/fit_check_view.py b/src/norefund/gui/fit_check_view.py deleted file mode 100644 index 7ab7f62..0000000 --- a/src/norefund/gui/fit_check_view.py +++ /dev/null @@ -1,450 +0,0 @@ -"""Fit Check — does an open-weight model fit on a given hardware target? - -All computation is synchronous, pure `core/selfhost.py` math (no I/O, no -thread) so every control recalculates on the spot, the moment it changes. -""" - -from __future__ import annotations - -from datetime import datetime - -import customtkinter as ctk - -from norefund.core.architectures import ModelArchitecture, list_architectures -from norefund.core.hardware_registry import HardwareTarget, list_hardware -from norefund.core.quantization import ( - kv_cache_dtype_display_name, - list_kv_cache_dtypes, - list_quantization_levels, - quantization_display_name, -) -from norefund.core.report.html import render_html -from norefund.core.report.model import ReportModel -from norefund.core.report.pdf import render_pdf -from norefund.core.selfhost import FitResult, evaluate_fit -from norefund.gui import formatting, theme -from norefund.gui.theme import COLORS -from norefund.gui.widgets import ( - ContextBar, - DropdownButton, - DropdownItem, - IconButton, - StatPill, - bind_mousewheel, - card, - export_via_dialog, - export_via_dialog_bytes, - section_label, -) - -_DEFAULT_CONTEXT = "8192" -_DEFAULT_QUANTIZATION = "q4_k_m" -_DEFAULT_KV_CACHE_DTYPE = "fp16" - - -def _vendor_icon(vendor: str) -> ctk.CTkImage: - """A small brand-color icon for a model's vendor. Vendors with no - bundled brand mark (e.g. Qwen) get a solid accent-color dot instead -- - every row needs a leading icon of some kind so the model list stays - aligned (a mix of icon/no-icon rows in the same popover looks broken).""" - return theme.provider_icon_or_dot(vendor, size=14) - - -class FitCheckView(ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - self._context_edited = False - self._fit_result: FitResult | None = None - self._fit_architecture_name = "" - self._fit_hardware_name = "" - self._fit_quantization_name = "" - self._fit_kv_cache_name = "" - - self._architectures = list_architectures() - self._hardware = list_hardware() - self._arch_by_id: dict[str, ModelArchitecture] = { - a.id: a for a in self._architectures - } - self._hw_by_id: dict[str, HardwareTarget] = {h.id: h for h in self._hardware} - - self._model_items = [ - DropdownItem(value=a.id, label=a.display_name, icon=_vendor_icon(a.vendor)) - for a in self._architectures - ] - self._hw_items = [ - DropdownItem(value=h.id, label=h.display_name) for h in self._hardware - ] - self._quant_items = [ - DropdownItem(value=level, label=quantization_display_name(level)) - for level in list_quantization_levels() - ] - self._kv_items = [ - DropdownItem(value=dtype, label=kv_cache_dtype_display_name(dtype)) - for dtype in list_kv_cache_dtypes() - ] - - self._build_layout() - self._recalculate() - - # ------------------------------------------------------------------ - # Layout -- single pane: results on top, configuration at the bottom. - # ------------------------------------------------------------------ - - def _build_layout(self) -> None: - scroll = ctk.CTkScrollableFrame(self, fg_color=COLORS["bg"]) - scroll.pack( - fill="both", expand=True, padx=theme.PAGE_GUTTER, pady=theme.SPACE_5 - ) - bind_mousewheel(scroll) - - self._build_verdict(scroll) - self._build_utilization_card(scroll) - self._build_breakdown_card(scroll) - self._build_concurrency_card(scroll) - self._build_config_card(scroll) - self._warnings_frame = ctk.CTkFrame(scroll, fg_color="transparent") - self._warnings_frame.pack(fill="x") - - def _build_verdict(self, parent) -> None: - verdict_row = ctk.CTkFrame(parent, fg_color="transparent") - verdict_row.pack(fill="x", pady=(0, theme.SPACE_1)) - self._verdict_icon = ctk.CTkLabel(verdict_row, text="") - self._verdict_icon.pack(side="left", padx=(0, theme.SPACE_2)) - self._verdict_text = ctk.CTkLabel( - verdict_row, - text="", - font=theme.font(theme.FONT_HEADING, "bold"), - anchor="w", - ) - self._verdict_text.pack(side="left") - - for label, command in ( - ("Export PDF", self._export_pdf), - ("Export HTML", self._export_html), - ): - IconButton(verdict_row, label, icon="file_text", command=command).pack( - side="right", padx=(theme.SPACE_2, 0) - ) - - self._error_label = ctk.CTkLabel( - parent, - text="", - image=theme.icon_image("x_circle", size=14, color=COLORS["destructive"]), - compound="left", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["destructive"], - anchor="w", - wraplength=800, - justify="left", - ) - - def _build_utilization_card(self, parent) -> None: - util_card = card(parent) - self._util_card = util_card - util_card.pack(fill="x", pady=(theme.SPACE_2, theme.SPACE_3)) - util_inner = ctk.CTkFrame(util_card, fg_color="transparent") - util_inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - util_header = ctk.CTkFrame(util_inner, fg_color="transparent") - util_header.pack(fill="x", pady=(0, theme.SPACE_2)) - ctk.CTkLabel( - util_header, - text="VRAM utilization", - font=theme.font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - self._util_pct_label = ctk.CTkLabel( - util_header, text="—", font=theme.mono_font(theme.FONT_LABEL, "bold") - ) - self._util_pct_label.pack(side="right") - self._util_bar = ContextBar(util_inner) - self._util_bar.pack(fill="x") - - def _build_breakdown_card(self, parent) -> None: - breakdown_card = card(parent) - breakdown_card.pack(fill="x", pady=(0, theme.SPACE_3)) - grid = ctk.CTkFrame(breakdown_card, fg_color="transparent") - grid.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - grid.columnconfigure((0, 1, 2), weight=1) - self._weights_pill = StatPill(grid, "Weights") - self._weights_pill.grid(row=0, column=0, sticky="w", pady=(0, theme.SPACE_3)) - self._kv_pill = StatPill(grid, "KV cache") - self._kv_pill.grid(row=0, column=1, sticky="w", pady=(0, theme.SPACE_3)) - self._activation_pill = StatPill(grid, "Activations") - self._activation_pill.grid(row=0, column=2, sticky="w", pady=(0, theme.SPACE_3)) - self._overhead_pill = StatPill(grid, "Framework overhead") - self._overhead_pill.grid(row=1, column=0, sticky="w") - self._total_pill = StatPill(grid, "Total needed") - self._total_pill.grid(row=1, column=1, sticky="w") - self._headroom_pill = StatPill(grid, "Headroom") - self._headroom_pill.grid(row=1, column=2, sticky="w") - - def _build_concurrency_card(self, parent) -> None: - concurrency_card = card(parent) - concurrency_card.pack(fill="x", pady=(0, theme.SPACE_4)) - inner = ctk.CTkFrame(concurrency_card, fg_color="transparent") - inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - self._concurrency_pill = StatPill(inner, "Max concurrent requests") - self._concurrency_pill.pack(anchor="w") - - def _build_config_card(self, parent) -> None: - config_card = card(parent) - config_card.pack(fill="x", pady=(0, theme.SPACE_3)) - inner = ctk.CTkFrame(config_card, fg_color="transparent") - inner.pack(fill="x", padx=theme.CARD_PAD_X, pady=theme.CARD_PAD_Y) - - section_label(inner, "Configuration").pack(anchor="w", pady=(0, theme.SPACE_3)) - - ctk.CTkLabel( - inner, - text="Model", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._model_dropdown = DropdownButton( - inner, self._model_items, self._architectures[0].id, - on_select=lambda _v: self._recalculate(), - ) - self._model_dropdown.pack(fill="x", pady=(0, theme.SPACE_4)) - - precision_grid = ctk.CTkFrame(inner, fg_color="transparent") - precision_grid.pack(fill="x", pady=(0, theme.SPACE_4)) - precision_grid.columnconfigure((0, 1), weight=1) - - quant_col = ctk.CTkFrame(precision_grid, fg_color="transparent") - quant_col.grid(row=0, column=0, sticky="ew", padx=(0, theme.SPACE_2)) - ctk.CTkLabel( - quant_col, - text="Weight quantization", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._quant_dropdown = DropdownButton( - quant_col, self._quant_items, _DEFAULT_QUANTIZATION, - on_select=lambda _v: self._recalculate(), - ) - self._quant_dropdown.pack(fill="x") - - kv_col = ctk.CTkFrame(precision_grid, fg_color="transparent") - kv_col.grid(row=0, column=1, sticky="ew", padx=(theme.SPACE_2, 0)) - ctk.CTkLabel( - kv_col, - text="KV cache precision", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._kv_dropdown = DropdownButton( - kv_col, self._kv_items, _DEFAULT_KV_CACHE_DTYPE, - on_select=lambda _v: self._recalculate(), - ) - self._kv_dropdown.pack(fill="x") - - bottom_grid = ctk.CTkFrame(inner, fg_color="transparent") - bottom_grid.pack(fill="x") - bottom_grid.columnconfigure((0, 1), weight=1) - - context_col = ctk.CTkFrame(bottom_grid, fg_color="transparent") - context_col.grid(row=0, column=0, sticky="ew", padx=(0, theme.SPACE_2)) - ctk.CTkLabel( - context_col, - text="Context needed", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._context_var = ctk.StringVar(value=_DEFAULT_CONTEXT) - entry = ctk.CTkEntry( - context_col, - textvariable=self._context_var, - height=theme.CONTROL_MD, - font=theme.mono_font(theme.FONT_TITLE), - fg_color=COLORS["input_bg"], - border_width=0, - ) - entry.pack(fill="x") - self._last_autofilled_context = _DEFAULT_CONTEXT - entry.bind("", self._on_context_edited) - - hw_col = ctk.CTkFrame(bottom_grid, fg_color="transparent") - hw_col.grid(row=0, column=1, sticky="ew", padx=(theme.SPACE_2, 0)) - ctk.CTkLabel( - hw_col, - text="Hardware", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - self._hw_dropdown = DropdownButton( - hw_col, self._hw_items, self._hardware[0].id, - on_select=lambda _v: self._recalculate(), - ) - self._hw_dropdown.pack(fill="x") - - # ------------------------------------------------------------------ - # Auto-fill from the last analysis - # ------------------------------------------------------------------ - - def _on_context_edited(self, _event=None) -> None: - # Gate on the value actually changing, not on any keystroke -- Tab, - # arrow keys, or a Ctrl+C copy also fire without - # editing the field, and would otherwise permanently disable the - # on_show() auto-fill for a field the user never actually touched. - if self._context_var.get() != self._last_autofilled_context: - self._context_edited = True - self._recalculate() - - def on_show(self) -> None: - """Called by MainView whenever this view is navigated to.""" - if not self._context_edited: - last = getattr(self.shell, "last_analysis_tokens", None) - if last: - value = str(last) - self._context_var.set(value) - self._last_autofilled_context = value - self._recalculate() - - # ------------------------------------------------------------------ - # Compute + render - # ------------------------------------------------------------------ - - def _recalculate(self) -> None: - architecture = self._arch_by_id[self._model_dropdown.selected_value()] - hardware = self._hw_by_id[self._hw_dropdown.selected_value()] - quantization = self._quant_dropdown.selected_value() - kv_cache_dtype = self._kv_dropdown.selected_value() - context_length = formatting.parse_int(self._context_var.get()) - - result = evaluate_fit( - architecture, - hardware, - quantization, - context_length, - kv_cache_dtype=kv_cache_dtype, - ) - self._fit_result = result - self._fit_architecture_name = architecture.display_name - self._fit_hardware_name = hardware.display_name - self._fit_quantization_name = quantization_display_name(quantization) - self._fit_kv_cache_name = kv_cache_dtype_display_name(kv_cache_dtype) - self._render_result(result) - - def _render_result(self, result: FitResult) -> None: - if result.error is not None: - self._verdict_icon.configure( - image=theme.icon_image("x_circle", size=20, color=COLORS["destructive"]) - ) - self._verdict_text.configure( - text="Can't estimate", text_color=COLORS["destructive"] - ) - self._error_label.configure(text=result.error) - # Explicit `before=` keeps this pinned right under the verdict row - # regardless of pack/forget history -- pack() alone would append - # it after every card currently pack()ed (util/breakdown/etc.). - self._error_label.pack( - fill="x", pady=(0, theme.SPACE_3), before=self._util_card - ) - self._util_bar.set_value(None) - self._util_pct_label.configure(text="—") - for pill in ( - self._weights_pill, self._kv_pill, self._activation_pill, - self._overhead_pill, self._total_pill, self._headroom_pill, - ): - pill.set_text("—") - self._concurrency_pill.set_text("—") - self._render_warnings(()) - return - - self._error_label.pack_forget() - - assert result.estimate is not None - verdict_icon = "check_circle" if result.fits else "x_circle" - verdict_color = COLORS["primary"] if result.fits else COLORS["destructive"] - verdict_text = "Fits on this hardware" if result.fits else "Does not fit" - self._verdict_icon.configure( - image=theme.icon_image(verdict_icon, size=20, color=verdict_color) - ) - self._verdict_text.configure(text=verdict_text, text_color=verdict_color) - - self._util_bar.set_value(result.utilization_pct) - self._util_pct_label.configure( - text=formatting.fmt_context_pct(result.utilization_pct), - text_color=formatting.context_color(result.utilization_pct), - ) - - self._weights_pill.set_text(formatting.fmt_bytes(result.estimate.weights_bytes)) - self._kv_pill.set_text(formatting.fmt_bytes(result.estimate.kv_cache_bytes)) - self._activation_pill.set_text( - formatting.fmt_bytes(result.estimate.activation_bytes) - ) - self._overhead_pill.set_text( - formatting.fmt_bytes(result.estimate.framework_overhead_bytes) - ) - self._total_pill.set_text(formatting.fmt_bytes(result.estimate.total_bytes)) - self._headroom_pill.set_text(self._fmt_headroom(result.headroom_bytes)) - - self._concurrency_pill.set_text( - formatting.fmt_num(result.max_concurrent_requests) - if result.max_concurrent_requests is not None - else "—" - ) - - self._render_warnings(result.warnings) - - @staticmethod - def _fmt_headroom(headroom_bytes: int | None) -> str: - if headroom_bytes is None: - return "—" - if headroom_bytes < 0: - return f"-{formatting.fmt_bytes(-headroom_bytes)} over" - return formatting.fmt_bytes(headroom_bytes) - - def _render_warnings(self, warnings: tuple[str, ...]) -> None: - for child in self._warnings_frame.winfo_children(): - child.destroy() - for message in warnings: - ctk.CTkLabel( - self._warnings_frame, - text=message, - image=theme.icon_image("warning", size=14, color=COLORS["warning"]), - compound="left", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - anchor="w", - wraplength=800, - justify="left", - ).pack(fill="x", pady=(0, theme.SPACE_2)) - - # ------------------------------------------------------------------ - # Export - # ------------------------------------------------------------------ - - def _build_report(self) -> ReportModel: - return ReportModel( - title="NoRefund Fit Check Report", - generated_at=datetime.now(), - fit=self._fit_result, - fit_architecture_name=self._fit_architecture_name, - fit_hardware_name=self._fit_hardware_name, - fit_quantization_name=self._fit_quantization_name, - fit_kv_cache_name=self._fit_kv_cache_name, - ) - - def _export_pdf(self) -> None: - export_via_dialog_bytes( - has_data=self._fit_result is not None, - extension="pdf", - filetype_label="PDF", - content_fn=lambda: render_pdf(self._build_report()), - ) - - def _export_html(self) -> None: - export_via_dialog( - has_data=self._fit_result is not None, - extension="html", - filetype_label="HTML", - content_fn=lambda: render_html(self._build_report()), - ) diff --git a/src/norefund/gui/formatting.py b/src/norefund/gui/formatting.py deleted file mode 100644 index fad8770..0000000 --- a/src/norefund/gui/formatting.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Pure, None-safe formatting helpers shared by every GUI view. - -Kept side-effect free and independent of any live widget so it can be -unit-tested without a Tk root (see tests/test_formatting.py). -""" - -from __future__ import annotations - -from norefund.core.models_registry import ModelInfo -from norefund.gui.theme import COLORS - - -def model_label(model: ModelInfo) -> str: - label = f"{model.display_name} · {model.provider}" - if model.tokenizer_is_approximate: - label += " (approx.)" - return label - - -def fmt_num(n: int) -> str: - return f"{n:,}" - - -def fmt_float(value: float | None, decimals: int = 1) -> str: - if value is None: - return "—" - return f"{value:,.{decimals}f}" - - -def fmt_context_pct(pct: float | None) -> str: - if pct is None: - return "—" - return f"{pct:.1f}%" - - -def fmt_cost(value: float) -> str: - if value < 0.01: - return f"${value:.6f}" - return f"${value:,.2f}" - - -def fmt_context_window(n: int) -> str: - if n >= 1_000_000: - return f"{n / 1_000_000:.1f}M tokens".replace(".0M", "M") - if n >= 1_000: - return f"{n / 1_000:.0f}K tokens" - return f"{n} tokens" - - -def fmt_bytes(n: int | None) -> str: - if n is None: - return "—" - value = float(n) - for unit in ("B", "KB", "MB", "GB"): - if value < 1024 or unit == "GB": - return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" - value /= 1024 - return f"{value:.1f} GB" - - -def parse_int(value: str, default: int = 0) -> int: - cleaned = value.replace(",", "").strip() - if not cleaned: - return default - try: - return int(float(cleaned)) - except ValueError: - return default - - -def is_valid_int(value: str) -> bool: - """Whether `value` would parse as a real number for parse_int(), rather - than silently falling back to its default -- lets a numeric entry flag - unparseable input instead of correcting it with no feedback.""" - cleaned = value.replace(",", "").strip() - if not cleaned: - return False - try: - int(float(cleaned)) - return True - except ValueError: - return False - - -def context_color(pct: float | None) -> tuple[str, str]: - """Returns a (light_hex, dark_hex) COLORS-style tuple for the given - context-usage percentage: primary <75%, warning 75-100%, destructive >=100%.""" - if pct is None or pct < 75: - return COLORS["primary"] - if pct < 100: - return COLORS["warning"] - return COLORS["destructive"] - - -def elide_middle(text: str, max_chars: int) -> str: - """Truncate long text in the middle, keeping the tail (usually a - filename) visible since that's the part that identifies the resource.""" - if len(text) <= max_chars or max_chars <= 1: - return text - tail_len = max(1, max_chars // 3) - head_len = max_chars - tail_len - 1 - return f"{text[:head_len]}…{text[-tail_len:]}" - - -def blend(top_hex: str, bottom_hex: str, alpha: float) -> str: - """Alpha-composite top_hex over bottom_hex, returning a flat hex color. - - Generic color math, not specific to "accent over background" -- e.g. - motion.py uses this to darken a button's own fg_color by blending - black over it, where neither color is an "accent." - """ - top = _hex_to_rgb(top_hex) - bottom = _hex_to_rgb(bottom_hex) - blended = tuple(round(bottom[i] + (top[i] - bottom[i]) * alpha) for i in range(3)) - return "#{:02x}{:02x}{:02x}".format(*blended) - - -def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: - h = hex_color.lstrip("#") - return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) diff --git a/src/norefund/gui/main_view.py b/src/norefund/gui/main_view.py deleted file mode 100644 index 95950b2..0000000 --- a/src/norefund/gui/main_view.py +++ /dev/null @@ -1,378 +0,0 @@ -"""App shell: sidebar navigation, header, and view switching.""" - -from __future__ import annotations - -import threading -from importlib.metadata import PackageNotFoundError, version - -import customtkinter as ctk - -from norefund.core.models_registry import list_models -from norefund.core.settings import SettingsStore -from norefund.gui import motion, theme -from norefund.gui.theme import COLORS -from norefund.gui.widgets import ( - DropdownButton, - NoticeBanner, - SidebarItem, - ThreadSafeSchedulerMixin, - section_label, -) - -_FALLBACK_VERSION = "0.1.0" - -try: - _APP_VERSION = version("norefund") -except PackageNotFoundError: - _APP_VERSION = _FALLBACK_VERSION - -_TITLES = { - "calculator": "Token Calculator", - "parser": "File Parser", - "registry": "Model Registry", - "resources": "Resources", - "compare": "Compare Models", - "fit_check": "Self-Host Fit Check", -} - - -class MainView(ThreadSafeSchedulerMixin, ctk.CTkFrame): - VIEW_CALCULATOR = "calculator" - VIEW_PARSER = "parser" - VIEW_REGISTRY = "registry" - VIEW_RESOURCES = "resources" - VIEW_COMPARE = "compare" - VIEW_FIT_CHECK = "fit_check" - - def __init__(self, parent) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.pack_propagate(False) - - self.settings_store = SettingsStore() - self.settings = self.settings_store.load() - self.models = list_models() - - self._nav_items: dict[str, SidebarItem] = {} - self._view_cache: dict[str, ctk.CTkFrame] = {} - self._current_view: str | None = None - self._file_count = 0 - self.last_analysis_tokens: int | None = None - - self._build_sidebar() - right = ctk.CTkFrame(self, fg_color=COLORS["bg"], corner_radius=0) - right.pack(side="right", fill="both", expand=True) - self._build_header(right) - - self._banner: NoticeBanner | None = None - self._content = ctk.CTkFrame(right, fg_color=COLORS["bg"], corner_radius=0) - self._content.pack(side="top", fill="both", expand=True) - - self.show_view(self.VIEW_CALCULATOR) - self._bind_shortcuts() - if not self.settings.onboarding_dismissed: - threading.Thread(target=self._check_onboarding, daemon=True).start() - - # ------------------------------------------------------------------ - # First-run onboarding banner - # ------------------------------------------------------------------ - - def _check_onboarding(self) -> None: - from norefund.core.resources import build_resource_report - - report = build_resource_report(self.models) - if not any(t.is_cached for t in report.tokenizers): - self._schedule(self._show_onboarding_banner) - - def _show_onboarding_banner(self) -> None: - if not self.winfo_exists() or self.settings.onboarding_dismissed: - return - self._banner = NoticeBanner( - self._content.master, - "No tokenizers downloaded yet — counts will be rough approximations.", - action_text="Open Resources", - on_action=lambda: self.show_view(self.VIEW_RESOURCES), - on_dismiss=self._dismiss_onboarding, - ) - self._banner.pack(side="top", fill="x", before=self._content) - - def _dismiss_onboarding(self) -> None: - self.settings.onboarding_dismissed = True - self.settings_store.save(self.settings) - - # ------------------------------------------------------------------ - # Keyboard shortcuts - # ------------------------------------------------------------------ - - def _bind_shortcuts(self) -> None: - root = self.winfo_toplevel() - view_order = [ - self.VIEW_CALCULATOR, - self.VIEW_PARSER, - self.VIEW_COMPARE, - self.VIEW_REGISTRY, - self.VIEW_RESOURCES, - self.VIEW_FIT_CHECK, - ] - for i, view_id in enumerate(view_order, start=1): - root.bind(f"", lambda _e, v=view_id: self.show_view(v)) - root.bind("", self._cancel_active_work) - - def _cancel_active_work(self, _event=None) -> None: - view = self._view_cache.get(self._current_view) - cancel_event = getattr(view, "cancel_event", None) - if cancel_event is not None: - cancel_event.set() - - # ------------------------------------------------------------------ - # Sidebar - # ------------------------------------------------------------------ - - def _build_sidebar(self) -> None: - sidebar = ctk.CTkFrame( - self, width=236, fg_color=COLORS["sidebar"], corner_radius=0 - ) - sidebar.pack(side="left", fill="y") - sidebar.pack_propagate(False) - - logo_row = ctk.CTkFrame(sidebar, fg_color="transparent") - logo_row.pack(fill="x", padx=theme.SPACE_4, pady=(theme.SPACE_5, theme.SPACE_4)) - ctk.CTkLabel( - logo_row, - text="$", - width=28, - height=28, - corner_radius=theme.RADIUS_CARD, - fg_color=COLORS["primary"], - text_color=COLORS["primary_fg"], - font=theme.font(theme.FONT_TITLE, "bold"), - ).pack(side="left") - title_col = ctk.CTkFrame(logo_row, fg_color="transparent") - title_col.pack(side="left", padx=(theme.SPACE_3, 0)) - ctk.CTkLabel( - title_col, - text="NoRefund", - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["sidebar_fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - title_col, - text="TOKEN & COST ANALYZER", - font=theme.font(theme.FONT_MICRO), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x") - - self._nav_section( - sidebar, - "Tools", - [ - (self.VIEW_CALCULATOR, "Token Calculator", "calculator"), - (self.VIEW_PARSER, "File Parser", "folder_open"), - (self.VIEW_COMPARE, "Compare Models", "bar_chart"), - (self.VIEW_FIT_CHECK, "Fit Check", "hash"), - ], - ) - self._nav_section( - sidebar, - "Data", - [ - (self.VIEW_REGISTRY, "Model Registry", "layers"), - (self.VIEW_RESOURCES, "Resources", "hard_drive"), - ], - ) - - footer = ctk.CTkFrame(sidebar, fg_color="transparent") - footer.pack(side="bottom", fill="x", padx=theme.SPACE_3, pady=theme.SPACE_3) - warn = ctk.CTkFrame( - footer, fg_color=COLORS["muted"], corner_radius=theme.RADIUS_CARD - ) - warn.pack(fill="x") - ctk.CTkLabel( - warn, - text=" Local analysis. Your files never leave this machine.", - image=theme.icon_image("check", size=14, color=COLORS["muted_fg"]), - compound="left", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - wraplength=190, - justify="center", - ).pack(padx=theme.SPACE_2, pady=theme.SPACE_2, fill="x") - ctk.CTkLabel( - footer, - text=f"v{_APP_VERSION} · open-source", - font=theme.font(theme.FONT_MICRO), - text_color=COLORS["muted_fg"], - ).pack(pady=(theme.SPACE_2, 0)) - - def _nav_section( - self, parent, label: str, items: list[tuple[str, str, str]] - ) -> None: - section_label(parent, label).pack( - fill="x", padx=theme.SPACE_4, pady=(theme.SPACE_3, theme.SPACE_1) - ) - for view_id, text, icon in items: - item = SidebarItem( - parent, text, icon, command=lambda v=view_id: self.show_view(v) - ) - item.pack(fill="x", padx=theme.SPACE_2, pady=1) - self._nav_items[view_id] = item - - # ------------------------------------------------------------------ - # Header - # ------------------------------------------------------------------ - - def _build_header(self, parent) -> None: - header = ctk.CTkFrame( - parent, height=52, fg_color=COLORS["card"], corner_radius=0 - ) - header.pack(side="top", fill="x") - header.pack_propagate(False) - - left = ctk.CTkFrame(header, fg_color="transparent") - left.pack(side="left", padx=theme.SPACE_4) - self._header_title = ctk.CTkLabel( - left, - text=_TITLES[self.VIEW_CALCULATOR], - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - ) - self._header_title.pack(side="left") - self._header_badge = ctk.CTkLabel( - left, - text="", - font=theme.font(theme.FONT_SMALL), - fg_color=COLORS["muted"], - text_color=COLORS["muted_fg"], - corner_radius=8, - ) - - right = ctk.CTkFrame(header, fg_color="transparent") - right.pack(side="right", padx=theme.SPACE_3) - initial_icon = "moon" if ctk.get_appearance_mode() == "Dark" else "sun" - self._theme_btn = ctk.CTkButton( - right, - text="", - # CTkButton only creates its internal image-label widget when - # constructed with an image; a later configure(image=...) on a - # button built with image=None is a silent no-op (it calls - # _update_image(), which bails out early if _image_label is - # still None) -- so this needs a real image from the start, - # not just whatever _sync_theme_icon() sets afterwards. - image=theme.icon_image(initial_icon, size=16, color=COLORS["fg"]), - width=theme.CONTROL_MD, - height=theme.CONTROL_MD, - corner_radius=theme.RADIUS_CARD, - fg_color="transparent", - hover_color=COLORS["muted"], - command=self._toggle_theme, - ) - self._theme_btn.pack(side="left", padx=theme.SPACE_1) - motion.press_feedback(self._theme_btn) - settings_btn = ctk.CTkButton( - right, - text="", - image=theme.icon_image("settings", size=16, color=COLORS["fg"]), - width=theme.CONTROL_MD, - height=theme.CONTROL_MD, - corner_radius=theme.RADIUS_CARD, - fg_color="transparent", - hover_color=COLORS["muted"], - command=self._open_settings, - ) - settings_btn.pack(side="left", padx=theme.SPACE_1) - motion.press_feedback(settings_btn) - - self._sync_theme_icon() - - def _sync_theme_icon(self) -> None: - is_dark = ctk.get_appearance_mode() == "Dark" - icon_name = "moon" if is_dark else "sun" - self._theme_btn.configure( - image=theme.icon_image(icon_name, size=16, color=COLORS["fg"]) - ) - - def _toggle_theme(self) -> None: - new_mode = "Light" if ctk.get_appearance_mode() == "Dark" else "Dark" - ctk.set_appearance_mode(new_mode) - self.settings.theme = new_mode.lower() - self.settings_store.save(self.settings) - self._sync_theme_icon() - - def _open_settings(self) -> None: - from norefund.gui.settings_modal import SettingsModal - - SettingsModal(self, self.settings, self._on_settings_saved) - - def _on_settings_saved(self, settings) -> None: - self.settings = settings - - # ------------------------------------------------------------------ - # View switching - # ------------------------------------------------------------------ - - def update_last_analysis_tokens(self, count: int) -> None: - self.last_analysis_tokens = count - - def update_header_count(self, count: int) -> None: - self._file_count = count - if self._current_view == self.VIEW_PARSER and count > 0: - self._header_badge.configure( - text=f"{count} file{'s' if count != 1 else ''}" - ) - self._header_badge.pack( - side="left", padx=(theme.SPACE_3, 0), ipadx=6, ipady=2 - ) - else: - self._header_badge.pack_forget() - - def show_view(self, view_id: str) -> None: - # Popovers (e.g. the model dropdown) are separate CTkToplevels, so - # raising a different cached view frame on top of them doesn't - # close them on its own -- force it here on every navigation. - # Closes every open dropdown popover app-wide, including - # ModelDropdownButton's -- it shares DropdownButton's registry. - DropdownButton.close_all() - for vid, item in self._nav_items.items(): - item.set_active(vid == view_id) - - if view_id not in self._view_cache: - view = self._make_view(view_id) - view.place(in_=self._content, x=0, y=0, relwidth=1, relheight=1) - self._view_cache[view_id] = view - active_view = self._view_cache[view_id] - active_view.tkraise() - on_show = getattr(active_view, "on_show", None) - if on_show is not None: - on_show() - - self._current_view = view_id - self._header_title.configure(text=_TITLES[view_id]) - self.update_header_count(self._file_count) - - def _make_view(self, view_id: str) -> ctk.CTkFrame: - if view_id == self.VIEW_CALCULATOR: - from norefund.gui.calculator_view import CalculatorView - - return CalculatorView(self._content, self) - if view_id == self.VIEW_PARSER: - from norefund.gui.parser_view import ParserView - - return ParserView(self._content, self) - if view_id == self.VIEW_REGISTRY: - from norefund.gui.registry_view import RegistryView - - return RegistryView(self._content, self) - if view_id == self.VIEW_RESOURCES: - from norefund.gui.resources_view import ResourcesView - - return ResourcesView(self._content, self) - if view_id == self.VIEW_COMPARE: - from norefund.gui.compare_view import CompareView - - return CompareView(self._content, self) - if view_id == self.VIEW_FIT_CHECK: - from norefund.gui.fit_check_view import FitCheckView - - return FitCheckView(self._content, self) - raise ValueError(f"Unknown view: {view_id}") diff --git a/src/norefund/gui/motion.py b/src/norefund/gui/motion.py deleted file mode 100644 index 547df07..0000000 --- a/src/norefund/gui/motion.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Two hand-rolled motion helpers — the app's entire animation surface. - -CustomTkinter has no transition system (no transforms, no opacity, no -springs on Tk widgets), so both of these animate `fg_color`/`text_color` -directly via `after()` and `formatting.blend()`. Per Emil Kowalski's -frequency rule, nothing here touches view switching or hover (CTk already -handles hover) — only press feedback and the loading->loaded handoff. -""" - -from __future__ import annotations - -import time -from collections.abc import Callable -from tkinter import TclError - -from norefund.gui import formatting - -_PRESS_FLOOR_MS = 90 -_FADE_STEP_MS = 16 - - -def _as_pair(color: str | tuple[str, str]) -> tuple[str, str]: - if isinstance(color, str): - return (color, color) - return (color[0], color[1]) - - -def _safe_configure(widget, **kwargs) -> None: - try: - if not widget.winfo_exists(): - return - widget.configure(**kwargs) - except (TclError, RuntimeError): - pass - - -def press_feedback(button, *, alpha: float = 0.15) -> None: - """Darken `button`'s fg_color on pointer-down, restore on release. - - Apple: respond on press, not release. For buttons with a transparent - fg_color (e.g. sidebar rows) the tint is derived from hover_color - instead, since there's no solid fg_color to darken. A short floor - keeps fast clicks visually registering even when press and release - land in the same event-loop tick. - - For a toggle/selection button (e.g. a filter pill), `command` runs as - part of the same ButtonRelease-1 event, before this handler, and may - recolor the button to reflect its new resting state (selected vs not). - The scheduled restore only reverts fg_color if nothing has touched it - since the press-darken was applied -- otherwise it would clobber the - command's recolor a moment after it happens. - """ - pressed_at = 0.0 - original_fg: str | tuple[str, str] | None = None - pressed_fg: tuple[str, str] | None = None - - def _on_press(_event=None) -> None: - nonlocal pressed_at, original_fg, pressed_fg - fg = button.cget("fg_color") - hover = button.cget("hover_color") - reference = fg if fg not in (None, "transparent") else hover - if reference in (None, "transparent"): - original_fg = None - return - ref_light, ref_dark = _as_pair(reference) - pressed_at = time.monotonic() - original_fg = fg - pressed_fg = ( - formatting.blend("#000000", ref_light, alpha), - formatting.blend("#000000", ref_dark, alpha), - ) - _safe_configure(button, fg_color=pressed_fg) - - def _on_release(_event=None) -> None: - if original_fg is None: - return - elapsed_ms = (time.monotonic() - pressed_at) * 1000 - remaining = max(0, _PRESS_FLOOR_MS - int(elapsed_ms)) - - def _restore() -> None: - if not button.winfo_exists(): - return - current = _as_pair(button.cget("fg_color")) - if current == _as_pair(pressed_fg): - _safe_configure(button, fg_color=original_fg) - - try: - button.after(remaining, _restore) - except (TclError, RuntimeError): - pass - - button.bind("", _on_press, add="+") - button.bind("", _on_release, add="+") - - -def fade_text_color( - widget, - from_token: tuple[str, str], - to_token: tuple[str, str], - duration: int = 150, - on_done: Callable[[], None] | None = None, -) -> None: - """Interpolate `widget`'s text_color from `from_token` to `to_token`. - - Auto-cancels if `widget` is destroyed mid-fade so a stray `after()` - callback can't fire against a dead widget. - """ - steps = max(1, duration // _FADE_STEP_MS) - cancelled = False - - def _cancel(_event=None) -> None: - nonlocal cancelled - cancelled = True - - widget.bind("", _cancel, add="+") - - def _step(i: int) -> None: - if cancelled: - return - alpha = i / steps - light = formatting.blend(to_token[0], from_token[0], alpha) - dark = formatting.blend(to_token[1], from_token[1], alpha) - _safe_configure(widget, text_color=(light, dark)) - if i >= steps: - if on_done is not None: - on_done() - return - try: - widget.after(_FADE_STEP_MS, _step, i + 1) - except (TclError, RuntimeError): - pass - - _step(0) diff --git a/src/norefund/gui/native_dialog.py b/src/norefund/gui/native_dialog.py deleted file mode 100644 index 08e90eb..0000000 --- a/src/norefund/gui/native_dialog.py +++ /dev/null @@ -1,99 +0,0 @@ -"""File pickers that prefer the desktop's native dialog over Tk's own. - -Stock ``tkinter.filedialog`` opens Tk's built-in Tcl file browser on Linux, -not the desktop's real file manager -- unlike Windows/macOS, where tkinter -dialogs already are the native picker. When zenity is available (standard on -GNOME and most Linux desktops), shell out to it for the picker users already -know; fall back to the stock Tk dialog everywhere else (no zenity, a -non-Linux platform, or anything other than a clean cancel). -""" - -from __future__ import annotations - -import shutil -import subprocess -import sys -import time -import tkinter as tk -from pathlib import Path -from tkinter import filedialog - -_ZENITY = "zenity" -_USER_CANCELLED = 1 -_POLL_INTERVAL_S = 0.05 - - -def _zenity_available() -> bool: - return sys.platform.startswith("linux") and shutil.which(_ZENITY) is not None - - -def _filter_args(filetypes: list[tuple[str, str]]) -> list[str]: - args = [] - for label, patterns in filetypes: - args += ["--file-filter", f"{label} | {patterns}"] - return args - - -def _run_zenity(args: list[str]) -> subprocess.CompletedProcess[str]: - """Like `subprocess.run(capture_output=True, text=True)`, but pumps the - Tk event loop while zenity is open. `subprocess.run` blocks the whole - interpreter until the child exits, which stops the main window from - repainting -- it visibly freezes under most Linux compositors -- for as - long as the dialog stays open. Polling with `Popen` instead lets Tk - keep processing its own events in between checks, the same nested-loop - approach Tk's own native dialogs use internally. - """ - proc = subprocess.Popen( - args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - root = tk._default_root - while proc.poll() is None: - if root is not None: - root.update() - time.sleep(_POLL_INTERVAL_S) - stdout, stderr = proc.communicate() - return subprocess.CompletedProcess(args, proc.returncode, stdout, stderr) - - -def ask_open_files(filetypes: list[tuple[str, str]]) -> list[str]: - if _zenity_available(): - result = _run_zenity( - [_ZENITY, "--file-selection", "--multiple", "--separator=\n"] - + _filter_args(filetypes) - ) - if result.returncode == 0: - return [p for p in result.stdout.strip().split("\n") if p] - if result.returncode == _USER_CANCELLED: - return [] - return list(filedialog.askopenfilenames(filetypes=filetypes)) - - -def ask_directory() -> str: - if _zenity_available(): - result = _run_zenity([_ZENITY, "--file-selection", "--directory"]) - if result.returncode == 0: - return result.stdout.strip() - if result.returncode == _USER_CANCELLED: - return "" - return filedialog.askdirectory() - - -def ask_save_file(*, defaultextension: str, filetypes: list[tuple[str, str]]) -> str: - if _zenity_available(): - result = _run_zenity( - [_ZENITY, "--file-selection", "--save", "--confirm-overwrite"] - + _filter_args(filetypes) - ) - if result.returncode == 0: - path = result.stdout.strip() - # Unlike filedialog.asksaveasfilename below, zenity has no - # concept of defaultextension -- a name typed with no suffix - # would otherwise save with none at all. - if defaultextension and not Path(path).suffix: - path += defaultextension - return path - if result.returncode == _USER_CANCELLED: - return "" - return filedialog.asksaveasfilename( - defaultextension=defaultextension, filetypes=filetypes - ) diff --git a/src/norefund/gui/parser_view.py b/src/norefund/gui/parser_view.py deleted file mode 100644 index 0c37119..0000000 --- a/src/norefund/gui/parser_view.py +++ /dev/null @@ -1,729 +0,0 @@ -"""File Parser — pick files/folders, analyze against a model, view results/logs.""" - -from __future__ import annotations - -import json -import threading -from collections.abc import Callable, Iterable -from datetime import datetime -from pathlib import Path - -import customtkinter as ctk - -from norefund.core.export import analysis_results_to_csv, analysis_results_to_markdown -from norefund.core.models_registry import ModelInfo -from norefund.core.parsing import SUPPORTED_EXTENSIONS -from norefund.core.report.html import render_html -from norefund.core.report.model import ReportModel -from norefund.core.report.pdf import render_pdf -from norefund.core.service import AnalysisResult, analyze_file, analyze_folder -from norefund.gui import formatting, native_dialog, theme -from norefund.gui.dnd import enable_file_drop -from norefund.gui.theme import COLORS, SUPPORTED_FILETYPES -from norefund.gui.widgets import ( - ContextBar, - EmptyState, - IconButton, - ModelDropdownButton, - StatPill, - TabBar, - ThreadSafeSchedulerMixin, - bind_mousewheel, - export_via_dialog, - export_via_dialog_bytes, - section_label, -) -from norefund.logging_config import latest_log_file - - -def _bind_row_hover(row: ctk.CTkFrame) -> None: - """Highlight `row` on hover, binding every descendant too (not just - `row` itself) -- Tk delivers to a parent the instant the - pointer crosses into a child (NotifyInferior), so a plain frame-only - binding drops the highlight as soon as the pointer reaches any of the - row's cell labels. Mirrors DropdownPopover._build_row's same fix.""" - - def _on_enter(_event=None) -> None: - row.configure(fg_color=COLORS["muted"]) - - def _on_leave(_event=None) -> None: - row.configure(fg_color="transparent") - - def _bind_recursive(widget) -> None: - widget.bind("", _on_enter) - widget.bind("", _on_leave) - for child in widget.winfo_children(): - _bind_recursive(child) - - _bind_recursive(row) - - -class ResultsTable(ctk.CTkScrollableFrame): - # (column label, grid weight) -- one list instead of two parallel ones - # that had to stay index-aligned across every loop below. - _COLUMNS: list[tuple[str, int]] = [ - ("File", 3), - ("Tokens", 1), - ("Context %", 2), - ("Fits?", 1), - ("Chunks", 1), - ("Input Cost", 1), - ("Words", 1), - ("Chars", 1), - ] - - def __init__(self, parent, **kwargs) -> None: - super().__init__(parent, fg_color=COLORS["bg"], **kwargs) - for i, (_label, weight) in enumerate(self._COLUMNS): - self.columnconfigure(i, weight=weight) - self._build_header() - self._row_frames: list[ctk.CTkFrame] = [] - bind_mousewheel(self) - - def _build_header(self) -> None: - header = ctk.CTkFrame(self, fg_color=COLORS["muted"], corner_radius=0) - header.grid( - row=0, column=0, columnspan=len(self._COLUMNS), sticky="ew", pady=(0, 2) - ) - for i, (label, weight) in enumerate(self._COLUMNS): - header.columnconfigure(i, weight=weight) - anchor = "w" if i == 0 else "e" - section_label(header, label, anchor=anchor).grid( - row=0, column=i, sticky="ew", padx=theme.SPACE_2, pady=theme.SPACE_1 - ) - - def clear(self) -> None: - for frame in self._row_frames: - frame.destroy() - self._row_frames.clear() - - def set_results(self, results: list[AnalysisResult]) -> None: - self.clear() - for i, result in enumerate(results, start=1): - self._add_row(i, result) - - def _add_row(self, row_index: int, result: AnalysisResult) -> None: - row = ctk.CTkFrame(self, fg_color="transparent") - row.grid(row=row_index, column=0, columnspan=len(self._COLUMNS), sticky="ew") - for i, (_label, weight) in enumerate(self._COLUMNS): - row.columnconfigure(i, weight=weight) - self._row_frames.append(row) - - name = Path(result.file_path).name - pad = {"padx": theme.SPACE_2, "pady": theme.SPACE_1} - - if result.error is not None: - ctk.CTkLabel( - row, - text=name, - font=theme.mono_font(theme.FONT_BODY), - anchor="w", - text_color=COLORS["fg"], - ).grid(row=0, column=0, sticky="ew", **pad) - ctk.CTkLabel( - row, - text=result.error, - image=theme.icon_image( - "x_circle", size=14, color=COLORS["destructive"] - ), - compound="left", - font=theme.font(theme.FONT_BODY), - anchor="w", - text_color=COLORS["destructive"], - ).grid( - row=0, - column=1, - columnspan=len(self._COLUMNS) - 1, - sticky="ew", - **pad, - ) - _bind_row_hover(row) - return - - ctk.CTkLabel( - row, - text=name, - font=theme.mono_font(theme.FONT_BODY), - anchor="w", - text_color=COLORS["fg"], - ).grid(row=0, column=0, sticky="ew", **pad) - ctk.CTkLabel( - row, - text=formatting.fmt_num(result.token_count), - font=theme.mono_font(theme.FONT_BODY), - anchor="e", - ).grid(row=0, column=1, sticky="ew", **pad) - - ctx_cell = ctk.CTkFrame(row, fg_color="transparent") - ctx_cell.grid(row=0, column=2, sticky="ew", **pad) - bar = ContextBar(ctx_cell, height=theme.SPACE_1 + 2) - bar.pack(side="left", fill="x", expand=True, padx=(0, theme.SPACE_2)) - bar.set_value(result.context_usage_pct) - ctk.CTkLabel( - ctx_cell, - text=formatting.fmt_context_pct(result.context_usage_pct), - font=theme.mono_font(theme.FONT_SMALL), - text_color=formatting.context_color(result.context_usage_pct), - ).pack(side="left") - - fits_icon = "check_circle" if result.fits_in_context else "x_circle" - fits_color = ( - COLORS["primary"] if result.fits_in_context else COLORS["destructive"] - ) - ctk.CTkLabel( - row, - text="", - image=theme.icon_image(fits_icon, size=14, color=fits_color), - anchor="e", - ).grid(row=0, column=3, sticky="ew", **pad) - - ctk.CTkLabel( - row, - text=str(result.min_chunks_needed), - font=theme.mono_font(theme.FONT_BODY), - anchor="e", - ).grid(row=0, column=4, sticky="ew", **pad) - ctk.CTkLabel( - row, - text=formatting.fmt_cost(result.estimated_input_cost), - font=theme.mono_font(theme.FONT_BODY), - anchor="e", - ).grid(row=0, column=5, sticky="ew", **pad) - ctk.CTkLabel( - row, - text=formatting.fmt_num(result.word_count), - font=theme.mono_font(theme.FONT_BODY), - anchor="e", - ).grid(row=0, column=6, sticky="ew", **pad) - ctk.CTkLabel( - row, - text=formatting.fmt_num(result.char_count), - font=theme.mono_font(theme.FONT_BODY), - anchor="e", - ).grid(row=0, column=7, sticky="ew", **pad) - _bind_row_hover(row) - - -class LogsPanel(ctk.CTkFrame): - _TAG_COLORS = { - "INFO": "muted_fg", - "WARNING": "warning", - "ERROR": "destructive", - "DEBUG": "muted_fg", - } - - def __init__(self, parent, **kwargs) -> None: - super().__init__(parent, fg_color=COLORS["bg"], **kwargs) - self._textbox = ctk.CTkTextbox( - self, - font=theme.mono_font(theme.FONT_BODY), - fg_color=COLORS["bg"], - text_color=COLORS["muted_fg"], - wrap="none", - state="disabled", - ) - self._textbox.pack(fill="both", expand=True) - - def refresh(self) -> None: - if not self.winfo_exists(): - return - # Re-applied on every refresh (not just once at construction) so a - # theme toggle after this view was first built doesn't leave log - # colors stuck on the old appearance mode -- views are cached and - # never rebuilt, so __init__ alone would only ever run once. - is_dark = ctk.get_appearance_mode() == "Dark" - for level, token in self._TAG_COLORS.items(): - self._textbox.tag_config(level, foreground=theme.resolve(token, is_dark)) - - self._textbox.configure(state="normal") - self._textbox.delete("1.0", "end") - log_path = latest_log_file() - if log_path is None: - self._textbox.insert( - "end", "No logs yet — run an analysis to see activity here.\n" - ) - else: - try: - lines = log_path.read_text( - encoding="utf-8", errors="ignore" - ).splitlines()[-500:] - except OSError as exc: - lines = [ - f'{{"level": "ERROR", "message": "Failed to read log file: {exc}"}}' - ] - for line in lines: - self._insert_line(line) - self._textbox.configure(state="disabled") - - def _insert_line(self, raw: str) -> None: - try: - data = json.loads(raw) - except json.JSONDecodeError: - self._textbox.insert("end", raw + "\n") - return - level = data.get("level", "INFO") - message = data.get("message", "") - ctx = data.get("ctx") or {} - ctx_str = " " + " ".join(f"{k}={v}" for k, v in ctx.items()) if ctx else "" - self._textbox.insert("end", f"› [{level}] {message}{ctx_str}\n", level) - - -class ParserView(ThreadSafeSchedulerMixin, ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - self._paths: list[Path] = [] - self._results: list[AnalysisResult] = [] - self.analyzing = False - self.cancel_event: threading.Event | None = None - self._active_tab = "results" - - self._build_toolbar() - self._build_file_strip() - self._build_tabs() - self._build_content() - self._build_status_bar() - - self._refresh_file_strip() - self._show_empty_results() - - # ------------------------------------------------------------------ - # Layout - # ------------------------------------------------------------------ - - def _build_toolbar(self) -> None: - toolbar = ctk.CTkFrame(self, fg_color=COLORS["card"], corner_radius=0) - toolbar.pack(side="top", fill="x") - inner = ctk.CTkFrame(toolbar, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_3, pady=theme.SPACE_2) - - IconButton(inner, "Add File", icon="plus", command=self._add_files).pack( - side="left", padx=(0, theme.SPACE_2) - ) - IconButton( - inner, "Add Folder", icon="folder_plus", command=self._add_folder - ).pack(side="left", padx=theme.SPACE_2) - IconButton( - inner, "Clear", icon="x", variant="danger", command=self._clear - ).pack(side="left", padx=theme.SPACE_2) - - ctk.CTkFrame(inner, fg_color=COLORS["border"], width=1).pack( - side="left", fill="y", padx=theme.SPACE_3, pady=theme.SPACE_1 - ) - - self._model_dropdown = ModelDropdownButton( - inner, self.shell.models, self.shell.models[0], on_select=lambda _m: None - ) - self._model_dropdown.pack(side="left", padx=theme.SPACE_2) - - out_frame = ctk.CTkFrame(inner, fg_color="transparent") - out_frame.pack(side="left", padx=theme.SPACE_3) - ctk.CTkLabel( - out_frame, - text="Est. output:", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(0, theme.SPACE_2)) - self._output_var = ctk.StringVar( - value=str(self.shell.settings.default_output_tokens) - ) - ctk.CTkEntry( - out_frame, - textvariable=self._output_var, - width=90, - font=theme.mono_font(theme.FONT_BODY), - fg_color=COLORS["input_bg"], - border_width=0, - ).pack(side="left") - ctk.CTkLabel( - out_frame, - text="tokens", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(side="left", padx=(theme.SPACE_2, 0)) - - self._analyze_btn = IconButton( - inner, "Analyze", icon="zap", variant="primary", command=self._run_analysis - ) - self._analyze_btn.pack(side="right") - self._analyze_btn.configure(state="disabled") - - def _build_file_strip(self) -> None: - wrapper = ctk.CTkFrame(self, fg_color=COLORS["bg"], corner_radius=0, height=132) - wrapper.pack(side="top", fill="x") - wrapper.pack_propagate(False) - self._file_strip = ctk.CTkScrollableFrame(wrapper, fg_color=COLORS["bg"]) - self._file_strip.pack( - fill="both", expand=True, padx=theme.SPACE_3, pady=theme.SPACE_2 - ) - bind_mousewheel(self._file_strip) - enable_file_drop(wrapper, self._on_files_dropped, suffixes=SUPPORTED_EXTENSIONS) - - def _build_tabs(self) -> None: - tab_bar = TabBar( - self, - [("results", "Results"), ("logs", "Logs")], - self._active_tab, - on_change=self._switch_tab, - ) - tab_bar.pack(side="top", fill="x", padx=theme.SPACE_3, pady=(theme.SPACE_1, 0)) - - def _build_content(self) -> None: - self._content = ctk.CTkFrame(self, fg_color=COLORS["bg"], corner_radius=0) - self._content.pack(side="top", fill="both", expand=True) - - self._results_frame = ctk.CTkFrame(self._content, fg_color=COLORS["bg"]) - self._logs_frame = LogsPanel(self._content) - - for frame in (self._results_frame, self._logs_frame): - frame.place(x=0, y=0, relwidth=1, relheight=1) - self._results_frame.tkraise() - - def _build_status_bar(self) -> None: - self._status_bar = ctk.CTkFrame( - self, fg_color=COLORS["card"], corner_radius=0, height=theme.CONTROL_MD - ) - self._status_left = ctk.CTkLabel( - self._status_bar, - text="", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ) - self._status_left.pack(side="left", padx=theme.SPACE_3) - self._status_right = ctk.CTkLabel( - self._status_bar, - text="", - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ) - self._status_right.pack(side="right", padx=theme.SPACE_3) - - # ------------------------------------------------------------------ - # File selection - # ------------------------------------------------------------------ - - def _add_paths(self, new_paths: Iterable[Path]) -> None: - """Append `new_paths`, skipping any already in `self._paths` (by - resolved path) -- re-adding or re-dropping the same file/folder - would otherwise double it up and analyze it twice.""" - existing = {p.resolve() for p in self._paths} - for p in new_paths: - resolved = p.resolve() - if resolved not in existing: - self._paths.append(p) - existing.add(resolved) - - def _add_files(self) -> None: - paths = native_dialog.ask_open_files(SUPPORTED_FILETYPES) - self._add_paths(Path(p) for p in paths) - self._refresh_file_strip() - - def _add_folder(self) -> None: - folder = native_dialog.ask_directory() - if folder: - self._add_paths([Path(folder)]) - self._refresh_file_strip() - - def _on_files_dropped(self, paths: list[Path]) -> None: - self._add_paths(paths) - self._refresh_file_strip() - - def _remove_path(self, path: Path) -> None: - self._paths = [p for p in self._paths if p != path] - self._refresh_file_strip() - - def _clear(self) -> None: - if self.analyzing and self.cancel_event is not None: - self.cancel_event.set() - self._reset_busy_state() - cleared_count = len(self._paths) - self._paths = [] - self._results = [] - self._refresh_file_strip() - self._show_empty_results() - self.shell.update_header_count(0) - - if cleared_count: - self._status_left.configure( - text=f"Cleared — {cleared_count} file(s) removed", - image=theme.blank_icon(size=14), - ) - self._status_right.configure(text="") - self._status_bar.pack(side="bottom", fill="x") - else: - self._status_bar.pack_forget() - - def _refresh_file_strip(self) -> None: - for child in self._file_strip.winfo_children(): - child.destroy() - - if not self._paths: - ctk.CTkLabel( - self._file_strip, - text=( - "No files selected. Click 'Add File' or 'Add Folder' " - "to get started." - ), - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).pack(pady=theme.SPACE_3) - else: - for path in self._paths: - self._build_file_row(path) - - self._analyze_btn.configure( - state="normal" if (self._paths and not self.analyzing) else "disabled" - ) - self.shell.update_header_count(len(self._paths)) - - def _build_file_row(self, path: Path) -> None: - row = ctk.CTkFrame(self._file_strip, fg_color="transparent") - row.pack(fill="x", pady=1) - icon = "folder_open" if path.is_dir() else "file_text" - ctk.CTkLabel( - row, - text=str(path), - image=theme.icon_image(icon, size=14, color=COLORS["fg"]), - compound="left", - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left", fill="x", expand=True) - remove_btn = ctk.CTkLabel( - row, - text="", - image=theme.icon_image("x", size=12, color=COLORS["muted_fg"]), - cursor="hand2", - ) - remove_btn.pack(side="right", padx=theme.SPACE_2) - remove_btn.bind("", lambda _e, p=path: self._remove_path(p)) - - # ------------------------------------------------------------------ - # Results tab state - # ------------------------------------------------------------------ - - def _show_empty_results(self) -> None: - for child in self._results_frame.winfo_children(): - child.destroy() - EmptyState( - self._results_frame, - "bar_chart", - "Add files and click Analyze to see results", - ).pack(expand=True) - - def _render_results(self, results: list[AnalysisResult]) -> None: - for child in self._results_frame.winfo_children(): - child.destroy() - - stats_row = ctk.CTkFrame(self._results_frame, fg_color="transparent") - stats_row.pack( - fill="x", padx=theme.SPACE_3, pady=(theme.SPACE_3, theme.SPACE_2) - ) - - successful = [r for r in results if r.error is None] - total_tokens = sum(r.token_count for r in successful) - total_input_cost = sum(r.estimated_input_cost for r in successful) - pct_values = [ - r.context_usage_pct for r in successful if r.context_usage_pct is not None - ] - avg_pct = sum(pct_values) / len(pct_values) if pct_values else None - - StatPill(stats_row, "Files", formatting.fmt_num(len(results))).pack( - side="left", padx=(0, theme.SPACE_6) - ) - StatPill(stats_row, "Total tokens", formatting.fmt_num(total_tokens)).pack( - side="left", padx=theme.SPACE_6 - ) - StatPill(stats_row, "Input cost", formatting.fmt_cost(total_input_cost)).pack( - side="left", padx=theme.SPACE_6 - ) - StatPill(stats_row, "Avg context", formatting.fmt_context_pct(avg_pct)).pack( - side="left", padx=theme.SPACE_6 - ) - for label, command in ( - ("Export CSV", self._export_csv), - ("Export MD", self._export_md), - ("Export PDF", self._export_pdf), - ("Export HTML", self._export_html), - ): - IconButton(stats_row, label, icon="file_text", command=command).pack( - side="right", padx=(theme.SPACE_2, 0) - ) - - table = ResultsTable(self._results_frame) - table.pack( - fill="both", expand=True, padx=theme.SPACE_3, pady=(0, theme.SPACE_3) - ) - table.set_results(results) - - # ------------------------------------------------------------------ - # Export - # ------------------------------------------------------------------ - - def _do_export( - self, - *, - extension: str, - filetype_label: str, - content_fn: Callable[[], str | bytes], - as_bytes: bool = False, - ) -> None: - exporter = export_via_dialog_bytes if as_bytes else export_via_dialog - exporter( - has_data=bool(self._results), - extension=extension, - filetype_label=filetype_label, - content_fn=content_fn, - ) - - def _export_csv(self) -> None: - self._do_export( - extension="csv", - filetype_label="CSV", - content_fn=lambda: analysis_results_to_csv(self._results), - ) - - def _export_md(self) -> None: - self._do_export( - extension="md", - filetype_label="Markdown", - content_fn=lambda: analysis_results_to_markdown(self._results), - ) - - def _build_report(self) -> ReportModel: - return ReportModel( - title="NoRefund Analysis Report", - generated_at=datetime.now(), - analysis=self._results, - ) - - def _export_pdf(self) -> None: - self._do_export( - extension="pdf", - filetype_label="PDF", - content_fn=lambda: render_pdf(self._build_report()), - as_bytes=True, - ) - - def _export_html(self) -> None: - self._do_export( - extension="html", - filetype_label="HTML", - content_fn=lambda: render_html(self._build_report()), - ) - - # ------------------------------------------------------------------ - # Tabs - # ------------------------------------------------------------------ - - def _switch_tab(self, tab_id: str) -> None: - self._active_tab = tab_id - if tab_id == "logs": - self._logs_frame.tkraise() - self._logs_frame.refresh() - else: - self._results_frame.tkraise() - - # ------------------------------------------------------------------ - # Analysis / threading - # ------------------------------------------------------------------ - - def _run_analysis(self) -> None: - if self.analyzing or not self._paths: - return - self.analyzing = True - self.cancel_event = threading.Event() - self._analyze_btn.configure( - text="Cancel", - image=theme.icon_image("x", size=16, color=COLORS["primary_fg"]), - command=self._cancel_analysis, - state="normal", - ) - - model = self._model_dropdown.selected_model() - paths = list(self._paths) - cancel_event = self.cancel_event - threading.Thread( - target=self._analysis_worker, - args=(paths, model, cancel_event), - daemon=True, - ).start() - - def _cancel_analysis(self) -> None: - if self.cancel_event is not None: - self.cancel_event.set() - self._analyze_btn.configure( - text="Cancelling…", image=theme.blank_icon(size=16), state="disabled" - ) - - def _analysis_worker( - self, paths: list[Path], model: ModelInfo, cancel_event: threading.Event - ) -> None: - results: list[AnalysisResult] = [] - try: - for path in paths: - if cancel_event.is_set(): - break - if path.is_dir(): - results.extend( - analyze_folder(path, model.id, cancel_event=cancel_event) - ) - else: - results.append(analyze_file(path, model.id)) - except Exception as exc: # noqa: BLE001 - self._schedule(self._analysis_error, str(exc)) - return - self._schedule(self._analysis_complete, results, model, cancel_event.is_set()) - - def _reset_busy_state(self) -> None: - self.analyzing = False - self.cancel_event = None - if not self.winfo_exists(): - return - self._analyze_btn.configure( - text="Analyze", - image=theme.icon_image("zap", size=16, color=COLORS["primary_fg"]), - command=self._run_analysis, - state="normal" if self._paths else "disabled", - ) - - def _analysis_complete( - self, results: list[AnalysisResult], model: ModelInfo, cancelled: bool - ) -> None: - if not self.winfo_exists(): - return - self._reset_busy_state() - self._results = results - self._render_results(results) - self.shell.update_header_count(len(results)) - - successful = [r for r in results if r.error is None] - total_tokens = sum(r.token_count for r in successful) - total_input_cost = sum(r.estimated_input_cost for r in successful) - if total_tokens > 0: - self.shell.update_last_analysis_tokens(total_tokens) - prefix = "Cancelled — " if cancelled else "Done — " - self._status_left.configure( - text=f"{prefix}{len(results)} file(s) analysed with {model.display_name}", - image=theme.blank_icon(size=14), - ) - tokens_str = formatting.fmt_num(total_tokens) - cost_str = formatting.fmt_cost(total_input_cost) - self._status_right.configure( - text=f"Total tokens: {tokens_str} Est. input cost: {cost_str}" - ) - self._status_bar.pack(side="bottom", fill="x") - if self._active_tab == "logs": - self._logs_frame.refresh() - - def _analysis_error(self, message: str) -> None: - if not self.winfo_exists(): - return - self._reset_busy_state() - self._status_left.configure( - text=f"Analysis failed: {message}", - image=theme.icon_image("x_circle", size=14, color=COLORS["muted_fg"]), - compound="left", - ) - self._status_right.configure(text="") - self._status_bar.pack(side="bottom", fill="x") diff --git a/src/norefund/gui/registry_view.py b/src/norefund/gui/registry_view.py deleted file mode 100644 index 609c6b2..0000000 --- a/src/norefund/gui/registry_view.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Model Registry — read-only browsable grid of every configured model.""" - -from __future__ import annotations - -import webbrowser - -import customtkinter as ctk - -from norefund.core.models_registry import ModelInfo -from norefund.gui import formatting, motion, theme -from norefund.gui.theme import COLORS -from norefund.gui.widgets import LoadingOverlay, ProviderBadge, bind_mousewheel, card - -_MIN_CARD_WIDTH = 340 -_PILL_HEIGHT = theme.CONTROL_SM - - -class RegistryView(ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - self._active_provider = "All" - self._pills: dict[str, ctk.CTkButton] = {} - self._cards: list[tuple[ModelInfo, ctk.CTkFrame]] = [] - self._loading = False - self._last_col_count = -1 - - self._build_header() - self._build_grid() - bind_mousewheel(self._scroll) - self._start_loading() - - # ------------------------------------------------------------------ - - def _build_header(self) -> None: - header = ctk.CTkFrame(self, fg_color="transparent") - header.pack( - fill="x", padx=theme.PAGE_GUTTER, pady=(theme.SPACE_5, theme.SPACE_3) - ) - - left = ctk.CTkFrame(header, fg_color="transparent") - left.pack(side="left", fill="x", expand=True) - ctk.CTkLabel( - left, - text="Model Registry", - font=theme.font(theme.FONT_HEADING, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x") - providers = sorted({m.provider for m in self.shell.models}) - subtitle = ( - f"{len(self.shell.models)} models across {len(providers)} providers" - " — locally stored pricing data." - ) - ctk.CTkLabel( - left, - text=subtitle, - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_1, 0)) - - pills_row = ctk.CTkFrame(header, fg_color="transparent") - pills_row.pack(side="right") - for label in ["All", *providers]: - pill = ctk.CTkButton( - pills_row, - text=label, - font=theme.font(theme.FONT_BODY), - corner_radius=_PILL_HEIGHT // 2, - height=_PILL_HEIGHT, - width=1, - fg_color=COLORS["muted"], - text_color=COLORS["muted_fg"], - hover_color=COLORS["border"], - command=lambda p=label: self._apply_filter(p), - ) - pill.pack(side="left", padx=theme.SPACE_1) - motion.press_feedback(pill) - self._pills[label] = pill - self._sync_pill_styles() - - def _sync_pill_styles(self) -> None: - for label, pill in self._pills.items(): - if label == self._active_provider: - pill.configure( - fg_color=COLORS["primary"], text_color=COLORS["primary_fg"] - ) - else: - pill.configure(fg_color=COLORS["muted"], text_color=COLORS["muted_fg"]) - - def _sync_pill_enabled(self) -> None: - state = "disabled" if self._loading else "normal" - for pill in self._pills.values(): - pill.configure(state=state) - - def _build_grid(self) -> None: - self._scroll = ctk.CTkScrollableFrame(self, fg_color=COLORS["bg"]) - self._scroll.pack( - fill="both", expand=True, padx=theme.PAGE_GUTTER, pady=(0, theme.SPACE_5) - ) - # Tk's bindtags put the scroll frame's pathname in every descendant - # card's bindtags too, so a plain bind() here fires on each of the - # ~40 model cards' own Configure events, not just the scroll - # frame's own resize -- filter to the real thing (same pattern as - # widgets.py's root handler). - self._scroll.bind( - "", - lambda e: self._relayout() if e.widget is self._scroll else None, - ) - self._loading_overlay = LoadingOverlay(self._scroll, "Loading models…") - - # ------------------------------------------------------------------ - # Loading: build every real card off-screen first, then reveal them - # all together in one grid pass. A plain text label covers the wait - # instead of a skeleton grid. - # ------------------------------------------------------------------ - - def _start_loading(self) -> None: - self._loading = True - self._cards = [] - self._sync_pill_enabled() - self._loading_overlay.show() - self.after(1, self._build_next_card, 0, []) - - def _build_next_card( - self, index: int, built: list[tuple[ModelInfo, ctk.CTkFrame]] - ) -> None: - if not self.winfo_exists(): - return - if index >= len(self.shell.models): - self._finish_loading(built) - return - # Built off-screen (never gridded here) so cards only become visible - # once all of them are ready, in a single _relayout call. - model = self.shell.models[index] - built.append((model, self._build_card(model))) - self.after(1, self._build_next_card, index + 1, built) - - def _finish_loading(self, built: list[tuple[ModelInfo, ctk.CTkFrame]]) -> None: - if not self.winfo_exists(): - return - self._loading = False - self._loading_overlay.hide() - self._cards = built - self._sync_pill_enabled() - self._relayout(force=True) - - def _refresh_scrollregion(self) -> None: - """Force the canvas scrollregion to match current content. - - CTkScrollableFrame is supposed to keep this in sync via an internal - binding, but that doesn't reliably fire on every grid - change here - a stale/empty scrollregion lets the canvas scroll past - real content into empty space. Recomputing it explicitly after every - grid change keeps scrolling bounded to what's actually on screen. - """ - canvas = self._scroll._parent_canvas - self._scroll.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - - def _build_card(self, model: ModelInfo) -> ctk.CTkFrame: - accent = theme.provider_color(model.provider) - model_card = card(self._scroll) - - header_tint = ( - formatting.blend(accent, COLORS["card"][0], 0.09), - formatting.blend(accent, COLORS["card"][1], 0.09), - ) - header = ctk.CTkFrame(model_card, fg_color=header_tint, corner_radius=0) - header.pack(fill="x") - header_inner = ctk.CTkFrame(header, fg_color="transparent") - header_inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - top_row = ctk.CTkFrame(header_inner, fg_color="transparent") - top_row.pack(fill="x") - ctk.CTkLabel( - top_row, - text=model.display_name, - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - ProviderBadge(top_row, model.provider).pack(side="right") - ctk.CTkLabel( - header_inner, - text=model.id, - font=theme.mono_font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_1, 0)) - - body = ctk.CTkFrame(model_card, fg_color="transparent") - body.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - ctk.CTkLabel( - body, - text="Context window", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - body, - text=formatting.fmt_context_window(model.context_window), - font=theme.mono_font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x", pady=(0, theme.SPACE_3)) - - pricing = ctk.CTkFrame(body, fg_color="transparent") - pricing.pack(fill="x") - pricing.columnconfigure((0, 1), weight=1) - self._price_cell(pricing, 0, "Input / 1M", model.input_price_per_million) - self._price_cell(pricing, 1, "Output / 1M", model.output_price_per_million) - - footer = ctk.CTkFrame(model_card, fg_color="transparent", border_width=0) - ctk.CTkFrame(footer, fg_color=COLORS["border"], height=1).pack(fill="x") - footer_inner = ctk.CTkFrame(footer, fg_color="transparent") - footer_inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_2) - ctk.CTkLabel( - footer_inner, - text=model.tokenizer_name, - image=theme.icon_image("hash", size=12, color=COLORS["muted_fg"]), - compound="left", - font=theme.mono_font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(side="left") - if model.docs_url: - docs_btn = ctk.CTkLabel( - footer_inner, - text="Docs", - image=theme.icon_image( - "external_link", size=12, color=COLORS["primary"] - ), - compound="right", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["primary"], - cursor="hand2", - ) - docs_btn.pack(side="right") - docs_btn.bind( - "", lambda _e, url=model.docs_url: webbrowser.open(url) - ) - footer.pack(fill="x") - - return model_card - - def _price_cell(self, parent, col: int, label: str, price: float) -> None: - cell = ctk.CTkFrame( - parent, fg_color=COLORS["muted"], corner_radius=theme.RADIUS_CARD - ) - # Only ever called with col=0 (left cell) or col=1 (right cell): a - # gap between them, no outer padding. - padx = (0, theme.SPACE_2) if col == 0 else (theme.SPACE_2, 0) - cell.grid(row=0, column=col, sticky="ew", padx=padx) - inner = ctk.CTkFrame(cell, fg_color="transparent") - inner.pack(padx=theme.SPACE_2, pady=theme.SPACE_2, fill="x") - ctk.CTkLabel( - inner, - text=label, - font=theme.font(theme.FONT_MICRO), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - inner, - text=f"${price:,.2f}", - font=theme.mono_font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x") - - # ------------------------------------------------------------------ - - def _apply_filter(self, provider: str) -> None: - self._active_provider = provider - self._sync_pill_styles() - self._relayout(force=True) - - def _visible_cards(self) -> list[ctk.CTkFrame]: - return [ - card_frame - for model, card_frame in self._cards - if self._active_provider == "All" or model.provider == self._active_provider - ] - - def _relayout(self, force: bool = False) -> None: - if not self.winfo_exists(): - return - width = self._scroll.winfo_width() - col_count = max(1, width // _MIN_CARD_WIDTH) - if col_count == self._last_col_count and not force: - return - self._last_col_count = col_count - - for i in range(col_count): - self._scroll.columnconfigure(i, weight=1) - - for model, card_frame in self._cards: - card_frame.grid_forget() - - for index, card_frame in enumerate(self._visible_cards()): - row, col = divmod(index, col_count) - card_frame.grid( - row=row, - column=col, - sticky="nsew", - padx=theme.SPACE_2, - pady=theme.SPACE_2, - ) - - self._refresh_scrollregion() diff --git a/src/norefund/gui/resources_view.py b/src/norefund/gui/resources_view.py deleted file mode 100644 index 4445de0..0000000 --- a/src/norefund/gui/resources_view.py +++ /dev/null @@ -1,543 +0,0 @@ -"""Resources — what tokenizers are downloaded, where they live, how big they are.""" - -from __future__ import annotations - -import os -import subprocess -import sys -import threading -import webbrowser -from collections.abc import Callable -from pathlib import Path - -import customtkinter as ctk - -from norefund.core.resources import ( - ManagedDir, - ResourceDownloadError, - ResourceReport, - TokenizerResource, - build_resource_report, - download_tokenizer, -) -from norefund.gui import formatting, theme -from norefund.gui.theme import COLORS -from norefund.gui.widgets import ( - IconButton, - LoadingOverlay, - StatPill, - ThreadSafeSchedulerMixin, - bind_mousewheel, - section_label, - status_dot, -) - -_PATH_MAX_CHARS = 64 - - -def _open_folder(path: Path) -> None: - try: - if sys.platform == "win32": - os.startfile(path) # noqa: S606 - elif sys.platform == "darwin": - subprocess.Popen(["open", str(path)]) - else: - subprocess.Popen(["xdg-open", str(path)]) - except OSError: - pass - - -class _TokenizerRow(ctk.CTkFrame): - def __init__( - self, - parent, - resource: TokenizerResource, - *, - is_downloading: Callable[[str], bool], - is_busy: Callable[[], bool], - start_download: Callable[[TokenizerResource], None], - cancel_download: Callable[[], None], - ) -> None: - super().__init__( - parent, fg_color=COLORS["card"], corner_radius=theme.RADIUS_CARD - ) - self._resource = resource - self._is_downloading = is_downloading - self._is_busy = is_busy - self._start_download = start_download - self._cancel_download = cancel_download - - inner = ctk.CTkFrame(self, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - inner.columnconfigure(1, weight=1) - - self._status_dot = status_dot(inner) - self._status_dot.grid( - row=0, column=0, rowspan=2, padx=(0, theme.SPACE_3), sticky="n" - ) - - title_row = ctk.CTkFrame(inner, fg_color="transparent") - title_row.grid(row=0, column=1, sticky="ew") - ctk.CTkLabel( - title_row, - text=resource.name, - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(side="left") - ctk.CTkLabel( - title_row, - text=resource.backend, - font=theme.font(theme.FONT_MICRO, "bold"), - fg_color=COLORS["muted"], - text_color=COLORS["muted_fg"], - corner_radius=8, - padx=theme.SPACE_2, - ).pack(side="left", padx=(theme.SPACE_2, 0)) - - n_models = len(resource.model_ids) - sub_text = f"used by {n_models} model{'s' if n_models != 1 else ''}" - self._sub_label = ctk.CTkLabel( - inner, - text=sub_text, - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ) - self._sub_label.grid(row=1, column=1, sticky="ew", pady=(theme.SPACE_1, 0)) - - self._path_label = ctk.CTkLabel( - inner, - text="", - font=theme.mono_font(theme.FONT_MICRO), - text_color=COLORS["muted_fg"], - anchor="w", - ) - self._path_label.grid(row=2, column=1, sticky="ew", pady=(theme.SPACE_1, 0)) - - self._size_label = ctk.CTkLabel( - inner, - text="", - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ) - self._size_label.grid(row=0, column=2, padx=(theme.SPACE_3, theme.SPACE_3)) - - self._action_area = ctk.CTkFrame(inner, fg_color="transparent") - self._action_area.grid(row=0, column=3, rowspan=2) - - # Renders resource.notes, which is often not an error at all (e.g. - # an auth hint with an "open page" link) -- named for its content, - # not assumed failure state. - self._notes_label = ctk.CTkLabel( - inner, - text="", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["destructive"], - anchor="w", - wraplength=480, - justify="left", - ) - - self.set_resource(resource) - - # ------------------------------------------------------------------ - - def set_resource(self, resource: TokenizerResource) -> None: - self._resource = resource - self._status_dot.configure( - fg_color=COLORS["primary"] if resource.is_cached else COLORS["muted_fg"] - ) - self._size_label.configure(text=formatting.fmt_bytes(resource.size_bytes)) - if resource.cache_path is not None: - self._path_label.configure( - text=formatting.elide_middle(str(resource.cache_path), _PATH_MAX_CHARS) - ) - else: - self._path_label.configure(text="not downloaded") - - link_url = ( - resource.source_url - if resource.notes and resource.backend == "hf" and resource.source_url - else None - ) - if resource.notes: - text = resource.notes - if link_url: - text += " open page" - self._notes_label.configure( - text=text, - image=( - theme.icon_image( - "external_link", size=12, color=COLORS["destructive"] - ) - if link_url - else theme.blank_icon(size=12) - ), - compound="right", - ) - self._notes_label.grid( - row=3, column=1, columnspan=3, sticky="ew", pady=(theme.SPACE_1, 0) - ) - if link_url: - self._notes_label.configure(cursor="hand2") - self._notes_label.bind( - "", lambda _e, url=link_url: webbrowser.open(url) - ) - else: - self._notes_label.configure(cursor="") - self._notes_label.unbind("") - else: - self._notes_label.grid_forget() - - self.refresh_action() - - def refresh_action(self) -> None: - for child in self._action_area.winfo_children(): - child.destroy() - - if self._resource.is_cached: - IconButton( - self._action_area, - "Open folder", - icon="folder_open", - command=self._open_folder, - ).pack(side="left") - return - - if self._is_downloading(self._resource.key): - self._progress = ctk.CTkProgressBar( - self._action_area, - width=120, - progress_color=COLORS["primary"], - fg_color=COLORS["muted"], - ) - self._progress.pack(side="left", padx=(0, theme.SPACE_2)) - self._progress.set(0) - IconButton( - self._action_area, - "Cancel", - icon="x", - variant="danger", - command=self._cancel_download, - ).pack(side="left") - return - - can_download = self._resource.source_url is not None - IconButton( - self._action_area, - "Download", - icon="download", - variant="primary", - command=lambda: self._start_download(self._resource), - state="normal" if can_download and not self._is_busy() else "disabled", - ).pack(side="left") - - def set_progress(self, downloaded: int, total: int | None) -> None: - if not hasattr(self, "_progress") or not self._progress.winfo_exists(): - return - if total: - self._progress.configure(mode="determinate") - self._progress.set(min(1.0, downloaded / total)) - else: - self._progress.configure(mode="indeterminate") - self._progress.start() - - def _open_folder(self) -> None: - if self._resource.cache_path is not None: - _open_folder(self._resource.cache_path.parent) - - -class _DirRow(ctk.CTkFrame): - def __init__(self, parent, managed_dir: ManagedDir) -> None: - super().__init__( - parent, fg_color=COLORS["card"], corner_radius=theme.RADIUS_CARD - ) - inner = ctk.CTkFrame(self, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_3) - inner.columnconfigure(0, weight=1) - - left = ctk.CTkFrame(inner, fg_color="transparent") - left.grid(row=0, column=0, sticky="ew") - ctk.CTkLabel( - left, - text=managed_dir.label, - font=theme.font(theme.FONT_LABEL, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - left, - text=formatting.elide_middle(str(managed_dir.path), _PATH_MAX_CHARS), - font=theme.mono_font(theme.FONT_MICRO), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_1, 0)) - - ctk.CTkLabel( - inner, - text=( - f"{formatting.fmt_bytes(managed_dir.size_bytes)}" - f" · {managed_dir.file_count} file" - f"{'s' if managed_dir.file_count != 1 else ''}" - ), - font=theme.mono_font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - ).grid(row=0, column=1, padx=theme.SPACE_3) - - IconButton( - inner, - "Open folder", - icon="folder_open", - command=lambda: _open_folder(managed_dir.path), - state="normal" if managed_dir.exists else "disabled", - ).grid(row=0, column=2) - - -class ResourcesView(ThreadSafeSchedulerMixin, ctk.CTkFrame): - def __init__(self, parent, shell) -> None: - super().__init__(parent, fg_color=COLORS["bg"]) - self.shell = shell - self._report: ResourceReport | None = None - self._rows: dict[str, _TokenizerRow] = {} - self._loading = False - self._downloading_key: str | None = None - self._cancel_event: threading.Event | None = None - self._download_queue: list[str] = [] - - self._build_header() - self._build_scroll() - self._refresh() - - # ------------------------------------------------------------------ - # Layout - # ------------------------------------------------------------------ - - def _build_header(self) -> None: - header = ctk.CTkFrame(self, fg_color="transparent") - header.pack( - fill="x", padx=theme.PAGE_GUTTER, pady=(theme.SPACE_5, theme.SPACE_3) - ) - - left = ctk.CTkFrame(header, fg_color="transparent") - left.pack(side="left", fill="x", expand=True) - ctk.CTkLabel( - left, - text="Resources", - font=theme.font(theme.FONT_HEADING, "bold"), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - left, - text="Tokenizers downloaded to this machine, and where they live on disk.", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_1, 0)) - - stats_row = ctk.CTkFrame(header, fg_color="transparent") - stats_row.pack(side="left", padx=theme.SPACE_7) - self._downloaded_pill = StatPill(stats_row, "Downloaded", "—") - self._downloaded_pill.pack(side="left", padx=(0, theme.SPACE_6)) - self._size_pill = StatPill(stats_row, "Disk used", "—") - self._size_pill.pack(side="left") - - self._refresh_btn = IconButton( - header, "Refresh", icon="refresh", command=self._refresh - ) - self._refresh_btn.pack(side="right") - self._download_all_btn = IconButton( - header, - "Download all", - icon="download", - variant="primary", - command=self._download_all, - ) - self._download_all_btn.pack(side="right", padx=(0, theme.SPACE_2)) - - def _build_scroll(self) -> None: - self._scroll = ctk.CTkScrollableFrame(self, fg_color=COLORS["bg"]) - self._scroll.pack( - fill="both", expand=True, padx=theme.PAGE_GUTTER, pady=(0, theme.SPACE_5) - ) - bind_mousewheel(self._scroll) - self._loading_overlay = LoadingOverlay(self._scroll, "Scanning…") - - # ------------------------------------------------------------------ - # Scan - # ------------------------------------------------------------------ - - def _refresh(self) -> None: - if self._loading: - return - self._loading = True - self._refresh_btn.configure(state="disabled") - for child in self._scroll.winfo_children(): - child.destroy() - self._rows = {} - self._loading_overlay.show() - threading.Thread(target=self._scan_worker, daemon=True).start() - - def _scan_worker(self) -> None: - report = build_resource_report(self.shell.models) - self._schedule(self._on_scan_complete, report) - - def _on_scan_complete(self, report: ResourceReport) -> None: - if not self.winfo_exists(): - return - self._loading = False - self._loading_overlay.hide() - self._refresh_btn.configure(state="normal") - self._report = report - self._render(report) - - def _render(self, report: ResourceReport) -> None: - cached = sum(1 for t in report.tokenizers if t.is_cached) - self._downloaded_pill.set_text(f"{cached} of {len(report.tokenizers)}") - self._size_pill.set_text(formatting.fmt_bytes(report.total_tokenizer_bytes)) - - section_label(self._scroll, "Tokenizers").pack( - fill="x", pady=(0, theme.SPACE_2) - ) - for resource in report.tokenizers: - row = _TokenizerRow( - self._scroll, - resource, - is_downloading=self.is_downloading, - is_busy=self.is_busy, - start_download=self.start_download, - cancel_download=self.cancel_download, - ) - row.pack(fill="x", pady=theme.SPACE_1) - self._rows[resource.key] = row - - section_label(self._scroll, "Storage").pack( - fill="x", pady=(theme.SPACE_4, theme.SPACE_2) - ) - for managed_dir in report.dirs: - _DirRow(self._scroll, managed_dir).pack(fill="x", pady=theme.SPACE_1) - - # ------------------------------------------------------------------ - # Downloads - # ------------------------------------------------------------------ - - def is_busy(self) -> bool: - return self._downloading_key is not None - - def is_downloading(self, key: str) -> bool: - return self._downloading_key == key - - def start_download(self, resource: TokenizerResource) -> None: - if self.is_busy(): - return - self._downloading_key = resource.key - self._cancel_event = threading.Event() - for row in self._rows.values(): - row.refresh_action() - threading.Thread( - target=self._download_worker, - args=(resource, self._cancel_event), - daemon=True, - ).start() - - def cancel_download(self) -> None: - self._download_queue = [] - if self._cancel_event is not None: - self._cancel_event.set() - - def _download_all(self) -> None: - if self.is_busy() or self._report is None: - return - missing = [t for t in self._report.tokenizers if not t.is_cached] - if not missing: - return - self._download_queue = [t.key for t in missing[1:]] - self.start_download(missing[0]) - - def _advance_queue(self) -> None: - if not self._download_queue or self._report is None: - return - next_key = self._download_queue.pop(0) - resource = next( - (t for t in self._report.tokenizers if t.key == next_key), None - ) - if resource is not None and not resource.is_cached: - self.start_download(resource) - - def _download_worker( - self, resource: TokenizerResource, cancel_event: threading.Event - ) -> None: - def on_progress(downloaded: int, total: int | None) -> None: - self._schedule(self._on_download_progress, resource.key, downloaded, total) - - try: - updated = download_tokenizer( - resource, on_progress=on_progress, cancel_event=cancel_event - ) - self._schedule(self._on_download_complete, updated) - except ResourceDownloadError as exc: - self._schedule(self._on_download_failed, resource, str(exc)) - except Exception: - self._schedule(self._on_download_cancelled, resource) - - def _on_download_progress( - self, key: str, downloaded: int, total: int | None - ) -> None: - row = self._rows.get(key) - if row is not None: - row.set_progress(downloaded, total) - - def _on_download_complete(self, resource: TokenizerResource) -> None: - self._downloading_key = None - self._cancel_event = None - row = self._rows.get(resource.key) - if row is not None: - row.set_resource(resource) - if self._report is not None: - self._report = ResourceReport( - tokenizers=[ - resource if t.key == resource.key else t - for t in self._report.tokenizers - ], - dirs=self._report.dirs, - total_tokenizer_bytes=self._report.total_tokenizer_bytes - + (resource.size_bytes or 0), - ) - cached = sum(1 for t in self._report.tokenizers if t.is_cached) - total = len(self._report.tokenizers) - self._downloaded_pill.set_text(f"{cached} of {total}") - self._size_pill.set_text( - formatting.fmt_bytes(self._report.total_tokenizer_bytes) - ) - for r in self._rows.values(): - r.refresh_action() - self._advance_queue() - - def _on_download_failed(self, resource: TokenizerResource, message: str) -> None: - self._downloading_key = None - self._cancel_event = None - failed = TokenizerResource( - key=resource.key, - backend=resource.backend, - name=resource.name, - model_ids=resource.model_ids, - is_cached=False, - cache_path=None, - size_bytes=None, - source_url=resource.source_url, - notes=message, - ) - row = self._rows.get(resource.key) - if row is not None: - row.set_resource(failed) - for r in self._rows.values(): - r.refresh_action() - self._advance_queue() - - def _on_download_cancelled(self, resource: TokenizerResource) -> None: - self._downloading_key = None - self._cancel_event = None - for r in self._rows.values(): - r.refresh_action() - self._advance_queue() diff --git a/src/norefund/gui/settings_modal.py b/src/norefund/gui/settings_modal.py deleted file mode 100644 index a04885d..0000000 --- a/src/norefund/gui/settings_modal.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Settings modal — the one true modal dialog in the app (needs grab_set).""" - -from __future__ import annotations - -from collections.abc import Callable - -import customtkinter as ctk - -from norefund.core import secrets -from norefund.core.settings import Settings -from norefund.gui import formatting, motion, theme -from norefund.gui.theme import COLORS - -_CURRENCIES = ["USD", "EUR", "GBP", "INR"] -_GRAB_RETRY_MS = 30 -_MAX_GRAB_ATTEMPTS = 10 - - -class SettingsModal(ctk.CTkToplevel): - def __init__( - self, parent, settings: Settings, on_save: Callable[[Settings], None] - ) -> None: - super().__init__(parent) - self._parent_shell = parent - self._settings = settings - self._on_save = on_save - - self.title("Settings") - # Clamp against the actual screen so the footer (Save/Cancel) can't - # be pushed off-screen -- the fixed 480x620 overflowed at 150% HiDPI - # scaling and on small/laptop screens. Height stays resizable as a - # safety net for anything smaller than this clamp still allows. - width = min(480, self.winfo_screenwidth() - 80) - height = min(620, self.winfo_screenheight() - 120) - self.geometry(f"{width}x{height}") - self.resizable(False, True) - self.configure(fg_color=COLORS["card"]) - self.transient(parent.winfo_toplevel()) - - self._build_ui() - self._grab_attempts = 0 - self.after(_GRAB_RETRY_MS, self._try_grab) - - def _try_grab(self) -> None: - if not self.winfo_exists(): - return - try: - self.grab_set() - except Exception: # noqa: BLE001 — TclError if window not yet viewable - self._grab_attempts += 1 - if self._grab_attempts < _MAX_GRAB_ATTEMPTS: - self.after(_GRAB_RETRY_MS, self._try_grab) - - # ------------------------------------------------------------------ - - def _build_ui(self) -> None: - header = ctk.CTkFrame(self, fg_color="transparent") - header.pack(fill="x", padx=theme.SPACE_5, pady=(theme.SPACE_4, theme.SPACE_1)) - ctk.CTkLabel( - header, - text="Settings", - font=theme.font(theme.FONT_TITLE, "bold"), - text_color=COLORS["fg"], - ).pack(side="left") - close_btn = ctk.CTkLabel( - header, - text="", - image=theme.icon_image("x", size=14, color=COLORS["muted_fg"]), - cursor="hand2", - ) - close_btn.pack(side="right") - close_btn.bind("", lambda _e: self._cancel()) - - body = ctk.CTkFrame(self, fg_color="transparent") - body.pack(fill="both", expand=True, padx=theme.SPACE_5, pady=theme.SPACE_2) - - ctk.CTkLabel( - body, - text="Default currency", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_2, theme.SPACE_2)) - self._currency_var = ctk.StringVar(value=self._settings.currency) - ctk.CTkOptionMenu( - body, - values=_CURRENCIES, - variable=self._currency_var, - height=theme.CONTROL_MD, - font=theme.font(theme.FONT_LABEL), - fg_color=COLORS["input_bg"], - button_color=COLORS["muted"], - ).pack(fill="x") - - ctk.CTkLabel( - body, - text="Default output tokens estimate", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_4, theme.SPACE_2)) - self._output_tokens_var = ctk.StringVar( - value=str(self._settings.default_output_tokens) - ) - self._output_tokens_entry = ctk.CTkEntry( - body, - textvariable=self._output_tokens_var, - height=theme.CONTROL_MD, - font=theme.mono_font(theme.FONT_LABEL), - fg_color=COLORS["input_bg"], - border_width=1, - border_color=COLORS["input_bg"], - ) - self._output_tokens_entry.pack(fill="x") - self._output_tokens_entry.bind( - "", lambda _e: self._on_output_tokens_edited() - ) - - ctk.CTkLabel( - body, - text="API tokens & secrets", - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x", pady=(theme.SPACE_5, theme.SPACE_1)) - - self._keyring_ok = secrets.keyring_available() - has_token = self._keyring_ok and secrets.get_hf_token() is not None - - if self._keyring_ok: - note_text = ( - "Stored in your OS keychain, never written to disk in plaintext. " - "Used only for HuggingFace tokenizer downloads." - ) - else: - note_text = ( - "No system keychain found — secure token storage is unavailable " - "here." - ) - ctk.CTkLabel( - body, - text=note_text, - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - justify="left", - wraplength=420, - ).pack(fill="x", pady=(0, theme.SPACE_2)) - - token_row = ctk.CTkFrame(body, fg_color="transparent") - token_row.pack(fill="x") - self._hf_token_var = ctk.StringVar(value="") - self._hf_token_entry = ctk.CTkEntry( - token_row, - textvariable=self._hf_token_var, - show="•", - placeholder_text=( - "Token saved — leave blank to keep" - if has_token - else "hf_xxx (optional)" - ), - height=theme.CONTROL_MD, - font=theme.mono_font(theme.FONT_LABEL), - fg_color=COLORS["input_bg"], - border_width=0, - state="normal" if self._keyring_ok else "disabled", - ) - self._hf_token_entry.pack(side="left", fill="x", expand=True) - self._clear_token_btn = ctk.CTkButton( - token_row, - text="Clear", - width=64, - height=theme.CONTROL_MD, - font=theme.font(theme.FONT_BODY), - fg_color=COLORS["muted"], - text_color=COLORS["fg"], - hover_color=COLORS["border"], - state="normal" if has_token else "disabled", - command=self._clear_hf_token, - ) - self._clear_token_btn.pack(side="left", padx=(theme.SPACE_2, 0)) - - toggle_row = ctk.CTkFrame(body, fg_color="transparent") - toggle_row.pack(fill="x", pady=(theme.SPACE_5, 0)) - self._chunk_warnings_var = ctk.BooleanVar( - value=self._settings.show_chunk_warnings - ) - text_col = ctk.CTkFrame(toggle_row, fg_color="transparent") - text_col.pack(side="left", fill="x", expand=True) - ctk.CTkLabel( - text_col, - text="Show chunk warnings", - font=theme.font(theme.FONT_LABEL), - text_color=COLORS["fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkLabel( - text_col, - text="Alert when files exceed the context window", - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x") - ctk.CTkSwitch( - toggle_row, - text="", - variable=self._chunk_warnings_var, - progress_color=COLORS["primary"], - ).pack(side="right") - - footer = ctk.CTkFrame(self, fg_color="transparent") - footer.pack( - fill="x", - padx=theme.SPACE_5, - pady=(theme.SPACE_2, theme.SPACE_4), - side="bottom", - ) - save_btn = ctk.CTkButton( - footer, - text="Save", - height=theme.CONTROL_MD, - corner_radius=theme.RADIUS_CARD, - font=theme.font(theme.FONT_LABEL, "bold"), - fg_color=COLORS["primary"], - text_color=COLORS["primary_fg"], - hover_color=COLORS["primary_hover"], - command=self._save, - ) - save_btn.pack(side="right") - motion.press_feedback(save_btn) - cancel_btn = ctk.CTkButton( - footer, - text="Cancel", - height=theme.CONTROL_MD, - corner_radius=theme.RADIUS_CARD, - font=theme.font(theme.FONT_LABEL), - fg_color=COLORS["muted"], - text_color=COLORS["fg"], - hover_color=COLORS["border"], - command=self._cancel, - ) - cancel_btn.pack(side="right", padx=(0, theme.SPACE_2)) - motion.press_feedback(cancel_btn) - - # ------------------------------------------------------------------ - - def _on_output_tokens_edited(self) -> None: - self._output_tokens_entry.configure( - border_color=( - COLORS["input_bg"] - if formatting.is_valid_int(self._output_tokens_var.get()) - else COLORS["destructive"] - ) - ) - - def _clear_hf_token(self) -> None: - secrets.delete_hf_token() - self._hf_token_var.set("") - self._hf_token_entry.configure(placeholder_text="hf_xxx (optional)") - self._clear_token_btn.configure(state="disabled") - - def _save(self) -> None: - if self._keyring_ok: - new_token = self._hf_token_var.get().strip() - if new_token: - secrets.set_hf_token(new_token) - - new_settings = Settings( - default_output_tokens=formatting.parse_int( - self._output_tokens_var.get(), self._settings.default_output_tokens - ), - theme=self._settings.theme, - currency=self._currency_var.get(), - show_chunk_warnings=self._chunk_warnings_var.get(), - onboarding_dismissed=self._settings.onboarding_dismissed, - ) - self._parent_shell.settings_store.save(new_settings) - self._on_save(new_settings) - self._close() - - def _cancel(self) -> None: - self._close() - - def _close(self) -> None: - if self.winfo_exists(): - try: - self.grab_release() - except Exception: # noqa: BLE001 - pass - self.destroy() diff --git a/src/norefund/gui/theme.py b/src/norefund/gui/theme.py deleted file mode 100644 index 855295c..0000000 --- a/src/norefund/gui/theme.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Static design tokens for the GUI: colors, type/spacing/control scales, icons. - -Color values are ported from the NoRefund Desktop UI Design reference -(`NoRefund Desktop UI Design/src/styles/theme.css`). Each entry is a -``(light_hex, dark_hex)`` pair; CTk widgets accept these tuples directly for -``fg_color``/``text_color``/etc. and switch automatically with -``customtkinter.get_appearance_mode()``. This module has no dependency on -``core.settings`` — appearance-mode *selection* lives in app.py/main_view.py. -""" - -from __future__ import annotations - -import tkinter as tk -import tkinter.font as tkfont - -import customtkinter as ctk -from PIL import Image, ImageDraw - -from norefund.core.parsing import SUPPORTED_EXTENSIONS -from norefund.core.paths import bundled_resource - -COLORS: dict[str, tuple[str, str]] = { - "bg": ("#f5f6f8", "#111318"), - "fg": ("#0f1117", "#e6edf3"), - "card": ("#ffffff", "#1c2029"), - "card_fg": ("#0f1117", "#e6edf3"), - "popover": ("#ffffff", "#242830"), - "popover_fg": ("#0f1117", "#e6edf3"), - # Deliberately distinct from "muted" -- muted's dark value (#242830) is - # byte-identical to popover's dark background, so it was invisible as a - # row-hover color on dark popovers. This is a step lighter. - "popover_hover": ("#e8eaed", "#343b47"), - "primary": ("#00b894", "#00d4aa"), - "primary_hover": ("#009f7f", "#00b894"), - # Dark text, not white: white-on-#00b894 is ~2.5:1 contrast, well under - # WCAG AA's 4.5:1 for text. Dark mode's value already got this right - # (#0d1117 on #00d4aa) -- light mode now matches it, since primary's - # own brightness barely changes between modes. - "primary_fg": ("#0d1117", "#0d1117"), - "secondary": ("#eef0f3", "#242830"), - "secondary_fg": ("#0f1117", "#e6edf3"), - "muted": ("#e8eaed", "#242830"), - "muted_fg": ("#6b7280", "#7d8590"), - "destructive": ("#ef4444", "#f85149"), - "destructive_fg": ("#ffffff", "#ffffff"), - # A "muted" chip lightly tinted toward destructive -- rest-state - # background for IconButton's "danger" variant, so a destructive - # button (e.g. Clear) reads as different from a neutral one before - # hover, not just on it. - "destructive_muted": ("#e9cccf", "#4a2f34"), - "border": ("#e2e2e4", "#2a2f39"), - "input_bg": ("#eef0f3", "#242830"), - "sidebar": ("#ffffff", "#181c23"), - "sidebar_fg": ("#0f1117", "#e6edf3"), - "sidebar_accent": ("#f0faf8", "#1a2e29"), - "sidebar_accent_fg": ("#00b894", "#00d4aa"), - "sidebar_border": ("#e9e9eb", "#22262e"), - "warning": ("#f59e0b", "#f59e0b"), - "warning_fg": ("#111318", "#111318"), -} - -PROVIDER_COLORS: dict[str, str] = { - "OpenAI": "#10a37f", - "Anthropic": "#d4a373", - "Google": "#4285f4", - "DeepSeek": "#5b5ea6", - "Meta": "#0668e1", - "Mistral": "#fa7343", -} -_DEFAULT_PROVIDER_COLOR = "#8b949e" - -SUPPORTED_FILETYPES: list[tuple[str, str]] = [ - ( - "Supported documents", - " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)), - ), - ("All files", "*.*"), -] - -# ---------------------------------------------------------------------- -# Type scale -# ---------------------------------------------------------------------- - -FONT_MICRO = 11 -FONT_SMALL = 12 -FONT_BODY = 13 -FONT_LABEL = 14 -FONT_TITLE = 15 -FONT_HEADING = 20 -FONT_DISPLAY = 26 - -# ---------------------------------------------------------------------- -# Spacing — single 4px grid -# ---------------------------------------------------------------------- - -SPACE_1 = 4 -SPACE_2 = 8 -SPACE_3 = 12 -SPACE_4 = 16 -SPACE_5 = 20 -SPACE_6 = 24 -SPACE_7 = 32 -CARD_PAD_X = 20 -CARD_PAD_Y = 16 -PAGE_GUTTER = 24 - -# ---------------------------------------------------------------------- -# Controls -# ---------------------------------------------------------------------- - -CONTROL_SM = 30 -CONTROL_MD = 36 -CONTROL_LG = 42 - -# ---------------------------------------------------------------------- -# Radius -# ---------------------------------------------------------------------- - -RADIUS_CARD = 6 - -_UI_FAMILY_CANDIDATES = ("Inter", "Segoe UI", "Helvetica", "Arial") -_MONO_FAMILY_CANDIDATES = ("JetBrains Mono", "Consolas", "Menlo", "monospace") - -_ui_family: str | None = None -_mono_family: str | None = None - - -def _resolve_family(candidates: tuple[str, ...], fallback: str) -> str: - available = set(tkfont.families()) - for name in candidates: - if name in available: - return name - return fallback - - -def _ui_family_name() -> str: - global _ui_family - if _ui_family is None: - _ui_family = _resolve_family(_UI_FAMILY_CANDIDATES, "TkDefaultFont") - return _ui_family - - -def _mono_family_name() -> str: - global _mono_family - if _mono_family is None: - _mono_family = _resolve_family(_MONO_FAMILY_CANDIDATES, "TkFixedFont") - return _mono_family - - -def font(size: int = FONT_BODY, weight: str = "normal") -> tuple[str, int, str]: - """UI text font tuple, e.g. for CTkLabel(font=font(FONT_LABEL, "bold")).""" - return (_ui_family_name(), size, weight) - - -def mono_font(size: int = FONT_BODY, weight: str = "normal") -> tuple[str, int, str]: - """Monospace font tuple for numbers, paths, and log output.""" - return (_mono_family_name(), size, weight) - - -def tk_font( - size: int = FONT_BODY, weight: str = "normal", scaling: float = 1.0 -) -> tuple[str, int, str]: - """Like font(), but for plain tkinter widgets (tk.Label/tk.Frame, not - CTkLabel) that don't run font tuples through CTk's own point->pixel - scaling. A *positive* Tk font size means points; CTkLabel always - converts to a *negative* (pixel) size before handing it to the - underlying Tk widget, so the exact same nominal size renders visibly - larger on a plain tk widget than on a CTkLabel. This applies the same - conversion CTk uses internally, so plain-tk text matches CTk text at - the same nominal size. `scaling` should be - `ctk.ScalingTracker.get_widget_scaling(widget)`. - """ - return (_ui_family_name(), -abs(round(size * scaling)), weight) - - -def resolve(token: str, dark: bool) -> str: - """Resolve a COLORS token to a single hex string for non-CTk widgets - (e.g. raw tkinter.Text tag colors) that don't auto-switch on appearance mode.""" - light_hex, dark_hex = COLORS[token] - return dark_hex if dark else light_hex - - -def provider_color(provider: str) -> str: - return PROVIDER_COLORS.get(provider, _DEFAULT_PROVIDER_COLOR) - - -_PROVIDER_SLUGS: dict[str, str] = { - "OpenAI": "openai", - "Anthropic": "anthropic", - "Google": "google", - "DeepSeek": "deepseek", - "Meta": "meta", - "Mistral": "mistral", -} - - -def provider_icon(provider: str, size: int = 16) -> ctk.CTkImage | None: - """The provider's brand mark (assets/icons/providers/.png), tinted - to its accent color. None if `provider` has no bundled mark, so callers - can fall back to a plain status dot.""" - slug = _PROVIDER_SLUGS.get(provider) - if slug is None: - return None - hex_color = provider_color(provider) - return icon_image(f"providers/{slug}", size=size, color=(hex_color, hex_color)) - - -_dot_icon_cache: dict[tuple[int, str, int], ctk.CTkImage] = {} - - -def _dot_icon(hex_color: str, size: int) -> ctk.CTkImage: - root_id = id(tk._default_root) - key = (root_id, hex_color, size) - cached = _dot_icon_cache.get(key) - if cached is not None: - return cached - diameter = max(4, size - 4) - offset = (size - diameter) // 2 - dot = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - ImageDraw.Draw(dot).ellipse( - (offset, offset, offset + diameter, offset + diameter), fill=hex_color - ) - image = ctk.CTkImage(light_image=dot, dark_image=dot, size=(size, size)) - _dot_icon_cache[key] = image - return image - - -def provider_icon_or_dot(provider: str, size: int = 16) -> ctk.CTkImage: - """Like `provider_icon()`, but never returns None: a provider with no - bundled brand mark (e.g. Qwen) gets a solid dot in its accent color - instead, matching `provider_mark()`'s widget-based fallback elsewhere. - - Use this (not `provider_icon()`) for a list of DropdownItems that mixes - providers with and without a bundled logo -- every row then gets a - leading icon, so they stay aligned with each other. - """ - icon = provider_icon(provider, size=size) - if icon is not None: - return icon - return _dot_icon(provider_color(provider), size=size) - - -# ---------------------------------------------------------------------- -# Icons — monochrome PNGs (src/norefund/assets/icons), alpha-tinted at -# load time to any COLORS token so one source file covers every theme -# color in both light and dark mode. Replaces the old mixed emoji/glyph -# ICONS dict; icon *names* below are the source-of-truth vocabulary used -# across the GUI (each must have a matching assets/icons/.png). -# ---------------------------------------------------------------------- - -_icon_source_cache: dict[str, Image.Image] = {} -_icon_image_cache: dict[tuple[int, str, int, tuple[str, str]], ctk.CTkImage] = {} - - -def _icon_source(name: str) -> Image.Image: - source = _icon_source_cache.get(name) - if source is None: - path = bundled_resource(f"assets/icons/{name}.png") - source = Image.open(path).convert("RGBA") - _icon_source_cache[name] = source - return source - - -def _tint(name: str, hex_color: str) -> Image.Image: - source = _icon_source(name) - r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) - solid = Image.new("RGBA", source.size, (r, g, b, 255)) - solid.putalpha(source.getchannel("A")) - return solid - - -def icon_image( - name: str, size: int = 16, color: tuple[str, str] = COLORS["fg"] -) -> ctk.CTkImage: - """A CTkImage for icon `name`, tinted to `color` (light_hex, dark_hex). - - Cached per (Tk interpreter, name, size, color) — callers can call this - freely on every build/reconfigure without re-reading or re-tinting the - source PNG. Keying on the current default root's id (not just the icon - params) matters because a CTkImage's underlying PhotoImage is bound to - the Tk interpreter that created it: reusing one across interpreters - (e.g. a fresh `ctk.CTk()` per test) raises "image ... doesn't exist" - once the original interpreter is destroyed. - """ - root_id = id(tk._default_root) - key = (root_id, name, size, color) - cached = _icon_image_cache.get(key) - if cached is not None: - return cached - image = ctk.CTkImage( - light_image=_tint(name, color[0]), - dark_image=_tint(name, color[1]), - size=(size, size), - ) - _icon_image_cache[key] = image - return image - - -_icon_source_cache["__blank__"] = Image.new("RGBA", (8, 8), (0, 0, 0, 0)) - - -def blank_icon(size: int = 16) -> ctk.CTkImage: - """A fully transparent CTkImage, for *clearing* a widget's icon. - - `widget.configure(image=None)` is a no-op on CTkButton/CTkLabel once an - image has been set: `_update_image()` only overwrites the underlying Tk - image when the new value is a CTkImage (or another non-None image), so - the previous icon stays on screen. Configuring with a real (blank) - CTkImage instead actually replaces it. - """ - return icon_image("__blank__", size=size, color=("#000000", "#000000")) diff --git a/src/norefund/gui/widgets.py b/src/norefund/gui/widgets.py deleted file mode 100644 index efa75fc..0000000 --- a/src/norefund/gui/widgets.py +++ /dev/null @@ -1,1040 +0,0 @@ -"""Reusable CTk widgets shared across views.""" - -from __future__ import annotations - -import tkinter as tk -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from tkinter import TclError -from typing import ClassVar - -import customtkinter as ctk - -from norefund.core.models_registry import ModelInfo -from norefund.gui import formatting, motion, native_dialog, theme -from norefund.gui.theme import COLORS - - -class ThreadSafeSchedulerMixin: - """Adds `_schedule()` for safely posting callbacks from worker threads. - - Mix into any CTk widget that starts background threads (downloads, - scans, tokenization) and needs to update the UI from them. Swallows the - Tcl/RuntimeError that `winfo_exists()`/`after()` can raise when the - widget (or the whole app) has been destroyed while the thread was still - running, so a callback firing during shutdown doesn't crash the app. - """ - - def _schedule(self, callback, *args) -> None: - try: - if not self.winfo_exists(): - return - self.after(0, callback, *args) - except (TclError, RuntimeError): - pass - - -class ContextBar(ctk.CTkFrame): - """Thin, color-coded horizontal progress bar for context-window usage.""" - - def __init__(self, parent, height: int = theme.SPACE_2, **kwargs) -> None: - super().__init__(parent, fg_color="transparent", **kwargs) - self._bar = ctk.CTkProgressBar( - self, - height=height, - corner_radius=height // 2, - progress_color=COLORS["primary"], - fg_color=COLORS["muted"], - ) - self._bar.pack(fill="x", expand=True) - self.set_value(None) - - def set_value( - self, pct: float | None, color: tuple[str, str] | None = None - ) -> None: - fraction = 0.0 if pct is None else max(0.0, min(pct / 100, 1.0)) - self._bar.set(fraction) - self._bar.configure(progress_color=color or formatting.context_color(pct)) - - -class StatPill(ctk.CTkFrame): - """Uppercase muted label stacked over a bold (usually mono) value.""" - - def __init__( - self, - parent, - label: str, - value: str = "—", - *, - value_font: tuple | None = None, - **kwargs, - ) -> None: - super().__init__(parent, fg_color="transparent", **kwargs) - ctk.CTkLabel( - self, - text=label.upper(), - font=theme.font(theme.FONT_SMALL), - text_color=COLORS["muted_fg"], - anchor="w", - ).pack(fill="x") - self._value_label = ctk.CTkLabel( - self, - text=value, - font=value_font or theme.mono_font(theme.FONT_HEADING, "bold"), - text_color=COLORS["fg"], - anchor="w", - ) - self._value_label.pack(fill="x") - - def set_text(self, value: str) -> None: - self._value_label.configure(text=value) - - -class IconButton(ctk.CTkButton): - """Small button with an optional icon glyph, styled by variant.""" - - _VARIANTS = { - "primary": ("primary", "primary_fg", "primary_hover"), - "muted": ("muted", "fg", "border"), - "danger": ("destructive_muted", "fg", "destructive"), - } - - def __init__( - self, - parent, - text: str = "", - icon: str | None = None, - variant: str = "muted", - command: Callable[[], None] | None = None, - **kwargs, - ) -> None: - fg_key, text_key, hover_key = self._VARIANTS.get( - variant, self._VARIANTS["muted"] - ) - image = ( - theme.icon_image(icon, size=16, color=COLORS[text_key]) if icon else None - ) - super().__init__( - parent, - text=text, - image=image, - compound="left" if image else "none", - font=theme.font(theme.FONT_LABEL), - fg_color=COLORS[fg_key], - text_color=COLORS[text_key], - # Without this, a disabled button falls back to CTk's generic - # grey text color while the icon (a static image tinted once at - # construction time) keeps its real color -- so text and icon - # visibly mismatch whenever the button is disabled. - text_color_disabled=COLORS[text_key], - hover_color=COLORS[hover_key], - corner_radius=theme.RADIUS_CARD, - height=theme.CONTROL_MD, - command=command, - **kwargs, - ) - motion.press_feedback(self) - - -class ProviderBadge(ctk.CTkLabel): - """Small uppercase pill tinted with the provider's brand color.""" - - # Provider brand colors are mid-brightness hues that fail WCAG AA - # directly against their own tinted background (as low as 2.05:1, - # measured for Anthropic) -- blend toward black in light mode and - # toward white in dark mode, since the badge's own background flips - # from mostly-light to mostly-dark between modes (0.4 is the minimum - # that clears 4.5:1 for every provider color in both modes). - _TEXT_BLEND = 0.4 - - def __init__(self, parent, provider: str, **kwargs) -> None: - accent = theme.provider_color(provider) - bg_tint = ( - formatting.blend(accent, COLORS["card"][0], 0.13), - formatting.blend(accent, COLORS["card"][1], 0.18), - ) - text_color = ( - formatting.blend("#000000", accent, self._TEXT_BLEND), - formatting.blend("#ffffff", accent, self._TEXT_BLEND), - ) - super().__init__( - parent, - text=provider.upper(), - font=theme.font(theme.FONT_MICRO, "bold"), - fg_color=bg_tint, - text_color=text_color, - corner_radius=8, - width=1, - height=22, - padx=8, - **kwargs, - ) - - -class TabBar(ctk.CTkFrame): - """Row of pill-style tab buttons. Tracks and styles which tab is - active; the caller's `on_change(tab_id)` is responsible for actually - switching the visible content.""" - - def __init__( - self, - parent, - tabs: list[tuple[str, str]], - active: str, - on_change: Callable[[str], None], - **kwargs, - ) -> None: - super().__init__(parent, fg_color="transparent", **kwargs) - self._on_change = on_change - self._active = active - self._buttons: dict[str, ctk.CTkButton] = {} - for tab_id, label in tabs: - btn = ctk.CTkButton( - self, - text=label, - font=theme.font(theme.FONT_LABEL), - corner_radius=theme.RADIUS_CARD, - height=theme.CONTROL_MD, - width=110, - fg_color="transparent", - hover_color=COLORS["muted"], - text_color=COLORS["muted_fg"], - command=lambda t=tab_id: self._select(t), - ) - btn.pack(side="left", padx=(0, theme.SPACE_2)) - motion.press_feedback(btn) - self._buttons[tab_id] = btn - self._sync_styles() - - def _select(self, tab_id: str) -> None: - self._active = tab_id - self._sync_styles() - self._on_change(tab_id) - - def _sync_styles(self) -> None: - for tab_id, btn in self._buttons.items(): - active = tab_id == self._active - btn.configure( - text_color=COLORS["primary"] if active else COLORS["muted_fg"] - ) - - -class SidebarItem(ctk.CTkButton): - """Full-width sidebar nav row with an active/inactive visual state.""" - - def __init__( - self, - parent, - text: str, - icon: str, - command: Callable[[], None] | None = None, - **kwargs, - ) -> None: - self._icon_name = icon - super().__init__( - parent, - text=text, - image=theme.icon_image(icon, size=18, color=COLORS["muted_fg"]), - compound="left", - font=theme.font(theme.FONT_TITLE), - anchor="w", - corner_radius=theme.RADIUS_CARD, - height=theme.CONTROL_LG, - fg_color="transparent", - text_color=COLORS["muted_fg"], - hover_color=COLORS["sidebar_accent"], - command=command, - **kwargs, - ) - self._active = False - motion.press_feedback(self) - - def set_active(self, active: bool) -> None: - self._active = active - color_key = "sidebar_accent_fg" if active else "muted_fg" - self.configure( - fg_color=COLORS["sidebar_accent"] if active else "transparent", - text_color=COLORS[color_key], - image=theme.icon_image(self._icon_name, size=18, color=COLORS[color_key]), - ) - - -@dataclass(frozen=True) -class DropdownItem: - """One selectable row: a stable `value`, its display `label`, and an - optional leading icon.""" - - value: str - label: str - icon: ctk.CTkImage | None = None - - -def _popover_geometry(anchor, popover, row_count: int, row_height: int) -> str: - anchor.update_idletasks() - width = max(anchor.winfo_width(), 220) - height = min(row_height * row_count, 320) - - x = anchor.winfo_rootx() - x = min(x, max(0, anchor.winfo_screenwidth() - width)) - y = anchor.winfo_rooty() + anchor.winfo_height() + 2 - if y + height > anchor.winfo_screenheight(): - y = anchor.winfo_rooty() - height - 2 # doesn't fit below -- open above - y = max(0, y) - - # CTkToplevel.geometry() re-multiplies width/height (but not x/y) by the - # window's own scaling factor before applying, while winfo_width()/ - # winfo_height() above are already real device pixels -- pre-divide so - # the two cancel out, otherwise the popover renders `scaling`x too big - # on any HiDPI display. - scaling = ctk.ScalingTracker.get_window_scaling(popover) - width = round(width / scaling) - height = round(height / scaling) - return f"{width}x{height}+{x}+{y}" - - -_root_tracking_installed: set[int] = set() - - -def _ensure_root_tracking(root) -> None: - """Install (once per root, ever) a watcher that keeps every open - DropdownPopover glued to its trigger on move/resize, closes them on - minimize/focus-loss, and discards its own tracking entry on root - -- shared across every popover to avoid accumulating a fresh - handler per open/close.""" - root_id = id(root) - if root_id in _root_tracking_installed: - return - _root_tracking_installed.add(root_id) - root.bind( - "", lambda _e: _root_tracking_installed.discard(root_id), add="+" - ) - - def _reposition_open_popovers(event=None) -> None: - # Tk's bindtags put the toplevel's pathname in every descendant's - # bindtags, so a plain root.bind("", ...) (not bind_all) - # fires for every descendant's Configure too, not just the root - # window's own resize/move -- filter to the real thing. - if event is not None and event.widget is not root: - return - for button in list(DropdownButton._open): - popover = button._popover - if popover is None or not popover.winfo_exists(): - continue - if not button.winfo_exists(): - continue - popover.geometry( - _popover_geometry( - button, popover, popover._row_count, popover._ROW_HEIGHT - ) - ) - - root.bind("", _reposition_open_popovers, add="+") - - def _on_root_unmap(event=None) -> None: - # Same bindtags quirk as above -- filter to the root's - # own Unmap, not some descendant frame being pack_forget()'d. - if event is not None and event.widget is not root: - return - DropdownButton.close_all() - - def _on_focus_out(_event=None) -> None: - # bind_all so this actually fires (a bare bind() on `root` only - # triggers if the root widget itself held focus, which it almost - # never does -- some descendant entry/button does). focus_get() - # returns None only when no widget in this app holds input focus - # any more, i.e. the OS moved focus to a different application -- - # ordinary in-app focus changes (tabbing between fields) always - # leave some widget focused, so they don't trigger this. - if root.focus_get() is None: - DropdownButton.close_all() - - root.bind("", _on_root_unmap, add="+") - root.bind_all("", _on_focus_out, add="+") - - -class DropdownButton(ctk.CTkFrame): - """The one dropdown component used everywhere a value is picked from a - list: trigger button (optional icon + label + chevron) that opens a - non-modal, width-matched, scrollable popover on click, with hover and - selected-row highlighting. - - Generic over `DropdownItem.value` (a plain string) -- `ModelDropdownButton` - below is a thin ModelInfo-specific wrapper around this same popover. - - Tracks every instance with an open popover in a class-level registry so - callers that switch screens (e.g. MainView.show_view) can force-close - any dropdown left open on the screen being navigated away from -- the - popover is a separate CTkToplevel, so raising a different view frame on - top of it does nothing to make it go away on its own. - """ - - _open: ClassVar[set[DropdownButton]] = set() - - def __init__( - self, - parent, - items: list[DropdownItem], - selected_value: str, - on_select: Callable[[str], None], - **kwargs, - ) -> None: - super().__init__( - parent, - fg_color=COLORS["input_bg"], - corner_radius=theme.RADIUS_CARD, - cursor="hand2", - # Constant border width so the focus ring below never shifts - # layout -- only border_color changes, between an exact match - # for fg_color (invisible) and primary (visible ring). - border_width=2, - **kwargs, - ) - self._rest_border_color = self.cget("fg_color") - self.configure(border_color=self._rest_border_color) - self._items = items - self._selected_value = selected_value - self._on_select = on_select - self._popover: DropdownPopover | None = None - - self._icon_label = ctk.CTkLabel(self, text="") - self._text_label = ctk.CTkLabel( - self, text="", font=theme.font(theme.FONT_LABEL), anchor="w" - ) - self._text_label.pack( - side="left", fill="x", expand=True, padx=(10, theme.SPACE_2), - pady=theme.SPACE_2, - ) - self._chevron = ctk.CTkLabel( - self, - text="", - image=theme.icon_image("chevron_down", size=12, color=COLORS["muted_fg"]), - ) - self._chevron.pack(side="right", padx=(theme.SPACE_2, 10), pady=theme.SPACE_2) - self._sync_display() - - for widget in (self, self._icon_label, self._text_label, self._chevron): - widget.bind("", self._toggle) - - # .bind() redirects to self._canvas (see CTkFrame.bind() source), - # so that's the widget that actually needs takefocus and is the - # one Tab-traversal and focus_set() will land keyboard focus on. - self._canvas.configure(takefocus=1) - self.bind("", self._toggle) - self.bind("", self._toggle) - self.bind( - "", lambda _e: self.configure(border_color=COLORS["primary"]) - ) - self.bind( - "", - lambda _e: self.configure(border_color=self._rest_border_color), - ) - - def _item_for(self, value: str) -> DropdownItem | None: - return next((item for item in self._items if item.value == value), None) - - def _sync_display(self) -> None: - item = self._item_for(self._selected_value) - self._text_label.configure(text=item.label if item is not None else "") - if item is not None and item.icon is not None: - self._icon_label.configure(image=item.icon) - self._icon_label.pack( - side="left", padx=(10, theme.SPACE_2), pady=theme.SPACE_2, - before=self._text_label, - ) - else: - self._icon_label.pack_forget() - - def selected_value(self) -> str: - return self._selected_value - - def select(self, value: str) -> None: - """Change the selection without firing `on_select` (external sync).""" - self._selected_value = value - self._sync_display() - - def _toggle(self, _event=None) -> None: - if self._popover is not None and self._popover.winfo_exists(): - self._popover.destroy() - return - self._popover = DropdownPopover( - self, self._items, self._selected_value, self._pick - ) - DropdownButton._open.add(self) - - def _pick(self, value: str) -> None: - self.select(value) - self._on_select(value) - - def _clear_popover(self) -> None: - self._popover = None - DropdownButton._open.discard(self) - - def close_popover(self) -> None: - if self._popover is not None and self._popover.winfo_exists(): - self._popover.destroy() - - @classmethod - def close_all(cls) -> None: - """Close every open popover, regardless of which screen opened it.""" - for button in list(cls._open): - button.close_popover() - - -class DropdownPopover(ctk.CTkToplevel): - """Borderless, non-modal, scrollable popover for `DropdownButton`. - - Width matches the trigger (never narrower than 220px); the currently - selected row is tinted and check-marked at rest, and every row - highlights on hover. Rows are plain tkinter.Frame/Label rather than - CTkFrame/CTkLabel -- CTkFrame's Canvas+DrawEngine background cost - ~300ms of visible lag rebuilding a 28-row list on every open; plain - rows cut that to ~50ms. - """ - - _ROW_HEIGHT = theme.CONTROL_LG - _click_watch_installed: ClassVar[bool] = False - - def __init__( - self, - anchor: DropdownButton, - items: list[DropdownItem], - selected_value: str, - on_pick: Callable[[str], None], - ) -> None: - super().__init__(anchor) - self._anchor = anchor - self._on_pick = on_pick - self._row_count = len(items) - self._icon_photos: list = [] # keep CTkImage-derived PhotoImages alive - self.overrideredirect(True) - self.configure(fg_color=COLORS["popover"]) - self.attributes("-topmost", True) - - self.geometry( - _popover_geometry(anchor, self, self._row_count, self._ROW_HEIGHT) - ) - _ensure_root_tracking(anchor.winfo_toplevel()) - - scroll = ctk.CTkScrollableFrame(self, fg_color=COLORS["popover"]) - scroll.pack(fill="both", expand=True, padx=1, pady=1) - bind_mousewheel(scroll) - - is_dark = ctk.get_appearance_mode() == "Dark" - # Rows keyed by value -- plain tkinter.Frame rows are directly - # discoverable via winfo_children() (unlike CTkFrame's - # internal-canvas indirection), but a stable dict is still clearer - # than tree-walking by index. Also used for / keyboard - # navigation between rows. - self.rows: dict[str, tk.Frame] = {} - self._row_widgets: dict[str, list[tk.Widget]] = {} - self._row_resting: dict[str, str] = {} - self._row_hover: dict[str, str] = {} - self._highlighted: str | None = None - for item in items: - self._build_row( - scroll, item, is_selected=item.value == selected_value, is_dark=is_dark - ) - - self.bind("", lambda _e: self.destroy()) - self.bind("", lambda _e: self._move_highlight(-1)) - self.bind("", lambda _e: self._move_highlight(1)) - self.bind("", lambda _e: self._activate_highlighted()) - self.after(10, self._grab_focus) - DropdownPopover._ensure_click_watch(self) - - def _build_row( - self, scroll, item: DropdownItem, *, is_selected: bool, is_dark: bool - ) -> None: - resting_hex = theme.resolve( - "sidebar_accent" if is_selected else "popover", is_dark - ) - text_hex = theme.resolve( - "sidebar_accent_fg" if is_selected else "popover_fg", is_dark - ) - hover_hex = theme.resolve("popover_hover", is_dark) - - row = tk.Frame( - scroll, bg=resting_hex, bd=0, highlightthickness=0, cursor="hand2" - ) - row.pack(fill="x", pady=1) - self.rows[item.value] = row - self._row_resting[item.value] = resting_hex - self._row_hover[item.value] = hover_hex - if is_selected: - self._highlighted = item.value - widgets = [row] - - widget_scaling = ctk.ScalingTracker.get_widget_scaling(scroll) - - if item.icon is not None: - mode = "dark" if is_dark else "light" - photo = item.icon.create_scaled_photo_image(widget_scaling, mode) - self._icon_photos.append(photo) - icon_label = tk.Label(row, image=photo, bg=resting_hex, bd=0) - icon_label.pack(side="left", padx=(8, theme.SPACE_2), pady=theme.SPACE_2) - widgets.append(icon_label) - - # tk_font (not font): a plain tk.Label renders a positive font size - # in *points*, while the trigger's CTkLabel converts the same - # nominal size to *pixels* internally -- using font() here would - # render visibly larger than the trigger despite an equal number. - label = tk.Label( - row, - text=item.label, - font=theme.tk_font(theme.FONT_LABEL, scaling=widget_scaling), - fg=text_hex, - bg=resting_hex, - bd=0, - anchor="w", - ) - label.pack( - side="left", fill="x", expand=True, padx=(0, theme.SPACE_2), - pady=theme.SPACE_2, - ) - widgets.append(label) - - if is_selected: - check_icon = theme.icon_image("check", size=14, color=COLORS["primary"]) - check_photo = check_icon.create_scaled_photo_image( - widget_scaling, "dark" if is_dark else "light" - ) - self._icon_photos.append(check_photo) - check = tk.Label(row, image=check_photo, bg=resting_hex, bd=0) - check.pack(side="right", padx=(theme.SPACE_2, 8)) - widgets.append(check) - - self._row_widgets[item.value] = widgets - for widget in widgets: - widget.bind("", lambda _e, v=item.value: self._pick(v)) - widget.bind( - "", lambda _e, v=item.value: self._set_row_bg(v, hover=True) - ) - widget.bind( - "", lambda _e, v=item.value: self._set_row_bg(v, hover=False) - ) - - def _set_row_bg(self, value: str, *, hover: bool) -> None: - color = self._row_hover[value] if hover else self._row_resting[value] - for widget in self._row_widgets[value]: - widget.configure(bg=color) - - def _move_highlight(self, delta: int) -> None: - values = list(self.rows) - if not values: - return - if self._highlighted in values: - new_index = (values.index(self._highlighted) + delta) % len(values) - else: - new_index = 0 if delta > 0 else len(values) - 1 - new_value = values[new_index] - - old_value = self._highlighted - self._highlighted = new_value - if old_value is not None and old_value != new_value: - self._set_row_bg(old_value, hover=False) - self._set_row_bg(new_value, hover=True) - self._scroll_into_view(new_value) - - def _scroll_into_view(self, value: str) -> None: - row = self.rows[value] - canvas = row.master._parent_canvas # row's parent is the CTkScrollableFrame - canvas.update_idletasks() - bbox = canvas.bbox("all") - if not bbox: - return - total_height = bbox[3] - bbox[1] - if total_height <= 0: - return - row_top = row.winfo_y() - row_bottom = row_top + row.winfo_height() - view_top = canvas.canvasy(0) - view_bottom = view_top + canvas.winfo_height() - if row_top < view_top: - canvas.yview_moveto(row_top / total_height) - elif row_bottom > view_bottom: - canvas.yview_moveto((row_bottom - canvas.winfo_height()) / total_height) - - def _activate_highlighted(self) -> None: - if self._highlighted is not None: - self._pick(self._highlighted) - - def _grab_focus(self) -> None: - if self.winfo_exists(): - self.focus_set() - - @classmethod - def _ensure_click_watch(cls, widget) -> None: - if cls._click_watch_installed: - return - cls._click_watch_installed = True - - def _on_global_click(event) -> None: - target = event.widget - if isinstance(target, str): - return - for button in list(DropdownButton._open): - popover = button._popover - if popover is None or not popover.winfo_exists(): - continue - if cls._within(target, popover) or cls._within(target, button): - continue - popover.destroy() - - widget.bind_all("", _on_global_click, add="+") - - @staticmethod - def _within(widget, ancestor) -> bool: - while widget is not None: - if widget is ancestor: - return True - widget = getattr(widget, "master", None) - return False - - def _pick(self, value: str) -> None: - self._on_pick(value) - if self.winfo_exists(): - self.destroy() - - def destroy(self) -> None: - if self._anchor._popover is self: - self._anchor._clear_popover() - # Return keyboard focus to the trigger so Tab/Return continue to - # work right after closing, whether closed by picking a row, - # Escape, or a click outside. - try: - if self._anchor.winfo_exists(): - self._anchor._canvas.focus_set() - except (TclError, RuntimeError): - pass - super().destroy() - - -class ModelDropdownButton(DropdownButton): - """ModelInfo-specific convenience wrapper around DropdownButton: turns - a model list into DropdownItems (provider icon + priced label) so - Calculator/Parser/Compare keep working with ModelInfo objects instead - of raw ids. Shares DropdownButton's `_open` registry (not redeclared - here), so DropdownButton.close_all() closes these popovers too.""" - - def __init__( - self, - parent, - models: list[ModelInfo], - selected: ModelInfo, - on_select: Callable[[ModelInfo], None], - **kwargs, - ) -> None: - self._models_by_id = {m.id: m for m in models} - items = [ - DropdownItem( - value=m.id, - label=formatting.model_label(m), - icon=theme.provider_icon_or_dot(m.provider, size=14), - ) - for m in models - ] - self._raw_on_select = on_select - super().__init__(parent, items, selected.id, self._handle_select, **kwargs) - - def _handle_select(self, value: str) -> None: - self._raw_on_select(self._models_by_id[value]) - - def selected_model(self) -> ModelInfo: - return self._models_by_id[self.selected_value()] - - -class NoticeBanner(ctk.CTkFrame): - """Dismissible, non-modal notice bar with an optional action link.""" - - def __init__( - self, - parent, - text: str, - *, - action_text: str | None = None, - on_action: Callable[[], None] | None = None, - on_dismiss: Callable[[], None] | None = None, - **kwargs, - ) -> None: - super().__init__(parent, fg_color=COLORS["warning"], corner_radius=0, **kwargs) - self._on_dismiss = on_dismiss - - inner = ctk.CTkFrame(self, fg_color="transparent") - inner.pack(fill="x", padx=theme.SPACE_4, pady=theme.SPACE_2) - ctk.CTkLabel( - inner, - text=text, - image=theme.icon_image("warning", size=14, color=COLORS["warning_fg"]), - compound="left", - font=theme.font(theme.FONT_BODY), - text_color=COLORS["warning_fg"], - anchor="w", - ).pack(side="left") - - if action_text and on_action is not None: - action = ctk.CTkLabel( - inner, - text=action_text, - font=theme.font(theme.FONT_BODY, "bold"), - text_color=COLORS["warning_fg"], - cursor="hand2", - ) - action.pack(side="left", padx=(theme.SPACE_3, 0)) - action.bind("", lambda _e: on_action()) - - close = ctk.CTkLabel( - inner, - text="", - image=theme.icon_image("x", size=12, color=COLORS["warning_fg"]), - cursor="hand2", - ) - close.pack(side="right") - close.bind("", lambda _e: self._dismiss()) - - def _dismiss(self) -> None: - self.pack_forget() - if self._on_dismiss is not None: - self._on_dismiss() - - -class ModelCheckList(ctk.CTkScrollableFrame): - """Scrollable list of model checkboxes, all checked by default.""" - - def __init__( - self, - parent, - models: list[ModelInfo], - on_change: Callable[[], None] | None = None, - **kwargs, - ) -> None: - super().__init__(parent, fg_color=COLORS["card"], **kwargs) - self._models = models - self._on_change = on_change - self._vars: dict[str, ctk.BooleanVar] = {} - - for model in models: - var = ctk.BooleanVar(value=True) - self._vars[model.id] = var - row = ctk.CTkFrame(self, fg_color="transparent") - row.pack(fill="x", pady=1) - ctk.CTkCheckBox( - row, - text="", - variable=var, - width=20, - command=self._notify_change, - fg_color=COLORS["primary"], - ).pack(side="left", padx=(theme.SPACE_1, theme.SPACE_1)) - dot = provider_mark(row, model.provider) - dot.pack(side="left", padx=(0, theme.SPACE_2)) - ctk.CTkLabel( - row, - text=formatting.model_label(model), - font=theme.font(theme.FONT_LABEL), - anchor="w", - ).pack(side="left", fill="x", expand=True) - - bind_mousewheel(self) - - def _notify_change(self) -> None: - if self._on_change is not None: - self._on_change() - - def selected_models(self) -> list[ModelInfo]: - return [m for m in self._models if self._vars[m.id].get()] - - def select_all(self) -> None: - for var in self._vars.values(): - var.set(True) - self._notify_change() - - def select_none(self) -> None: - for var in self._vars.values(): - var.set(False) - self._notify_change() - - -_wheel_scroll_installed = False - - -def bind_mousewheel(frame: ctk.CTkScrollableFrame) -> None: - """Enable Linux (X11) mouse-wheel scrolling for a CTkScrollableFrame. - - CustomTkinter only binds , which fires on Windows/macOS; - X11 sends / instead. Installed once app-wide (not - per frame) and walks up from whatever's under the pointer to the - nearest CTkScrollableFrame, so callers can call this on every - scrollable frame they build without leaking a handler per call. - """ - global _wheel_scroll_installed - if _wheel_scroll_installed: - return - _wheel_scroll_installed = True - - def _on_wheel(event) -> None: - widget = event.widget - # A widget from outside this app's own tree (e.g. Tk's built-in file - # dialog, whose internals have no Python-side wrapper) comes through - # as a raw path string rather than a widget object. - if isinstance(widget, str): - return - canvas = None - while widget is not None: - if isinstance(widget, ctk.CTkScrollableFrame): - canvas = widget._parent_canvas - break - widget = getattr(widget, "master", None) - if canvas is None: - return - top, bottom = canvas.yview() - scrolling_up = event.num == 4 - if scrolling_up and top <= 0.0: - return # already at the top - don't scroll into blank canvas - if not scrolling_up and bottom >= 1.0: - return # already at the bottom - canvas.yview_scroll(-1 if scrolling_up else 1, "units") - - frame.bind_all("", _on_wheel, add="+") - frame.bind_all("", _on_wheel, add="+") - - -def export_via_dialog( - *, - has_data: bool, - extension: str, - filetype_label: str, - content_fn: Callable[[], str], -) -> None: - """Prompt a save-file dialog and write `content_fn()`'s result to it. - - No-op if `has_data` is False (nothing to export yet) or the dialog is - cancelled. - """ - if not has_data: - return - path = native_dialog.ask_save_file( - defaultextension=f".{extension}", - filetypes=[(filetype_label, f"*.{extension}")], - ) - if path: - Path(path).write_text(content_fn(), encoding="utf-8") - - -def export_via_dialog_bytes( - *, - has_data: bool, - extension: str, - filetype_label: str, - content_fn: Callable[[], bytes], -) -> None: - """Bytes-writing sibling of `export_via_dialog`, for binary formats - (e.g. PDF) that can't go through `Path.write_text`.""" - if not has_data: - return - path = native_dialog.ask_save_file( - defaultextension=f".{extension}", - filetypes=[(filetype_label, f"*.{extension}")], - ) - if path: - Path(path).write_bytes(content_fn()) - - -def card(parent, **kwargs) -> ctk.CTkFrame: - """Standard card container: `COLORS['card']` background, RADIUS_CARD. - - Unpacked — the caller still calls `.pack(...)`/`.grid(...)` themselves, - since callers vary in their own spacing (padx/pady). - """ - return ctk.CTkFrame( - parent, fg_color=COLORS["card"], corner_radius=theme.RADIUS_CARD, **kwargs - ) - - -def status_dot( - parent, color: str | tuple = COLORS["muted"], **kwargs -) -> ctk.CTkLabel: - """Small colored circle used for generic (non-provider) status indicators.""" - return ctk.CTkLabel( - parent, - text="", - width=12, - height=12, - corner_radius=6, - fg_color=color, - **kwargs, - ) - - -def provider_mark(parent, provider: str, *, size: int = 14, **kwargs) -> ctk.CTkLabel: - """The provider's brand mark, tinted to its accent color -- falls back - to a plain colored dot for a provider with no bundled logo. Delegates - the icon-or-dot fallback to theme.provider_icon_or_dot(), the one place - that logic lives (rather than reimplementing it here too).""" - return ctk.CTkLabel( - parent, text="", image=theme.provider_icon_or_dot(provider, size=size), **kwargs - ) - - -def section_label( - parent, text: str, *, size: int = theme.FONT_MICRO, anchor: str = "w", **kwargs -) -> ctk.CTkLabel: - """Uppercase, bold, muted header label for a section heading.""" - return ctk.CTkLabel( - parent, - text=text.upper(), - font=theme.font(size, "bold"), - text_color=COLORS["muted_fg"], - anchor=anchor, - **kwargs, - ) - - -class LoadingOverlay: - """Centered muted label placed over a `CTkScrollableFrame`'s canvas. - - Parented on `_parent_canvas` (the scroll area's actual visible viewport, - not the inner content frame that grows/shrinks with content) so - relx/rely=0.5 centers it on the screen the user sees, regardless of - scroll position or how much content ends up being built. Owns the one - place that reaches into CTkScrollableFrame's private `_parent_canvas` - attribute, so callers don't each have to. - """ - - def __init__(self, scrollable_frame: ctk.CTkScrollableFrame, text: str) -> None: - self._label = ctk.CTkLabel( - scrollable_frame._parent_canvas, - text=text, - font=theme.font(theme.FONT_LABEL), - text_color=COLORS["muted_fg"], - ) - self._bg = scrollable_frame.cget("fg_color") - - def show(self) -> None: - self._label.configure(text_color=COLORS["muted_fg"]) - self._label.place(relx=0.5, rely=0.5, anchor="center") - - def hide(self) -> None: - motion.fade_text_color( - self._label, - COLORS["muted_fg"], - self._bg, - duration=150, - on_done=self._label.place_forget, - ) - - -class EmptyState(ctk.CTkLabel): - """Centered muted icon+message shown where results would otherwise go.""" - - def __init__(self, parent, icon: str, text: str, **kwargs) -> None: - super().__init__( - parent, - text=text, - image=theme.icon_image(icon, size=32, color=COLORS["muted_fg"]), - compound="top", - font=theme.font(theme.FONT_TITLE), - text_color=COLORS["muted_fg"], - justify="center", - **kwargs, - ) From 8d5abcd169f05c79a0b19aa8c2b52b57aeb9cedd Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 10:51:35 +0530 Subject: [PATCH 2/4] build: drop customtkinter and repoint the GUI launch to the desktop app Removed customtkinter, Pillow (unused anywhere in src/), and the dnd optional group (tkinterdnd2) now that gui/ is gone. main.py's --gui path now launches norefund.desktop.app instead of the deleted Tk App class; kept main.py's plain-text CLI analyze mode (norefund --model ...) rather than repointing the console script straight at desktop.app:main, since that mode has nothing to do with the Tk GUI and dropping it would be an unrelated regression. norefund.spec's PyInstaller excludes list no longer names customtkinter -- it's not a dependency to exclude anymore. --- packaging/norefund.spec | 1 - pyproject.toml | 5 --- src/norefund/main.py | 5 +-- uv.lock | 81 +++++++++++++++++++---------------------- 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/packaging/norefund.spec b/packaging/norefund.spec index e4da578..d12645e 100644 --- a/packaging/norefund.spec +++ b/packaging/norefund.spec @@ -63,7 +63,6 @@ hiddenimports += collect_submodules("gi.overrides") # edgechromium on Windows; macOS has no choice of backend). excludes = [ "tkinter", - "customtkinter", "PyQt5", "PyQt6", "PySide2", diff --git a/pyproject.toml b/pyproject.toml index fd4b3c2..dc365c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ ] dependencies = [ - "customtkinter>=5.2.2", "tiktoken>=0.7.0", "pypdf>=4.3.1", "python-pptx>=1.0.2", @@ -30,7 +29,6 @@ dependencies = [ "platformdirs>=4.0", "huggingface_hub>=0.25.0", "keyring>=25.0", - "Pillow>=10.0", "reportlab>=4.0", "pywebview>=6.2.1", ] @@ -42,9 +40,6 @@ dev = [ "pytest>=9.1.1", "pyinstaller>=6.21.0", ] -dnd = [ - "tkinterdnd2>=0.4.2", -] linux = [ "pygobject>=3.50", ] diff --git a/src/norefund/main.py b/src/norefund/main.py index 001b496..f5d9b57 100644 --- a/src/norefund/main.py +++ b/src/norefund/main.py @@ -19,7 +19,7 @@ def _init_tiktoken_cache_dir() -> None: _init_tiktoken_cache_dir() from norefund.core.service import analyze_file # noqa: E402 -from norefund.gui.app import App # noqa: E402 +from norefund.desktop.app import main as run_desktop_app # noqa: E402 def main() -> None: @@ -40,8 +40,7 @@ def main() -> None: def _run_gui() -> None: - app = App() - app.mainloop() + run_desktop_app() def _run_cli(file_path: str, model_id: str) -> None: diff --git a/uv.lock b/uv.lock index 891ed61..136c1ba 100644 --- a/uv.lock +++ b/uv.lock @@ -306,28 +306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, ] -[[package]] -name = "customtkinter" -version = "5.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "darkdetect" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/48/c5a9d44188c44702e1e3db493c741e9c779596835a761b819fe15431d163/customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207", size = 261999, upload-time = "2024-01-10T02:24:36.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/b1/b43b33001a77256b335511e75f257d001082350b8506c8807f30c98db052/customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c", size = 296062, upload-time = "2024-01-10T02:24:33.53Z" }, -] - -[[package]] -name = "darkdetect" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/77/7575be73bf12dee231d0c6e60ce7fb7a7be4fcd58823374fc59a6e48262e/darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1", size = 7681, upload-time = "2022-12-16T14:14:42.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955, upload-time = "2022-12-16T14:14:40.92Z" }, -] - [[package]] name = "filelock" version = "3.29.4" @@ -626,6 +604,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "modulegraph" +version = "0.19.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/fa/c471f46fa1696309b806d6c663d599525925a5c13cf1371a02587a265109/modulegraph-0.19.7.tar.gz", hash = "sha256:9ad8a81148ba1d90ade66617a153786f7d7cf6a88de83ee28e251183122c2a57", size = 88618, upload-time = "2025-11-22T08:22:43.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c1/3eff0be83439f0408866eaacd4106c9d19b762cba66bcf0d0a523a6f6620/modulegraph-0.19.7-py2.py3-none-any.whl", hash = "sha256:139f89042c9912777ba4bbf1d5496591c6ad780110ea3082c11e8737cf82cacc", size = 35151, upload-time = "2025-11-22T08:22:42.239Z" }, +] + [[package]] name = "more-itertools" version = "11.1.0" @@ -649,10 +640,8 @@ name = "norefund" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "customtkinter" }, { name = "huggingface-hub" }, { name = "keyring" }, - { name = "pillow" }, { name = "platformdirs" }, { name = "pypdf" }, { name = "python-docx" }, @@ -671,12 +660,13 @@ dev = [ { name = "pytest" }, { name = "ruff" }, ] -dnd = [ - { name = "tkinterdnd2" }, -] linux = [ { name = "pygobject" }, ] +macos = [ + { name = "py2app" }, + { name = "setuptools" }, +] [package.dev-dependencies] dev = [ @@ -689,11 +679,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=26.5.1" }, - { name = "customtkinter", specifier = ">=5.2.2" }, { name = "huggingface-hub", specifier = ">=0.25.0" }, { name = "keyring", specifier = ">=25.0" }, - { name = "pillow", specifier = ">=10.0" }, { name = "platformdirs", specifier = ">=4.0" }, + { name = "py2app", marker = "extra == 'macos'", specifier = ">=0.28,<0.28.9" }, { name = "pygobject", marker = "extra == 'linux'", specifier = ">=3.50" }, { name = "pyinstaller", marker = "extra == 'dev'", specifier = ">=6.21.0" }, { name = "pypdf", specifier = ">=4.3.1" }, @@ -704,11 +693,11 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "reportlab", specifier = ">=4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.19" }, + { name = "setuptools", marker = "extra == 'macos'", specifier = "<81" }, { name = "tiktoken", specifier = ">=0.7.0" }, - { name = "tkinterdnd2", marker = "extra == 'dnd'", specifier = ">=0.4.2" }, { name = "tokenizers", specifier = ">=0.23.1" }, ] -provides-extras = ["dev", "dnd", "linux"] +provides-extras = ["dev", "linux", "macos"] [package.metadata.requires-dev] dev = [ @@ -838,6 +827,21 @@ version = "0.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } +[[package]] +name = "py2app" +version = "0.28.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib" }, + { name = "modulegraph" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/d8/a81d296560df958e496a03e4373615db5157dacb388945110404cbd0879b/py2app-0.28.8.tar.gz", hash = "sha256:cab7aec752a8b83e7c6cef7c15271dc720155275e44cbf610e3162e0807313ec", size = 1173004, upload-time = "2024-05-25T16:39:52.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/12/a0932851fe222d9cf9193e192f01d7f62c89d9690b17d731ef5385df4360/py2app-0.28.8-py2.py3-none-any.whl", hash = "sha256:38b80f0c91f8d2dddb6396e6bbff8a84875f073c510f4f1a307e8ae44c7d73d8", size = 835661, upload-time = "2024-05-25T16:31:50.8Z" }, +] + [[package]] name = "pycairo" version = "1.29.1" @@ -1384,11 +1388,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "80.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] [[package]] @@ -1447,15 +1451,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] -[[package]] -name = "tkinterdnd2" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/68/a7400de3c15038522b00540480a1273809ad4680b941e9c5f82520b27f2f/tkinterdnd2-0.6.2.tar.gz", hash = "sha256:e015c2d863c9292e61d0294dea43010ff99c3cc9423e564676e04df413de9916", size = 729841, upload-time = "2026-07-05T00:05:21.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/1b/039642c212c24887a941af706b006365f3733d88aab383f0cf151768403c/tkinterdnd2-0.6.2-py3-none-any.whl", hash = "sha256:b6a8b229d26286c022bb2fbd311c2e431e4d9bbab8133be80e9c98e7bcf9fe59", size = 811654, upload-time = "2026-07-05T00:05:19.682Z" }, -] - [[package]] name = "tokenizers" version = "0.23.1" From e8ff2fc9b5fc293465e87f85621d3aad2d4d5853 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 11:09:42 +0530 Subject: [PATCH 3/4] docs: update CLAUDE.md and README for the React UI cutover Both still described the retired CustomTkinter GUI (python -m norefund.gui.app, norefund --gui as a Tk launch, a 10-model feature list, gui/ in the project structure tree). Rewrote to match the app as it now is: desktop/ + frontend/ layout, actual model/provider counts, the full current view list, and the macOS xattr -cr note alongside the existing Windows SmartScreen one. GUI_REVIEW.md and GUI_PERFORMANCE.md, which GUI_REBUILD/13-CUTOVER.md says to archive to docs/history/, don't exist anywhere in this repo -- nothing to move. --- README.md | 49 +++++++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0d608b6..7b3f86b 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,23 @@ used only when you explicitly download a tokenizer, from the app's Resources vie --- -## Features (v0.1) +## Features - Parse PDF, PPTX, DOCX, TXT, MD files -- Count tokens for 10+ LLM models (GPT-4o, Claude, Gemini, DeepSeek, Llama, Mistral) +- Count tokens for 21 models across 7 providers (OpenAI, Anthropic, Google, DeepSeek, + Meta, Mistral, Qwen) - Context window usage percentage with fit/chunk analysis +- Compare cost and context fit across multiple models side by side, with per-model and + portfolio cost projection +- Self-Host Fit Check: estimate whether an open-weight model actually fits on your own + GPU, Apple Silicon Mac, or cloud instance, given quantization, KV cache precision, + context length, and concurrency - Local cost estimation per model, with no data ever sent anywhere +- Model Registry: browse every supported model's context window, pricing, and + architecture details - Resources view: see which tokenizers are downloaded, where they live on disk, and their size, with a one-click download for anything missing -- CLI and Desktop GUI (CustomTkinter) +- CLI and a native desktop app (React + pywebview) for Windows, macOS, and Linux --- @@ -43,6 +51,10 @@ the first time you run it. Click **More info → Run anyway** to continue. This means the publisher isn't verified, not that the app is unsafe — the source is right here in this repo. +**macOS Gatekeeper:** for the same reason, macOS may refuse to open the app with an +"is damaged and can't be opened" dialog. Run `xattr -cr NoRefund.app` in Terminal after +extracting it — see `packaging/README.md` for details. + --- ## Quick Start @@ -50,22 +62,25 @@ here in this repo. ```bash # Install pip install -e ".[dev]" +cd frontend && npm install && npm run build && cd .. # CLI norefund path/to/file.pdf --model openai:gpt-4o -# GUI +# Desktop app norefund --gui ``` +For frontend development with hot reload, see `CLAUDE.md`'s Commands section. + --- ## Project Structure ``` src/norefund/ - main.py # Entry point - gui/ # CustomTkinter GUI + main.py # CLI entry point + GUI launch + desktop/ # pywebview shell and JS bridge (api.py, app.py, dto.py, jobs.py) core/ parsing.py # Document text extraction tokenization.py # Tokenizer backends @@ -74,6 +89,7 @@ src/norefund/ service.py # Orchestration config/ default_models.yaml # Local model registry +frontend/ # React UI (Calculator, Parser, Compare, Fit Check, Registry, Resources) tests/ ``` @@ -81,20 +97,13 @@ tests/ ## Supported Models -| Model | Provider | Context Window | -|---|---|---| -| GPT-4o | OpenAI | 128K | -| GPT-4o Mini | OpenAI | 128K | -| GPT-4.1 | OpenAI | 1M | -| Claude 3.5 Sonnet | Anthropic | 200K | -| Claude 3 Haiku | Anthropic | 200K | -| Gemini 2.0 Flash | Google | 1M | -| Gemini 1.5 Pro | Google | 2M | -| DeepSeek V3 | DeepSeek | 128K | -| Llama 3 8B | Meta (self-hosted) | 8K | -| Mistral 7B | Mistral (self-hosted) | 32K | - -**Tokenizer accuracy:** OpenAI models, DeepSeek V3, Llama 3, and Mistral use each +21 models across 7 providers — OpenAI, Anthropic, Google, DeepSeek, Meta, Mistral, and +Qwen, spanning both hosted API models and self-hosted open-weight models. The +in-app **Model Registry** view is the source of truth for the current list, context +windows, and pricing (`config/default_models.yaml` backs it, so it never drifts from +what the app actually uses). + +**Tokenizer accuracy:** OpenAI models, DeepSeek V3, Llama, Qwen, and Mistral use each provider's real tokenizer. Anthropic and Google don't publish a local tokenizer for Claude or Gemini, so those counts are a `cl100k_base` approximation — the app marks them `(approx.)` wherever they're shown. From 6042b6f9c683de9aa09c6032e8061d311026597b Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 11:12:15 +0530 Subject: [PATCH 4/4] chore: final cutover verification Full gate run on Linux (this dev machine): - pytest: 250 passed - ruff check src/: clean - frontend: npm run typecheck, npm test (102 passed), npm run build: clean - python packaging/build.py: builds, PyInstaller one-dir output 125.0 MB, frozen binary launches and stays up under Xvfb (exit 124 = killed by the 5s timeout while still running, same pass condition build.yml uses) Bundle sizes from the v0.1.0 release build (github.com/Phantom-VK/NoRefund/releases/tag/v0.1.0), compressed release artifact sizes: - Linux tar.gz: ~65 MB - Windows installer (Inno Setup .exe): ~33.7 MB - macOS tar.gz: ~64 MB Cold start time to first paint and idle memory are not instrumented by this pass -- they need a real per-OS interactive measurement (a headless Linux Xvfb run doesn't give a meaningful "time to first paint" number, and there's no Windows/macOS hardware in this environment). Left as an open follow-up rather than reporting fabricated numbers.