From 5d58eb5ced8ae85d8cdac9296658bd3777691e68 Mon Sep 17 00:00:00 2001 From: falbany Date: Wed, 4 Nov 2026 21:53:44 +0100 Subject: [PATCH 1/2] refactor(gui)!: remove the deprecated Tkinter frontend (phase 2) Delete src/profiles/gui/ (15 modules) and its skipped test tree (tests/gui/, tests/test_app_tk_lifecycle.py). The web frontend (React PWA + FastAPI + PyWebView desktop shell) is the only UI. - app.py: drop _run_gui(), the --tkinter/--web flags, the deprecation banners, the gui_backend constructor param, the TYPE_CHECKING MainWindow import and the stale _window attribute; run() now routes headless -> server-only -> web GUI unconditionally - tests: test_app_no_tkinter.py deleted (the tkinter-unavailable ImportError path it pinned no longer exists); its web-only routing coverage is folded into TestRun (run() -> _run_web_gui, desktop launch call). test_app_backend_selection.py deleted (backend selection ceased to exist) - packaging: sv-ttk + darkdetect out of both dev lists and uv.lock; guard tests now pin their absence from dev lists instead of presence - .coveragerc: drop the profiles/gui omit block Fixes the code/tests/packaging sections of #50. Docs sync follows. --- .coveragerc | 3 - pyproject.toml | 8 +- src/profiles/app.py | 108 +- src/profiles/gui/__init__.py | 1 - src/profiles/gui/context_menu.py | 614 -------- src/profiles/gui/controllers/__init__.py | 27 - .../gui/controllers/directory_manager.py | 157 -- .../gui/controllers/scan_controller.py | 99 -- .../gui/controllers/window_actions.py | 103 -- src/profiles/gui/i18n.py | 263 ---- src/profiles/gui/main_window.py | 1352 ----------------- src/profiles/gui/presentation/__init__.py | 17 - src/profiles/gui/presentation/row_colors.py | 73 - src/profiles/gui/search_bar.py | 371 ----- src/profiles/gui/status_bar.py | 386 ----- src/profiles/gui/styles.py | 113 -- src/profiles/gui/theme.py | 688 --------- src/profiles/gui/ui.py | 319 ---- tests/gui/__init__.py | 1 - tests/gui/conftest.py | 28 - tests/gui/controllers/__init__.py | 1 - .../gui/controllers/test_directory_manager.py | 114 -- tests/gui/controllers/test_scan_controller.py | 132 -- tests/gui/controllers/test_window_actions.py | 86 -- tests/gui/presentation/__init__.py | 1 - tests/gui/presentation/test_row_colors.py | 70 - tests/gui/test_context_menu.py | 138 -- tests/gui/test_i18n.py | 167 -- tests/gui/test_main_window.py | 1263 --------------- tests/gui/test_main_window_shortcuts.py | 73 - tests/gui/test_search_bar.py | 321 ---- tests/gui/test_status_bar.py | 323 ---- tests/gui/test_styles.py | 183 --- tests/gui/test_theme.py | 499 ------ tests/gui/test_ui.py | 195 --- tests/packaging/test_packaging.py | 7 +- tests/test_app.py | 36 +- tests/test_app_backend_selection.py | 37 - tests/test_app_no_tkinter.py | 115 -- tests/test_app_tk_lifecycle.py | 103 -- uv.lock | 26 - 41 files changed, 43 insertions(+), 8578 deletions(-) delete mode 100644 src/profiles/gui/__init__.py delete mode 100644 src/profiles/gui/context_menu.py delete mode 100644 src/profiles/gui/controllers/__init__.py delete mode 100644 src/profiles/gui/controllers/directory_manager.py delete mode 100644 src/profiles/gui/controllers/scan_controller.py delete mode 100644 src/profiles/gui/controllers/window_actions.py delete mode 100644 src/profiles/gui/i18n.py delete mode 100644 src/profiles/gui/main_window.py delete mode 100644 src/profiles/gui/presentation/__init__.py delete mode 100644 src/profiles/gui/presentation/row_colors.py delete mode 100644 src/profiles/gui/search_bar.py delete mode 100644 src/profiles/gui/status_bar.py delete mode 100644 src/profiles/gui/styles.py delete mode 100644 src/profiles/gui/theme.py delete mode 100644 src/profiles/gui/ui.py delete mode 100644 tests/gui/__init__.py delete mode 100644 tests/gui/conftest.py delete mode 100644 tests/gui/controllers/__init__.py delete mode 100644 tests/gui/controllers/test_directory_manager.py delete mode 100644 tests/gui/controllers/test_scan_controller.py delete mode 100644 tests/gui/controllers/test_window_actions.py delete mode 100644 tests/gui/presentation/__init__.py delete mode 100644 tests/gui/presentation/test_row_colors.py delete mode 100644 tests/gui/test_context_menu.py delete mode 100644 tests/gui/test_i18n.py delete mode 100644 tests/gui/test_main_window.py delete mode 100644 tests/gui/test_main_window_shortcuts.py delete mode 100644 tests/gui/test_search_bar.py delete mode 100644 tests/gui/test_status_bar.py delete mode 100644 tests/gui/test_styles.py delete mode 100644 tests/gui/test_theme.py delete mode 100644 tests/gui/test_ui.py delete mode 100644 tests/test_app_backend_selection.py delete mode 100644 tests/test_app_no_tkinter.py delete mode 100644 tests/test_app_tk_lifecycle.py diff --git a/.coveragerc b/.coveragerc index e395170..e753340 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,9 +4,6 @@ branch = True omit = */tests/* */__main__.py - # Deprecated Tkinter frontend: tests are skipped (tests/gui/conftest.py) - # pending deletion in phase 2 — exclude it from the coverage gate too. - */profiles/gui/* [report] exclude_lines = diff --git a/pyproject.toml b/pyproject.toml index f65444a..0b6b624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,9 +59,6 @@ dev = [ "deptry>=0.22.0", "httpx>=0.27.0", "briefcase>=0.4.4", - # Tkinter frontend (deprecated, developer use only; see 2026-10-23-native-installer-design.md) - "sv-ttk>=2.5.0", - "darkdetect>=0.8.0", ] [tool.hatch.build.hooks.custom] @@ -98,7 +95,7 @@ web-based (React) interface packaged as a native desktop app.""" icon = "img/profiles" sources = ["src/profiles"] # Runtime dependencies installed into the Briefcase bundle. Must mirror -# [project].dependencies minus the deprecated Tk extras (sv-ttk, darkdetect). +# [project].dependencies. # proxy_tools is vendored as a local wheel because pywebview depends on it, # it ships only an sdist on PyPI, and Briefcase installs macOS requirements # with `--only-binary :all:` (universal py3-none-any wheel; see @@ -216,7 +213,4 @@ dev = [ "deptry>=0.22.0", "httpx>=0.27.0", "briefcase>=0.4.4", - # Tkinter frontend (deprecated, developer use only; see 2026-10-23-native-installer-design.md) - "sv-ttk>=2.5.0", - "darkdetect>=0.8.0", ] diff --git a/src/profiles/app.py b/src/profiles/app.py index 16e4eff..bd2ffb0 100644 --- a/src/profiles/app.py +++ b/src/profiles/app.py @@ -1,7 +1,8 @@ """ProFiles application entry point. -Supports both GUI mode (interactive) and headless CLI mode (automated). -In headless mode, files can be launched via command-line arguments. +Supports interactive mode (web UI in a desktop shell or browser) and +headless CLI mode (automated). In headless mode, files can be launched +via command-line arguments. """ from __future__ import annotations @@ -12,7 +13,6 @@ import platform import sys from pathlib import Path -from typing import TYPE_CHECKING from profiles.core import AppConfig, actions, load_config from profiles.core.config.io.yaml_io import PRIMARY_CONFIG_NAME @@ -31,20 +31,8 @@ ) from profiles.utils.network import get_hostname, get_username -if TYPE_CHECKING: - from profiles.gui.main_window import MainWindow - _DEFAULT_PORT = 8088 -_TKINTER_DEPRECATION_BANNER = ( - "⚠ The Tkinter frontend is deprecated (developer/advanced use only) and\n" - " will be removed in a future release. The default web GUI replaces it." -) -_TKINTER_UNAVAILABLE_MSG = ( - "ERROR: The Tkinter frontend requires a full Python installation with Tk\n" - " (tkinter). Use the default web GUI instead: profiles" -) - def host_error(value: str) -> str | None: """Validate a ``--host`` bind value; return an error message or ``None``. @@ -94,11 +82,12 @@ class ProFileApp: """ProFiles application orchestrator. Supports two modes: - - GUI mode: Interactive file browser with launch capabilities + - Interactive mode: web UI in a PyWebView desktop shell (or browser-only + with ``--server-only``) - Headless mode: CLI-only for automated file launching Usage:: - # GUI mode + # Interactive mode (web GUI) app = ProFileApp() app.run() @@ -114,7 +103,6 @@ def __init__( headless: bool = False, server_only: bool = False, no_config_propose: bool = False, - gui_backend: str = "web", host: str = "127.0.0.1", ) -> None: """Initialize the application. @@ -126,7 +114,6 @@ def __init__( headless: Run without GUI (CLI mode). server_only: Run only the web server without opening desktop window. no_config_propose: Do not offer to create a .profiles at startup. - gui_backend: GUI backend to use ('tk' or 'web'). Defaults to 'web'. host: Bind address for the web server ('0.0.0.0' serves the LAN). """ self._config_path = Path(config_path) if config_path is not None else None @@ -134,10 +121,8 @@ def __init__( self._headless = headless self._server_only = server_only self._no_config_propose = no_config_propose - self._gui_backend = gui_backend.lower() self._host = host self._config: AppConfig | None = None - self._window: MainWindow | None = None self._logger: logging.Logger | None = None @staticmethod @@ -241,8 +226,7 @@ def run(self, file: str | Path | None = None) -> None: The full lifecycle: 1. Load configuration (to get verbose level) 2. Configure logging with the specified verbosity - 3. Create and display the main window (or run headless) - 4. Start the Tkinter event loop + 3. Branch to headless mode, server-only mode, or the web GUI Args: file: File to launch (headless mode only). @@ -276,15 +260,10 @@ def run(self, file: str | Path | None = None) -> None: self._run_server_only() return - # Step 4: Create and run the GUI - # (missing-config proposal lives in gui/window_actions.py for the - # Tkinter GUI and in the web frontend's config modal + the - # /api/config/create endpoint for the web backend.) - self._logger.info(f"Selected GUI backend: {self._gui_backend}") - if self._gui_backend == "web": - self._run_web_gui() - else: - self._run_gui() + # Step 4 (continued): Launch the web GUI in the desktop shell. + # (Missing-config proposal lives in the web frontend's config modal + # and the /api/config/create endpoint.) + self._run_web_gui() def _run_server_only(self, port: int = _DEFAULT_PORT, host: str | None = None) -> None: """Run only the FastAPI background server without opening PyWebView. @@ -429,43 +408,6 @@ def _report_server_start_failure(self, port: int, exc: Exception) -> None: ) raise SystemExit(1) from exc - def _run_gui(self) -> None: - """Create and run the GUI. - - Handles GUI creation and error cases (e.g., no display available). - tkinter is imported lazily so the web backend and headless mode work - on Python distributions without Tk (e.g., Briefcase embeddable bundles). - - Exits: - SystemExit(1) if tkinter is unavailable or GUI initialization fails. - """ - try: - import tkinter as tk - - from profiles.gui.main_window import MainWindow - except ImportError: - print(_TKINTER_UNAVAILABLE_MSG, file=sys.stderr) - sys.exit(1) - try: - self._window = MainWindow(config=self._config) - except (RuntimeError, tk.TclError) as exc: - error_msg = str(exc).lower() - if "screen" in error_msg or "display" in error_msg: - events.app_gui_failed( - self._logger, - error="running in headless environment. Use --headless for CLI mode.", - ) - print( - "ERROR: No display available. Use 'profiles --headless' for CLI mode.", - file=sys.stderr, - ) - else: - events.app_gui_failed(self._logger, error=str(exc)) - print(f"ERROR: Failed to create GUI: {exc}", file=sys.stderr) - sys.exit(1) - - self._window.run() - def run_headless(self, file_path: str | Path | None = None) -> int: """Run in headless/CLI mode for automated file launching. @@ -601,9 +543,7 @@ def main() -> None: in pyproject.toml. Usage: - profiles Launch GUI with web backend (default) - profiles --web Launch GUI with web backend - profiles --tkinter Launch GUI with Tkinter backend + profiles Launch the web GUI (default) profiles --config path/.profiles Launch with explicit config profiles --headless Headless: scan & launch all profiles --headless file.mttl Launch specific file @@ -614,9 +554,7 @@ def main() -> None: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - profiles Launch GUI with web backend (default) - profiles --web Launch GUI with web backend - profiles --tkinter Deprecated Tkinter GUI (developer use only) + profiles Launch the web GUI (default) profiles --config /path/to/.profiles Use explicit config file profiles --headless Headless: scan & launch all profiles --headless file.mttl Headless: launch specific file @@ -660,16 +598,6 @@ def main() -> None: action="store_true", help="Do not offer to create a .profiles file at startup", ) - parser.add_argument( - "--tkinter", - action="store_true", - help="Use Tkinter GUI backend (deprecated; developer use only, will be removed)", - ) - parser.add_argument( - "--web", - action="store_true", - help="Use web GUI backend (default)", - ) parser.add_argument( "file", nargs="?", @@ -687,21 +615,11 @@ def main() -> None: init_default_config() return - # Determine GUI backend from arguments - if args.tkinter: - gui_backend = "tk" - print(_TKINTER_DEPRECATION_BANNER) - elif args.web: - gui_backend = "web" - else: - gui_backend = "web" # Default to web - app = ProFileApp( config_path=args.config, headless=args.headless, server_only=args.server_only, no_config_propose=args.no_config_propose, - gui_backend=gui_backend, host=args.host, ) app.run(file=args.file) diff --git a/src/profiles/gui/__init__.py b/src/profiles/gui/__init__.py deleted file mode 100644 index 2aebfab..0000000 --- a/src/profiles/gui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""GUI modules for ProFiles.""" diff --git a/src/profiles/gui/context_menu.py b/src/profiles/gui/context_menu.py deleted file mode 100644 index b5dd2ce..0000000 --- a/src/profiles/gui/context_menu.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Context menu helper for MainWindow.""" - -# pylint: disable=protected-access - -from __future__ import annotations - -import time -import tkinter as tk -from functools import partial -from pathlib import Path -from tkinter import messagebox -from typing import TYPE_CHECKING - -from profiles.core import actions -from profiles.core.actions import ( - ActionStatus, - open_terminal_in_directory, - reveal_in_file_manager, -) -from profiles.core.processing.file_classifier import ensure_trailing_separator -from profiles.core.processing.file_metadata import get_file_metadata -from profiles.core.telemetry import events -from profiles.gui.i18n import t -from profiles.utils.file_utils import hash_file, open_file_explorer -from profiles.utils.network import get_username - -if TYPE_CHECKING: - from profiles.gui.main_window import MainWindow - - -class FileContextMenu: - """Helper class to build and manage the context menu for MainWindow.""" - - def __init__(self, window: MainWindow) -> None: - """Initialize the context menu helper. - - Args: - window: The parent MainWindow instance. - """ - self.window = window - - def _make_menu(self, parent: tk.Misc) -> tk.Menu: - """Build a tk.Menu themed to match the active palette. - - Args: - parent: Parent widget (root for the top menu, parent menu for submenus). - - Returns: - A themed ``tk.Menu`` instance. - """ - theme = self.window._theme - return tk.Menu( - parent, - tearoff=0, - bg=theme.surface, - fg=theme.on_surface, - activebackground=theme.primary, - activeforeground=theme.on_primary, - bd=1, - relief="flat", - ) - - def selected_file_path(self) -> Path | None: - """Resolve the full filesystem path of the currently selected file. - - Returns: - The full path to the selected file, or None if the selection - is empty or the directory has not been populated. - """ - selection = self.window._tree.selection() - if not selection: - return None - iid = selection[0] - # Authoritative path is kept in _tree_to_path when the scan worker - # populates the tree. Fall back to reconstructing from the combobox - # value for callers that insert rows directly (e.g. tests). - cached = self.window._tree_to_path.get(iid) - if cached is not None: - return cached - values = self.window._tree.item(iid, "values") - if not values: - return None - directory = self.window._dir_var.get().strip() - if not directory: - return None - return Path(ensure_trailing_separator(directory)) / values[0] - - def on_tree_right_click(self, event: tk.Event) -> None: - """Show the right-click context menu for the row under the cursor. - - Args: - event: The Tkinter event triggering the context menu. - """ - row = self.window._tree.identify_row(event.y) - if row: - # Select the row that was right-clicked (without clearing the - # current multi-selection if multi-select is ever enabled). - self.window._tree.selection_set(row) - self.window._tree.focus(row) - else: - # Right-clicked empty area → bail out, do not show a menu. - return - - file_path = self.selected_file_path() - if file_path is None: - return - - menu = self._make_menu(self.window._root) - - # --- Top: primary action ------------------------------------ - menu.add_command( - label=f"▶ {t('menu.launch')} {file_path.name}", - command=partial(self.action_launch, file_path), - ) - menu.add_command( - label=f"🚀 {t('menu.launch_args')}", - command=partial(self.action_launch_with_args, file_path), - ) - menu.add_separator() - - # --- Open folder -------------------------------------------- - menu.add_command( - label=f"📂 {t('menu.reveal')}", - command=partial(self.action_reveal, file_path), - ) - menu.add_command( - label=f"📁 {t('menu.open_folder')}", - command=partial(self.action_open_folder, file_path), - ) - menu.add_command( - label=f"🖥 {t('menu.terminal')}", - command=partial(self.action_open_terminal, file_path), - ) - menu.add_separator() - - # --- Filter -------------------------------------------- - menu.add_command( - label=f"🔎 {t('menu.filter_folder')}", - command=partial(self.action_filter_to_folder, file_path), - ) - menu.add_command( - label=f"🔎 {t('menu.filter_extension')}", - command=partial(self.action_filter_by_extension, file_path), - ) - menu.add_separator() - - # --- Copy actions ------------------------------------------- - copy_menu = self._make_menu(menu) - copy_menu.add_command( - label=t("menu.copy.full"), - command=partial(self.action_copy_path, file_path), - ) - copy_menu.add_command( - label=t("menu.copy.forward"), - command=partial(self.action_copy_path, file_path, forward_slashes=True), - ) - copy_menu.add_command( - label=t("menu.copy.name_w_ext"), - command=partial(self.action_copy_name, file_path, with_ext=True), - ) - copy_menu.add_command( - label=t("menu.copy.name_wo_ext"), - command=partial(self.action_copy_name, file_path, with_ext=False), - ) - copy_menu.add_command( - label=t("menu.copy.directory"), - command=partial(self.action_copy, str(file_path.parent)), - ) - copy_menu.add_separator() - copy_menu.add_command( - label=f"🔗 {t('menu.copy.uri')}", - command=partial(self.action_copy_uri, file_path), - ) - menu.add_cascade(label=f"📋 {t('menu.copy')}", menu=copy_menu) - - # --- Hash (compute + show + verify) -------------------------- - hash_menu = self._make_menu(menu) - hash_menu.add_command( - label=t("menu.hash.md5"), - command=partial(self.action_hash, file_path, "md5"), - ) - hash_menu.add_command( - label=t("menu.hash.sha256"), - command=partial(self.action_hash, file_path, "sha256"), - ) - hash_menu.add_separator() - hash_menu.add_command( - label=t("menu.hash.copy_md5"), - command=partial(self.action_hash, file_path, "md5", copy_only=True), - ) - hash_menu.add_command( - label=t("menu.hash.copy_sha256"), - command=partial(self.action_hash, file_path, "sha256", copy_only=True), - ) - hash_menu.add_separator() - hash_menu.add_command( - label=f"✅ {t('menu.hash.verify_md5')}", - command=partial(self.action_verify_hash, file_path, "md5"), - ) - hash_menu.add_command( - label=f"✅ {t('menu.hash.verify_sha256')}", - command=partial(self.action_verify_hash, file_path, "sha256"), - ) - menu.add_cascade(label=f"# {t('menu.hash')}", menu=hash_menu) - menu.add_separator() - - # --- Info + utility ------------------------------------------ - menu.add_command( - label=f"ℹ {t('menu.properties')}", - command=partial(self.action_properties, file_path), - ) - menu.add_command( - label=f"🗑 {t('menu.delete')}", - command=partial(self.action_clear_file, file_path), - ) - menu.add_command( - label=f"🔄 {t('menu.refresh_list')}", - command=self.window._refresh_file_list, - ) - - try: - menu.tk_popup(event.x_root, event.y_root) - finally: - menu.grab_release() - - def action_launch(self, file_path: Path) -> None: - """Launch the given file using the OS default association.""" - result = actions.launch_selected_file( - directory=str(file_path.parent), - filename=file_path.name, - release=self.window._config.release, - username=get_username(), - logger=self.window._logger, - config=self.window._config, - ) - if result.status is ActionStatus.NOT_FOUND: - events.file_launch_failed( - self.window._logger, - path=str(file_path), - error=result.message, - ) - messagebox.showwarning( - "File Not Found", - f"The selected file does not exist:\n{file_path}", - ) - return - if result.status is ActionStatus.SUCCESS: - if self.window._close_var.get(): - self.window._root.after(500, self.window._on_close) - return - events.file_launch_failed( - self.window._logger, - path=str(file_path), - error=result.message, - ) - messagebox.showerror("Execution Error", result.message) - - def action_launch_with_args(self, file_path: Path) -> None: - """Prompt for arguments then launch *file_path* with them. - - Delegates to :meth:`MainWindow._action_launch_with_args` so the - dialog and action-result presentation stay consistent with the - rest of the GUI. - """ - self.window._action_launch_with_args(file_path) - - def action_open_folder(self, file_path: Path) -> None: - """Open the directory that contains *file_path*.""" - parent = file_path.parent - if not parent.is_dir(): - events.external_opened( - self.window._logger, - kind="folder", - path=str(parent), - status="rejected", - reason="not_found", - ) - messagebox.showwarning( - "Folder Not Found", - f"The folder does not exist:\n{parent}", - ) - return - if not open_file_explorer(parent): - events.external_opened( - self.window._logger, - kind="folder", - path=str(parent), - status="failed", - error="open_file_explorer returned False", - ) - messagebox.showerror( - "Open Folder Error", - f"Failed to open folder:\n{parent}", - ) - return - events.external_opened( - self.window._logger, - kind="folder", - path=str(parent), - status="ok", - ) - - def action_reveal(self, file_path: Path) -> None: - """Reveal the file in the OS file explorer.""" - result = reveal_in_file_manager(file_path) - if result.status is ActionStatus.NOT_FOUND: - events.file_revealed( - self.window._logger, - path=str(file_path), - status="failed", - error=result.message, - ) - messagebox.showwarning("File Not Found", result.message) - return - if result.status is ActionStatus.FAILED: - events.file_revealed( - self.window._logger, - path=str(file_path), - status="failed", - error=result.message, - ) - # Last-resort fallback: open the parent folder. - open_file_explorer(file_path.parent) - return - events.file_revealed( - self.window._logger, - path=str(file_path), - status="ok", - ) - - def action_copy(self, value: str) -> None: - """Copy *value* to the system clipboard.""" - self.window._root.clipboard_clear() - self.window._root.clipboard_append(value) - - def action_copy_path(self, file_path: Path, *, forward_slashes: bool = False) -> None: - """Copy the full file path to the clipboard.""" - text = str(file_path) - if forward_slashes: - text = text.replace("\\", "/") - self.action_copy(text) - - def action_copy_name(self, file_path: Path, *, with_ext: bool = True) -> None: - """Copy the file name (with or without extension) to the clipboard.""" - self.action_copy(file_path.name if with_ext else file_path.stem) - - def action_properties(self, file_path: Path) -> None: - """Show a properties dialog for the file (size, dates, path).""" - if not file_path.exists(): - messagebox.showwarning( - "File Not Found", - f"The selected file does not exist:\n{file_path}", - ) - return - try: - meta = get_file_metadata(file_path) - except (OSError, ValueError) as exc: - messagebox.showerror("Properties Error", f"Failed to read file info:\n{exc}") - return - - size_kb = meta["size_bytes"] / 1024 - text = ( - f"File: {meta['name']}\n" - f"Path: {meta['path']}\n\n" - f"Size: {meta['size_bytes']:,} bytes ({size_kb:.1f} KB)\n" - f"Modified: {meta['modified']}\n" - f"Created: {meta['created']}" - ) - messagebox.showinfo("File Properties", text) - - def action_filter_to_folder(self, file_path: Path) -> None: - """Switch the directory combobox to the file's parent folder.""" - parent = file_path.parent - # _dir_var holds a formatted combobox label (e.g. "📁 base"); resolve - # it through DirectoryManager to compare against a real path. - current_paths = self.window._dir_manager.resolve( - self.window._dir_var.get(), - ) - current = Path(current_paths[0]) if current_paths else None - if current is not None and parent == current: - events.filter_rejected( - self.window._logger, - kind="folder", - reason="already_active", - value=str(parent), - ) - return - if not parent.is_dir(): - events.filter_rejected( - self.window._logger, - kind="folder", - reason="not_found", - value=str(parent), - ) - messagebox.showwarning( - "Folder Not Found", - f"The folder does not exist:\n{parent}", - ) - return - self.window._dir_var.set(str(parent)) - self.window._apply_config_overrides() - self.window._refresh_file_list() - events.filter_changed( - self.window._logger, - kind="folder", - value=str(parent), - ) - - def action_hash(self, file_path: Path, algorithm: str, *, copy_only: bool = False) -> None: - """Compute the file's hash, show a dialog, and optionally copy it.""" - if not file_path.exists(): - events.hash_computed( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="rejected", - reason="not_found", - ) - messagebox.showwarning( - "File Not Found", - f"The selected file does not exist:\n{file_path}", - ) - return - start = time.perf_counter() - try: - digest = hash_file(file_path, algorithm) - except (OSError, ValueError) as exc: - events.hash_computed( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="failed", - error=str(exc), - ) - messagebox.showerror("Hash Error", f"Failed to hash file:\n{exc}") - return - duration_ms = (time.perf_counter() - start) * 1000 - events.hash_computed( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="ok", - duration_ms=duration_ms, - ) - - if copy_only: - self.action_copy(digest) - return - - messagebox.showinfo( - f"{algorithm.upper()} Hash", - f"File: {file_path.name}\nPath: {file_path}\n\n{algorithm.upper()}: {digest}", - ) - - def action_copy_uri(self, file_path: Path) -> None: - """Copy a ``file://`` URI to the clipboard for the file.""" - try: - uri = file_path.as_uri() - except ValueError as exc: - messagebox.showerror("URI Error", f"Cannot build URI:\n{exc}") - return - self.action_copy(uri) - - def action_filter_by_extension(self, file_path: Path) -> None: - """Set the extension filter to ``.`` and re-scan the current folder.""" - ext = file_path.suffix - if not ext: - events.filter_rejected( - self.window._logger, - kind="extension", - reason="no_extension", - value=file_path.name, - ) - messagebox.showwarning( - "No Extension", - f"Selected file has no extension:\n{file_path.name}", - ) - return - self.window._ext_var.set(ext) - self.window._refresh_file_list() - events.filter_changed( - self.window._logger, - kind="extension", - value=ext, - ) - - def action_verify_hash(self, file_path: Path, algorithm: str) -> None: - """Compute the file's *algorithm* hash and compare it to the clipboard.""" - if not file_path.exists(): - events.hash_verified( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="rejected", - reason="not_found", - ) - messagebox.showwarning( - "File Not Found", - f"The selected file does not exist:\n{file_path}", - ) - return - try: - expected_clip = self.window._root.clipboard_get().strip() - except tk.TclError: - expected_clip = "" - if not expected_clip: - events.hash_verified( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="rejected", - reason="empty_clipboard", - ) - messagebox.showinfo( - f"Verify {algorithm.upper()}", - "Clipboard is empty — copy a hash first, then try again.", - ) - return - try: - digest = hash_file(file_path, algorithm) - except (OSError, ValueError) as exc: - events.hash_verified( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - status="failed", - error=str(exc), - ) - messagebox.showerror("Hash Error", f"Failed to hash file:\n{exc}") - return - - match = digest.casefold() == expected_clip.casefold() - events.hash_verified( - self.window._logger, - algorithm=algorithm, - path=str(file_path), - match=match, - ) - if match: - messagebox.showinfo( - f"Verify {algorithm.upper()}", - f"✅ Match!\n\n{algorithm.upper()}: {digest}", - ) - else: - messagebox.showerror( - f"Verify {algorithm.upper()}", - f"❌ Mismatch\n\nFile: {digest}\nClipboard: {expected_clip}", - ) - - def action_open_terminal(self, file_path: Path) -> None: - """Open a terminal session in the file's parent directory.""" - result = open_terminal_in_directory(file_path.parent) - if result.status is ActionStatus.SUCCESS: - events.external_opened( - self.window._logger, - kind="terminal", - path=str(file_path.parent), - status="ok", - ) - return - if result.status is ActionStatus.NOT_FOUND: - events.external_opened( - self.window._logger, - kind="terminal", - path=str(file_path.parent), - status="rejected", - ) - messagebox.showwarning("Folder Not Found", result.message) - return - events.external_opened( - self.window._logger, - kind="terminal", - path=str(file_path.parent), - status="failed", - error=result.message, - ) - messagebox.showerror("Open Terminal Error", result.message) - - def action_clear_file(self, file_path: Path) -> None: - """Delete the given file from the filesystem.""" - if not file_path.exists(): - messagebox.showwarning( - "File Not Found", - f"The selected file does not exist:\n{file_path}", - ) - return - - # Confirm before deleting - if not messagebox.askyesno( - "Delete File", - f"Are you sure you want to delete this file?\n\n" - f"{file_path}\n\n" - f"This action cannot be undone.", - ): - return - - try: - file_path.unlink() - events.file_deleted(self.window._logger, path=str(file_path)) - messagebox.showinfo( - "File Deleted", - f"File deleted successfully:\n{file_path}", - ) - self.window._refresh_file_list() - except OSError as exc: - events.file_delete_failed( - self.window._logger, - path=str(file_path), - error=str(exc), - ) - messagebox.showerror( - "Delete File Error", - f"Failed to delete file:\n{file_path}\n\n{exc}", - ) diff --git a/src/profiles/gui/controllers/__init__.py b/src/profiles/gui/controllers/__init__.py deleted file mode 100644 index e6f55a4..0000000 --- a/src/profiles/gui/controllers/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Controllers — orchestrate widgets with core actions. - -Tk-dependent, no I/O knowledge. Each controller encapsulates a cluster -of related methods that previously lived on :class:`MainWindow`. -""" - -from profiles.gui.controllers.directory_manager import ( - DirectoryManager, - format_dir_entry, - strip_dir_label, -) -from profiles.gui.controllers.scan_controller import ( - ScanQueue, - ScanResult, - run_scan, -) -from profiles.gui.controllers.window_actions import WindowActions - -__all__ = [ - "DirectoryManager", - "ScanQueue", - "ScanResult", - "WindowActions", - "format_dir_entry", - "run_scan", - "strip_dir_label", -] diff --git a/src/profiles/gui/controllers/directory_manager.py b/src/profiles/gui/controllers/directory_manager.py deleted file mode 100644 index 2fb9dde..0000000 --- a/src/profiles/gui/controllers/directory_manager.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Directory combobox manager — populates, formats, resolves, auto-selects. - -Owns the directory / extension / filter combobox state. Pure logic -delegates to ``profiles.core.config.service``; widget access is -limited to the combobox + vars passed in by the view. - -Decoupled from MainWindow via a small :class:`DirectoryView` protocol -documented below. -""" - -from __future__ import annotations - -import logging -import tkinter as tk -from pathlib import Path -from tkinter import ttk - -from profiles.core.config import service as config_service -from profiles.core.config.loader import load_config -from profiles.core.config.models import AppConfig, MachineConfiguration - -# Icon prefixes used in combobox display strings. -_FOLDER_ICON = "📁" -_FILE_ICON = "📄" -_PATH_COUNT_SUFFIX_PATTERN = " (N paths)" - - -class DirectoryView: - """Adapter exposing the widgets DirectoryManager touches. - - Documented as a Protocol — no runtime cost. MainWindow implements - this. - """ - - # ── directory combobox ──────────────────────────────────────── - _dir_combo: ttk.Combobox - _dir_var: tk.StringVar - # ── extension / filter comboboxes (for apply_config_overrides) ─ - _ext_combo: ttk.Combobox - _ext_var: tk.StringVar - _filter_combo: ttk.Combobox - _filter_var: tk.StringVar - # ── view deps ───────────────────────────────────────────────── - _config: AppConfig - _logger: logging.Logger - - -def format_dir_entry(entry: config_service.DirectoryEntry) -> str: - """Render a :class:`DirectoryEntry` as a combobox display string. - - Format: ``{icon} {label}`` with an optional ``(N paths)`` suffix - for multi-path config groups. - """ - icon = entry.icon - if icon == _FOLDER_ICON and len(entry.paths) > 1: - return f"{icon} {entry.label} ({len(entry.paths)} paths)" - return f"{icon} {entry.label}" - - -def strip_dir_label(raw: str) -> str: - """Strip the icon prefix and the ``(N paths)`` suffix from a label. - - Inverse of :func:`format_dir_entry`. - """ - for prefix in (f"{_FOLDER_ICON} ", f"{_FILE_ICON} "): - if raw.startswith(prefix): - label = raw[len(prefix) :] - break - else: - label = raw - if label.endswith(")"): - idx = label.rfind(" (") - if idx != -1: - label = label[:idx] - return label.strip() - - -class DirectoryManager: - """Owns the directory / extension / filter combobox state.""" - - def __init__(self, view: DirectoryView, hostname: str) -> None: - self._view = view - self._hostname = hostname - - # ── directory combobox ──────────────────────────────────────── - - def populate(self) -> None: - """Populate the directory combobox from configurations.""" - entries = config_service.get_directory_combobox_values(self._view._config) - self._view._dir_combo["values"] = [format_dir_entry(e) for e in entries] - - def resolve(self, label: str) -> list[str]: - """Resolve a combobox label to its list of scan paths. - - Falls back to ``[label]`` (treating it as a raw path) when no - config entry matches. - """ - entries = config_service.get_directory_combobox_values(self._view._config) - for entry in entries: - if entry.label == label: - return list(entry.paths) - return [label] - - def set_selection(self, label: str) -> None: - """Set the combobox to the entry matching *label*, else *label* itself.""" - entries = config_service.get_directory_combobox_values(self._view._config) - for entry in entries: - if entry.label == label: - self._view._dir_var.set(format_dir_entry(entry)) - return - self._view._dir_var.set(label) - - def auto_select(self) -> None: - """Auto-select the directory matching the current host. - - Sets the directory combobox to the matched label (formatted as - a known entry when applicable) or to the current working - directory as a fallback. - """ - config_path = self._view._config.config_path - if not config_path.exists(): - self._view._dir_var.set(str(Path.cwd())) - return - try: - fresh = load_config(config_path) - except (FileNotFoundError, OSError): - self._view._dir_var.set(str(Path.cwd())) - return - try: - matched = config_service.auto_select_directory(fresh, self._hostname) - except (AttributeError, OSError): - matched = None - if matched: - self.set_selection(matched) - else: - self._view._dir_var.set(str(Path.cwd())) - - def current_label(self) -> str: - """Return the current combobox selection, icon and count stripped.""" - return strip_dir_label(self._view._dir_var.get()) - - def find_active_config(self) -> MachineConfiguration | None: - """Find the config matching the currently selected directory.""" - return config_service.find_active_config(self._view._config, self.current_label()) - - # ── extensions / filters ────────────────────────────────────── - - def apply_config_overrides(self) -> None: - """Merge per-config extensions/filters with generic defaults.""" - merged_extensions, merged_filters = config_service.merge_config_overrides( - self._view._config, - self.current_label(), - ) - self._view._ext_combo["values"] = merged_extensions - self._view._ext_var.set(merged_extensions[0] if merged_extensions else "") - self._view._filter_combo["values"] = merged_filters - self._view._filter_var.set(merged_filters[0] if merged_filters else "") diff --git a/src/profiles/gui/controllers/scan_controller.py b/src/profiles/gui/controllers/scan_controller.py deleted file mode 100644 index a872134..0000000 --- a/src/profiles/gui/controllers/scan_controller.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Background scan worker — pure, no Tk, no widgets. - -Owns the thread-side logic for a single scan: invoke -``scanner.scan_and_process_dynamic`` and push the result (or error) to -a queue. The main thread drains the queue. - -Splitting this out lets us unit-test the worker without instantiating -Tk. The MainWindow still owns the queue, scan_id bookkeeping, and the -chunked main-thread insert. -""" - -from __future__ import annotations - -import logging -import queue -from typing import Any - -from profiles.core.config.models import AppConfig -from profiles.core.processing import scanner -from profiles.core.processing.scanner import ScannedFileDynamic -from profiles.core.telemetry import events - -# Tuple pushed onto the result queue: -# ("ok", scan_id, [ScannedFileDynamic, ...]) -# ("error", scan_id, None) -ScanResult = tuple[str, int, list[ScannedFileDynamic] | None] - - -def run_scan( - *, - config: AppConfig, - directory_label: str, - scan_paths: list[str], - extension: str, - filter_text: str, - recursive: bool, - queue_: queue.Queue[ScanResult], - scan_id: int, - logger: logging.Logger, -) -> None: - """Execute a single scan off the main thread; push result to ``queue_``. - - Intended to be the target of a ``threading.Thread``. Pure: reads - only ``config`` and the captured ``scan_paths``/``filter_text``/ - etc. Writes only to ``queue_`` and ``logger``. - - Args: - config: The application configuration (read for exclude lists). - directory_label: Captured at request time; used to pick the - active machine config (which contributes per-config settings). - scan_paths: Directories to scan (multiple supported). - extension: Extension filter expression. - filter_text: Keyword filter. - recursive: Whether to recurse into subdirectories. - queue_: Shared queue drained by the main thread. - scan_id: Identifies this scan; the main thread discards results - whose scan_id has been superseded. - logger: Standard logger for diagnostics. - """ - try: - items = scanner.scan_and_process_dynamic( - scan_paths, - extension=extension, - filter_text=filter_text, - recursive=recursive, - column_names=config.column_names, - columns=config.columns, - config=config, - ) - queue_.put(("ok", scan_id, items)) - except Exception as exc: # pylint: disable=broad-except - events.scan_failed(logger, directory="background", error=str(exc)) - queue_.put(("error", scan_id, None)) - - -class ScanQueue: - """Thin wrapper around ``queue.Queue`` with a typed ``try_dequeue``. - - Extracted so tests can substitute a fake queue without touching the - real threading primitives. - """ - - def __init__(self) -> None: - self._q: queue.Queue[ScanResult] = queue.Queue() - - def put(self, result: ScanResult) -> None: - self._q.put(result) - - def try_dequeue(self) -> tuple[bool, ScanResult | None]: - """Return ``(True, item)`` if available, else ``(False, None)``.""" - try: - return True, self._q.get_nowait() - except queue.Empty: - return False, None - - -# Silence unused-import lint for Any (kept for forward-compat typing). -_ = Any -__all__ = ["run_scan", "ScanQueue", "ScanResult"] diff --git a/src/profiles/gui/controllers/window_actions.py b/src/profiles/gui/controllers/window_actions.py deleted file mode 100644 index fe85c8f..0000000 --- a/src/profiles/gui/controllers/window_actions.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Top-level window actions: open config, open log, restart. - -These actions use :mod:`profiles.core.actions` for the business logic and -:messsagebox for user-facing dialogs. Kept separate from MainWindow to -keep the class focused on widget orchestration. -""" - -from __future__ import annotations - -import logging -import subprocess -import sys -from pathlib import Path -from tkinter import messagebox - -from profiles.core import actions -from profiles.core.config.models import AppConfig -from profiles.core.telemetry import events - -__all__ = ["WindowActions"] - - -class WindowActions: - """Top-level window actions for config, log, and restart.""" - - def __init__( - self, - config: AppConfig, - logger: logging.Logger, - root_destroy: object, - ) -> None: - self._config = config - self._logger = logger - self._root_destroy = root_destroy # () -> None - - def open_config(self) -> None: - """Open the configuration file with the default text editor. - - When the ``.profiles`` file does not exist, offer to generate a - documented starter file in the current working directory. - """ - config_path = self._config.config_path - - if not config_path.exists(): - cwd_label = str(Path.cwd()) - prompt = ( - f"No configuration file was found at:\n{config_path}\n\n" - "Would you like to generate a starter .profiles file in the\n" - f"current working directory ({cwd_label})?\n\n" - "The starter is fully commented and ready to edit." - ) - if not messagebox.askyesno( - "Configuration File Missing", - prompt, - ): - return - - write_result = actions.write_starter_config( - config_path, - logger=self._logger, - ) - if write_result.status != actions.ActionStatus.SUCCESS: - messagebox.showerror( - write_result.message, - write_result.message, - ) - return - - messagebox.showinfo( - "Starter Configuration Created", - write_result.message, - ) - - result = actions.open_config_file( - config_path, - logger=self._logger, - ) - if result.status != actions.ActionStatus.SUCCESS: - messagebox.showerror(result.message, result.message) - - def open_log(self, log_path: Path) -> None: - """Open the log file with the default text editor.""" - result = actions.open_log_file(log_path, logger=self._logger) - if result.status != actions.ActionStatus.SUCCESS: - messagebox.showerror(result.message, result.message) - - def restart(self) -> None: - """Destroy the current window and launch a fresh instance.""" - events.app_restarting(self._logger) - self._root_destroy() - - python_executable = sys.executable - try: - subprocess.Popen([str(python_executable), "-m", "profiles"]) - events.app_launched(self._logger, command=f"{python_executable} -m profiles") - except OSError as exc: - events.app_gui_failed(self._logger, error=f"restart failed: {exc}") - messagebox.showerror( - "Restart Failed", - f"Could not automatically restart the application.\n\n" - f"Error: {exc}\n\n" - "Please restart manually by running ProFiles again.", - ) diff --git a/src/profiles/gui/i18n.py b/src/profiles/gui/i18n.py deleted file mode 100644 index e4b4306..0000000 --- a/src/profiles/gui/i18n.py +++ /dev/null @@ -1,263 +0,0 @@ -"""GUI i18n catalog and live re-label registry. - -Single Responsibility: own the English/French translation strings and -expose a small callback registry that GUI widgets register with so a -language switch re-labels the whole window without rebuilding it. - -Scope of translations (per design doc): GUI chrome only — buttons, -menus, status labels, tooltips, dialog chrome. Logs, error dialogs, and -internal exception messages stay English. -""" - -from __future__ import annotations - -from collections.abc import Callable - -SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "fr") - -LANGUAGE_LABELS: dict[str, str] = { - "en": "\U0001f310 EN", - "fr": "\U0001f310 FR", -} - -# The full catalog. Every key that appears here must also exist in EN -# (so a missing key always has a fallback). Any new translatable string -# must be added in BOTH languages in the same commit. -STRINGS: dict[str, dict[str, str]] = { - "en": { - "title.tooltip": "ProFiles — Production Test Program Launcher", - "search.dir_label": "Directory:", - "search.dir.tooltip": "Select or type a directory path", - "search.browse": "\U0001f4c1 Browse", - "search.browse.tooltip": "Browse for a directory", - "search.recursive": "Recursive", - "search.recursive.tooltip": "Search files in subdirectories recursively", - "search.search_btn": "\U0001f50d Search", - "search.search_btn.tooltip": "Scan the selected directory", - "search.ext_label": "Extension:", - "search.ext.tooltip": 'Filter by extension with operators: AND (space), OR, NOT (-), exact ("...")', - "search.filter_label": "Filter:", - "search.filter.tooltip": 'Filter with operators: AND (space), OR, NOT (-), exact ("...")', - "status.config": "\u2699 Config", - "status.config.tooltip": "Open configuration file", - "status.refresh": "\U0001f504 Refresh", - "status.refresh.tooltip": "Reload configuration and refresh file list", - "status.log": "\U0001f4c4 Log", - "status.log.tooltip": "Open log file", - "status.shortcuts": "\u2328 Shortcuts", - "status.shortcuts.tooltip": "Show keyboard shortcuts (?)", - "status.language.tooltip": "Switch GUI language", - "status.theme.tooltip": "Toggle between light and dark theme", - "status.user": "User:", - "status.user.tooltip": "Current username", - "status.host": "Host:", - "status.host.tooltip": "Current machine hostname", - "status.ip": "IP:", - "status.ip.tooltip": "Current machine IP address", - "status.count": "Files:", - "status.count.tooltip": "Number of files matching current filters", - "status.dir_status.tooltip": "Current directory and scan status", - "action.close_after": "Close after execution", - "action.close_after.tooltip": "Close ProFiles after launching a file", - "action.execute": "\u25b6 Execute", - "action.execute.empty": "\u25b6 Execute (select a file first)", - "action.execute.no_match": "\u25b6 Execute (no matching file)", - "shortcuts.title": "Keyboard Shortcuts", - "shortcuts.close": "Close", - "menu.refresh": "Refresh", - "menu.config": "Open .profiles", - "menu.log": "Open log file", - "menu.shortcuts": "Keyboard shortcuts", - "menu.exit": "Exit", - "menu.launch": "Launch", - "menu.launch_args": "Launch with arguments…", - "menu.reveal": "Reveal in file explorer", - "menu.open_folder": "Open containing folder", - "menu.terminal": "Open terminal here", - "menu.filter_folder": "Filter list to this folder", - "menu.filter_extension": "Filter list by this extension", - "menu.copy": "Copy", - "menu.copy.full": "Full file path", - "menu.copy.forward": "File path (forward slashes)", - "menu.copy.name_w_ext": "File name with extension", - "menu.copy.name_wo_ext": "File name without extension", - "menu.copy.directory": "Directory path", - "menu.copy.uri": "Copy as URI", - "menu.hash": "Hash", - "menu.hash.md5": "MD5", - "menu.hash.sha256": "SHA-256", - "menu.hash.copy_md5": "Copy MD5", - "menu.hash.copy_sha256": "Copy SHA-256", - "menu.hash.verify_md5": "Verify MD5 against clipboard", - "menu.hash.verify_sha256": "Verify SHA-256 against clipboard", - "menu.properties": "Properties", - "menu.delete": "Delete file", - "menu.refresh_list": "Refresh list", - "count_patterns.scanning": "Scanning...", - "count_patterns.dir_not_found": "Directory not found", - "count_patterns.scan_failed": "Scan failed", - }, - "fr": { - "title.tooltip": "ProFiles — Lanceur de programmes de test", - "search.dir_label": "Répertoire :", - "search.dir.tooltip": "Saisir ou choisir un répertoire", - "search.browse": "\U0001f4c1 Parcourir", - "search.browse.tooltip": "Parcourir les répertoires", - "search.recursive": "Récursif", - "search.recursive.tooltip": "Rechercher dans les sous-répertoires", - "search.search_btn": "\U0001f50d Rechercher", - "search.search_btn.tooltip": "Scanner le répertoire sélectionné", - "search.ext_label": "Extension :", - "search.ext.tooltip": 'Filtrer par extension : AND (espace), OR, NOT (-), exact ("...")', - "search.filter_label": "Filtre :", - "search.filter.tooltip": 'Filtrer : AND (espace), OR, NOT (-), exact ("...")', - "status.config": "\u2699 Config", - "status.config.tooltip": "Ouvrir le fichier de configuration", - "status.refresh": "\U0001f504 Actualiser", - "status.refresh.tooltip": "Recharger la configuration et la liste", - "status.log": "\U0001f4c4 Journal", - "status.log.tooltip": "Ouvrir le journal", - "status.shortcuts": "\u2328 Raccourcis", - "status.shortcuts.tooltip": "Afficher les raccourcis clavier (?)", - "status.language.tooltip": "Changer la langue de l'interface", - "status.theme.tooltip": "Basculer entre thème clair et sombre", - "status.user": "Utilisateur :", - "status.user.tooltip": "Nom d'utilisateur courant", - "status.host": "Hôte :", - "status.host.tooltip": "Nom de la machine", - "status.ip": "IP :", - "status.ip.tooltip": "Adresse IP de la machine", - "status.count": "Fichiers :", - "status.count.tooltip": "Nombre de fichiers correspondant aux filtres", - "status.dir_status.tooltip": "Répertoire courant et état du scan", - "action.close_after": "Fermer après exécution", - "action.close_after.tooltip": "Fermer ProFiles après le lancement", - "action.execute": "\u25b6 Exécuter", - "action.execute.empty": "\u25b6 Exécuter (sélectionnez un fichier)", - "action.execute.no_match": "\u25b6 Exécuter (aucun fichier)", - "shortcuts.title": "Raccourcis clavier", - "shortcuts.close": "Fermer", - "menu.refresh": "Actualiser", - "menu.config": "Ouvrir .profiles", - "menu.log": "Ouvrir le journal", - "menu.shortcuts": "Raccourcis clavier", - "menu.exit": "Quitter", - "menu.launch": "Lancer", - "menu.launch_args": "Lancer avec des arguments…", - "menu.reveal": "Ouvrir dans l'explorateur", - "menu.open_folder": "Ouvrir le dossier contenant", - "menu.terminal": "Ouvrir le terminal ici", - "menu.filter_folder": "Filtrer la liste sur ce dossier", - "menu.filter_extension": "Filtrer la liste par cette extension", - "menu.copy": "Copier", - "menu.copy.full": "Chemin complet du fichier", - "menu.copy.forward": "Chemin (slash)", - "menu.copy.name_w_ext": "Nom avec extension", - "menu.copy.name_wo_ext": "Nom sans extension", - "menu.copy.directory": "Chemin du répertoire", - "menu.copy.uri": "Copier comme URI", - "menu.hash": "Empreinte", - "menu.hash.md5": "MD5", - "menu.hash.sha256": "SHA-256", - "menu.hash.copy_md5": "Copier le MD5", - "menu.hash.copy_sha256": "Copier le SHA-256", - "menu.hash.verify_md5": "Vérifier le MD5 dans le presse-papiers", - "menu.hash.verify_sha256": "Vérifier le SHA-256 dans le presse-papiers", - "menu.properties": "Propriétés", - "menu.delete": "Supprimer le fichier", - "menu.refresh_list": "Actualiser la liste", - "count_patterns.scanning": "Scan en cours...", - "count_patterns.dir_not_found": "Répertoire introuvable", - "count_patterns.scan_failed": "Échec du scan", - }, -} - -# --- Module-private state ------------------------------------------------ - -_current_language: str = "en" -_relabel_registry: list[Callable[[str], None]] = [] - - -def current_language() -> str: - """Return the active in-process language code (default ``"en"``).""" - return _current_language - - -def set_current_language(lang: str) -> None: - """Set the active language without notifying the registry. - - Use :func:`set_language` for the normal "switch and re-label" flow. - """ - global _current_language - _current_language = normalize_language(lang) - - -def normalize_language(value: str) -> str: - """Coerce *value* to a supported language code. - - Empty strings and unsupported codes fall back to ``"en"``. - Case-insensitive. - """ - if not value: - return "en" - candidate = value.strip().lower() - if candidate in SUPPORTED_LANGUAGES: - return candidate - return "en" - - -def t(key: str, lang: str | None = None) -> str: - """Look up *key* for *lang* (defaults to :func:`current_language`). - - Fallback order: requested language → English → raw key. - """ - effective = normalize_language(lang) if lang is not None else _current_language - table = STRINGS.get(effective, STRINGS["en"]) - if key in table: - return table[key] - en_table = STRINGS["en"] - if key in en_table: - return en_table[key] - return key - - -def register(fn: Callable[[str], None]) -> None: - """Register a re-label callback. Idempotent per callable. - - Re-registering the same ``fn`` replaces (does not duplicate) the - existing entry. Order of first registration is preserved. - """ - if fn in _relabel_registry: - return - _relabel_registry.append(fn) - - -def set_language(lang: str) -> None: - """Set the active language and notify every registered callback. - - The active language is updated first; then each registered callback - is invoked in registration order with the new language. - """ - new_lang = normalize_language(lang) - global _current_language - _current_language = new_lang - for fn in _relabel_registry: - fn(new_lang) - - -def _reset_for_tests() -> None: - """Clear the registry. Test-only — not part of the public API.""" - _relabel_registry.clear() - - -__all__ = [ - "LANGUAGE_LABELS", - "STRINGS", - "SUPPORTED_LANGUAGES", - "current_language", - "normalize_language", - "register", - "set_current_language", - "set_language", - "t", -] diff --git a/src/profiles/gui/main_window.py b/src/profiles/gui/main_window.py deleted file mode 100644 index aa74c12..0000000 --- a/src/profiles/gui/main_window.py +++ /dev/null @@ -1,1352 +0,0 @@ -"""Main window for ProFiles. - -Provides the primary GUI interface: a file browser with filtering -capabilities for selecting and launching production test programs. -""" - -from __future__ import annotations - -import contextlib -import importlib.resources -import logging -import platform -import queue -import re -import threading -import time -import tkinter as tk -from collections.abc import Callable -from pathlib import Path -from tkinter import filedialog, messagebox, ttk - -from profiles import __version__ -from profiles.core import actions -from profiles.core.config import service as config_service -from profiles.core.config.io.yaml_io import write_value -from profiles.core.config.loader import load_config -from profiles.core.config.models import AppConfig, MachineConfiguration -from profiles.core.environment import system -from profiles.core.processing import scanner -from profiles.core.processing.file_classifier import directory_exists -from profiles.core.telemetry import events -from profiles.gui.context_menu import FileContextMenu -from profiles.gui.controllers.directory_manager import ( - DirectoryManager, - format_dir_entry, -) -from profiles.gui.controllers.scan_controller import run_scan -from profiles.gui.controllers.window_actions import WindowActions -from profiles.gui.i18n import set_language -from profiles.gui.presentation.row_colors import RowColorRules, default_tag_name -from profiles.gui.status_bar import StatusBar -from profiles.gui.styles import ToolTip, configure_styles -from profiles.gui.theme import ( - THEME_LABELS, - THEMES, - Md3Theme, - contrast_ratio, - resolve_theme_name, -) -from profiles.gui.ui import MainWindowUI -from profiles.utils.file_utils import open_file_explorer - -#: Minimum WCAG contrast ratio for a row-colour foreground against the -#: theme surface. Below this the text is effectively unreadable, so the -#: configured colour is replaced with the theme's standard text colour. -_MIN_ROW_COLOR_CONTRAST = 1.5 - - -class MainWindow: - """Main application window for ProFiles. - - Provides a treeview-based file browser with filtering, directory - selection, and one-click execution of production test programs. - """ - - # Widgets and variables (built by MainWindowUI) - _header_frame: ttk.Frame - _search_frame: ttk.Frame - _title_frame: ttk.Frame - _title_label: ttk.Label - _title_author: ttk.Label - _controls_frame: ttk.Frame - _dir_frame: ttk.Frame - _dir_var: tk.StringVar - _dir_combo: ttk.Combobox - _browse_btn: ttk.Button - _filter_frame: ttk.Frame - _recursive_check: ttk.Checkbutton - _search_btn: ttk.Button - _ext_var: tk.StringVar - _ext_combo: ttk.Combobox - _filter_var: tk.StringVar - _filter_combo: ttk.Combobox - _list_container: ttk.Frame - _tree: ttk.Treeview - _sort_state: dict[int, str] - _v_scroll: ttk.Scrollbar - _h_scroll: ttk.Scrollbar - _action_frame: ttk.Frame - _close_var: tk.BooleanVar - _close_check: ttk.Checkbutton - _execute_btn: ttk.Button - _status_frame: ttk.Frame - _status_inner: ttk.Frame - _config_link: ttk.Button - _refresh_btn: ttk.Button - _log_link: ttk.Button - _shortcuts_btn: ttk.Button | None - _theme_btn: ttk.Button - _user_label: ttk.Label - _host_label: ttk.Label - _ip_label: ttk.Label - _count_label: ttk.Label - _dir_status_label: ttk.Label - _dir_status_tooltip: ToolTip | None - - _row_color_rules: list[tuple[str, str]] - _row_color_pattern_cache: list[tuple[re.Pattern, str]] # Compiled regex patterns - _status_bar: StatusBar # Set dynamically by ui.py _build_status_bar() - _row_color_tag_prefix: str - - def __init__(self, config: AppConfig) -> None: - """Initialize the main window. - - Args: - config: The application configuration. - """ - self._config = config - self._logger = logging.getLogger("profiles") - self._current_scan_id: int = 0 - self._scan_in_progress: bool = False - self._scan_queue: queue.Queue = queue.Queue() - self._filter_timer: str | None = None # Debounce timer for filter field - self._ext_timer: str | None = None # Debounce timer for extension field - self._start_time: float = 0.0 # Set in run() to compute APP_CLOSED uptime_s - self._row_color_rules: list[tuple[str, str]] = [] - self._row_color_tag_prefix = "_rowcolor" - self._tree_to_path: dict[str, Path] = {} # iid -> filesystem path - self._tree_to_filename: dict[str, str] = {} # iid -> filename (for log) - - # Build window - self._root = tk.Tk() - self._recursive_var = tk.BooleanVar(value=self._config.recursive_search) - self._root.title(f"ProFiles - {config.title}" if config.title else "ProFiles") - self._root.geometry("1000x700") - self._root.minsize(800, 500) - - # Try to set icon - self._try_set_icon() - - # Theme initialisation - self._theme_name: str = self._config.theme - resolved_name = resolve_theme_name(self._theme_name) - self._theme: Md3Theme = THEMES.get(resolved_name, THEMES["light"]) - configure_styles(self._root, resolved_name) - - # Language initialisation (before UI build so widgets are created - # in the configured language) - set_language(self._config.language) - - # Initialize helpers - self._ui = MainWindowUI(self) - self._context_menu = FileContextMenu(self) - - # Build the UI - self._ui.build() - - # Configure keyboard shortcuts - self._configure_bindings() - - # Populate initial data - self._load_system_info() # sets self._hostname early - self._dir_manager = DirectoryManager(view=self, hostname=self._hostname) - self._window_actions = WindowActions( - config=self._config, - logger=self._logger, - root_destroy=self._root.destroy, - ) - self._populate_directories() - self._populate_extensions() - self._populate_filters() - self._auto_select_directory() - self._apply_config_overrides() - self._configure_row_colors() - self._refresh_file_list() - - # Check if config file exists, propose creation if not - if not self._config.skip_config_prompt: - self._check_config_file() - - # Center on screen - self._center_window() - - # Protocol for window close - self._root.protocol("WM_DELETE_WINDOW", self._on_close) - - # ---------------------------------------------------------------- - # Window setup - # ---------------------------------------------------------------- - - def _try_set_icon(self) -> None: - """Try to set the window icon from available resources. - - Checks the current working directory first, then falls back to - packaged assets (when the package is installed with bundled - ``img/`` resources). - """ - cwd_ico = Path.cwd() / "img" / "launcher.ico" - cwd_png = Path.cwd() / "img" / "launcher.png" - if cwd_ico.exists(): - with contextlib.suppress(tk.TclError): - self._root.iconbitmap(str(cwd_ico)) - return - if cwd_png.exists(): - with contextlib.suppress(tk.TclError): - self._root.iconphoto(True, tk.PhotoImage(file=str(cwd_png))) - return - - # Fallback: look for packaged assets (no img/ shipped today; safe no-op) - try: - pkg_img = importlib.resources.files("profiles").joinpath("img") - pkg_ico = pkg_img.joinpath("launcher.ico") - if pkg_ico.is_file(): - with contextlib.suppress(tk.TclError): - self._root.iconbitmap(str(pkg_ico)) - return - pkg_png = pkg_img.joinpath("launcher.png") - if pkg_png.is_file(): - with contextlib.suppress(tk.TclError): - self._root.iconphoto(True, tk.PhotoImage(file=str(pkg_png))) - except (ImportError, AttributeError, OSError, TypeError): - pass - - def _center_window(self) -> None: - """Center the window on the screen.""" - self._root.update_idletasks() - width = self._root.winfo_width() - height = self._root.winfo_height() - screen_width = self._root.winfo_screenwidth() - screen_height = self._root.winfo_screenheight() - x = (screen_width - width) // 2 - y = (screen_height - height) // 2 - self._root.geometry(f"{width}x{height}+{x}+{y}") - - # ---------------------------------------------------------------- - # Sorting - # ---------------------------------------------------------------- - - def _sort_treeview(self, col_index: int) -> None: - """Sort the treeview rows by *col_index* (0=File, 1=Version). - - Optimized version: batch delete and reinsert using move for better performance - with large file lists (1000+ items). - """ - children = list(self._tree.get_children("")) - if not children: - return - - # Collect all items with their sort values before sorting - items_data = [] - for item_id in children: - val = self._tree.set(item_id, col_index) - sort_val = val.lower() if val else "" - items_data.append((sort_val, item_id)) - - # Determine direction - reverse = self._sort_state.get(col_index) == "asc" - self._sort_state[col_index] = "desc" if reverse else "asc" - - # Sort the collected data - items_data.sort(key=lambda x: x[0], reverse=reverse) - - # Batch move/reattach items in sorted order (much faster than delete + insert) - for _, item_id in items_data: - self._tree.move(item_id, "", tk.END) - - # Update heading indicators - for i, header in enumerate(self._config.column_headers): - arrow = "" - if i == col_index: - arrow = " \u25b2" if not reverse else " \u25bc" - self._tree.heading(i, text=header + arrow) - - # ---------------------------------------------------------------- - # Theme switching - # ---------------------------------------------------------------- - - def _on_toggle_theme(self) -> None: - """Toggle between light, dark, and auto themes.""" - if self._theme_name == "light": - new_name = "dark" - elif self._theme_name == "dark": - new_name = "auto" - else: - new_name = "light" - self._apply_theme(new_name) - - def _apply_theme(self, theme_name: str) -> None: - """Apply the named theme to the entire GUI.""" - resolved_name = resolve_theme_name(theme_name) - theme = THEMES.get(resolved_name, THEMES["light"]) - self._theme = theme - self._theme_name = theme_name - - # 1. Apply ttk styles + tk palette (single entry point) - configure_styles(self._root, resolved_name) - - # 2. Reconfigure row color tags against the (possibly new) theme palette - self._configure_row_colors() - - # 3. Update theme button text via status bar - if self._status_bar: - self._status_bar.update_theme_label(THEME_LABELS.get(theme_name, "\u2600 Light")) - - # 4. Persist to config - write_value( - self._config.config_path, - "defaults.theme", - theme_name, - ) - - events.theme_switched(self._logger, value=theme_name) - - def _on_toggle_language(self) -> None: - """Toggle the GUI language between English and French.""" - from profiles.gui.i18n import current_language - - new_lang = "fr" if current_language() == "en" else "en" - set_language(new_lang) - - # Persist to config - write_value( - self._config.config_path, - "defaults.language", - new_lang, - ) - - events.lang_switched(self._logger, value=new_lang) - - # ---------------------------------------------------------------- - # Data population - # ---------------------------------------------------------------- - - def _populate_directories(self) -> None: - """Populate the directory combobox from configurations. - - Delegates to :class:`DirectoryManager` for the actual logic. - """ - self._dir_manager.populate() - - @staticmethod - def _format_dir_entry(entry: config_service.DirectoryEntry) -> str: - """Format a DirectoryEntry as a combobox display string.""" - return format_dir_entry(entry) - - def _resolve_dir_selection(self, label: str) -> list[str]: - """Resolve a combobox selection label to the list of scan paths.""" - return self._dir_manager.resolve(label) - - def _set_dir_selection(self, label: str) -> None: - """Set the combobox to the entry matching *label*, or ``label`` itself.""" - self._dir_manager.set_selection(label) - - def _populate_extensions(self) -> None: - """Populate the extension combobox.""" - self._ext_combo["values"] = self._config.extensions - self._ext_var.set(self._config.extensions[0] if self._config.extensions else "") - - def _populate_filters(self) -> None: - """Populate the filter combobox.""" - self._filter_combo["values"] = self._config.filters - self._filter_var.set(self._config.filters[0] if self._config.filters else "") - - # ponytail: only consumer is tests/test_extension_filter.py; delete when - # those tests move to the core module. - @staticmethod - def _is_simple_extension(ext: str) -> bool: - """Check whether *ext* is a simple extension (no search operators).""" - return scanner.is_simple_extension(ext) - - def _load_system_info(self) -> None: - """Load and display system information in the status bar.""" - info = system.collect_system_info() - self._hostname = info.hostname - self._user_label.config(text=info.username) - self._host_label.config(text=info.hostname) - self._ip_label.config(text=info.ip) - - # Update logger source with actual hostname - system.apply_source_to_logger( - self._logger, system.format_user_host(info.username, info.hostname) - ) - - def _auto_select_directory(self) -> None: - """Auto-select the directory matching the current hostname. - - Delegates to :class:`DirectoryManager`. - """ - self._dir_manager.auto_select() - - def _current_dir_label(self) -> str: - """Return the current combobox selection with icon prefix stripped.""" - return self._dir_manager.current_label() - - def _find_active_config(self) -> MachineConfiguration | None: - """Find the configuration matching the currently selected directory.""" - return self._dir_manager.find_active_config() - - def _apply_config_overrides(self) -> None: - """Merge per-config extensions/filters with the generic defaults.""" - self._dir_manager.apply_config_overrides() - - # ---------------------------------------------------------------- - # Row coloring (per-config `row_colors`) - # ---------------------------------------------------------------- - - def _configure_row_colors(self) -> None: - """Reconfigure treeview tags to match the active configuration's row_colors. - - Each ``(pattern, color)`` rule from the active MachineConfiguration - becomes a unique treeview tag. Pattern matching is case-insensitive - substring against each filename. If no configuration matches the - selected directory, fall back to default row_colors. - - Configured colours are used as-is on both light and dark themes; - only colours whose WCAG contrast ratio against ``self._theme.surface`` - is below ``_MIN_ROW_COLOR_CONTRAST`` (effectively invisible) are - replaced with the theme's ``on_surface`` text colour so the row - stays readable. Tag names derive from the *original* configured - colour, keeping tags stable across theme switches. A - ``_rowcolor_default`` tag is always configured with the theme's - ``on_surface_variant`` foreground so empty rules still produce a - visible tag (it is not auto-applied to rows). - """ - self._row_color_rules = [] - - active = self._find_active_config() - - # Use active config row_colors if found, otherwise fall back to default row_colors - row_colors_to_use = active.row_colors if active is not None else self._config.row_colors - - for pattern, color in row_colors_to_use: - if not pattern or not color: - continue - # Contrast-based fallback: if the configured foreground is - # indistinguishable from the active surface it would be - # invisible on the background, so substitute the standard - # readable text colour. Legitimate tints (e.g. PROD:#1565C0 - # on the dark surface) keep their colour in both themes. - effective_color = color - if contrast_ratio(color, self._theme.surface) < _MIN_ROW_COLOR_CONTRAST: - effective_color = self._theme.on_surface - # Tag names disallow spaces; replace them so the tag can be applied. - safe_pattern = pattern.replace(" ", "_") - safe_color = color.lstrip("#").replace(" ", "_") - tag_name = f"{self._row_color_tag_prefix}_{safe_pattern}_{safe_color}" - try: - self._tree.tag_configure(tag_name, foreground=effective_color) - except tk.TclError as exc: - self._logger.warning("Skipping invalid row_color '%s:%s': %s", pattern, color, exc) - continue - self._row_color_rules.append((pattern, tag_name)) - - # Build the engine for fast per-row tag lookup - self._row_color_rules_engine = RowColorRules(row_colors_to_use, self._row_color_tag_prefix) - - # Always expose a default tag so empty rule lists still produce a - # visible colour that tests can inspect. The tag is intentionally - # not auto-applied to rows — this preserves the unchanged row - # colouring behaviour when no rules are configured. - try: - self._tree.tag_configure( - default_tag_name(self._row_color_tag_prefix), - foreground=self._theme.on_surface_variant, - ) - except tk.TclError as exc: - self._logger.warning("Could not configure default row color tag: %s", exc) - - def _row_color_tags_for(self, filename: str) -> tuple[str, ...]: - """Return tags for *filename*: the default variant tag (always - applied) plus the first matching row_color rule, if any. - - Delegates to :class:`RowColorRules` for the matching — this - method only exists to keep the original call sites stable. - """ - if self._row_color_rules_engine is None: - return (default_tag_name(self._row_color_tag_prefix),) - return self._row_color_rules_engine.tags_for(filename) - - # ---------------------------------------------------------------- - # File list operations - # ---------------------------------------------------------------- - - def _refresh_file_list(self) -> None: - """Scan the selected directory and populate the file list.""" - directory_label = self._current_dir_label() - extension = self._ext_var.get() - filter_text = self._filter_var.get().strip() - - # Resolve the combobox label to scan paths (multiple for configs) - scan_paths = self._resolve_dir_selection(directory_label) - - # Cancel any ongoing scan/insert by incrementing the ID - self._current_scan_id += 1 - scan_id = self._current_scan_id - - # Clear the tree (batch delete for performance) - children = self._tree.get_children() - if children: - self._tree.delete(*children) - self._tree_to_path.clear() - self._tree_to_filename.clear() - - # Reset sort state and heading indicators - self._sort_state.clear() - for i, header in enumerate(self._config.column_headers): - self._tree.heading(i, text=header) - - # Check that at least one scan path exists - if not scan_paths or not any(directory_exists(p) for p in scan_paths): - self._count_label.config(text="Files: 0") - self._dir_status_label.config(text="Directory not found", style="Status.Error.TLabel") - self._update_empty_state(True) - return - - self._update_empty_state(False) - - if len(scan_paths) > 1: - self._dir_status_label.config( - text=f"Scanning {len(scan_paths)} paths", style="Status.Info.TLabel" - ) - if self._dir_status_tooltip: - self._dir_status_tooltip.set_text( - "Paths:\n" + "\n".join(f"• {p}" for p in scan_paths) - ) - else: - self._dir_status_label.config(text="Scanning...", style="Status.Info.TLabel") - if self._dir_status_tooltip: - self._dir_status_tooltip.set_text("Current search directory") - - self._count_label.config(text="Files: 0") - self._root.update_idletasks() - self._scan_in_progress = True - self._root.after(200, self._show_progress) - - # Start scanning in a background thread - # NOTE: tkinter variables MUST be read on the main thread, so the - # worker only performs pure computation and pushes its result into - # _scan_queue; _poll_scan_queue drains it on the main thread. - threading.Thread( - target=self._bg_scan_and_process, - args=( - scan_id, - scan_paths, - extension, - filter_text, - bool(self._recursive_var.get()), - directory_label, - ), - daemon=True, - ).start() - self._root.after( - 50, self._poll_scan_queue, scan_id, directory_label, filter_text, extension - ) - - def _bg_scan_and_process( - self, - scan_id: int, - directories: list[str], - extension: str, - filter_text: str, - recursive: bool, - directory_label: str, - ) -> None: - """Scan directories and process files in a background thread. - - Thin wrapper around :func:`run_scan` (which owns the pure - worker logic). Kept as a method so existing - ``threading.Thread(target=self._bg_scan_and_process, args=...)`` - call sites continue to work without churn. - """ - # If this scan has already been superseded, skip the work entirely. - if scan_id != self._current_scan_id: - return - run_scan( - config=self._config, - directory_label=directory_label, - scan_paths=directories, - extension=extension, - filter_text=filter_text, - recursive=recursive, - queue_=self._scan_queue, - scan_id=scan_id, - logger=self._logger, - ) - - def _poll_scan_queue( - self, - scan_id: int, - display_label: str, - filter_text: str, - extension: str, - ) -> None: - """Drain a finished scan result on the main thread. - - Called repeatedly via ``after()`` while a background scan is in - flight. When the worker pushes a result for *scan_id*, the chunked - insertion (which touches the Treeview) is started here, on the - main thread. Stale results for superseded scans are discarded. - """ - while True: - try: - status, queued_scan_id, items = self._scan_queue.get_nowait() - except queue.Empty: - # Scan still running: keep polling if this scan is still current - if scan_id == self._current_scan_id: - self._root.after( - 50, - self._poll_scan_queue, - scan_id, - display_label, - filter_text, - extension, - ) - return - - if queued_scan_id != scan_id: - continue # Result belongs to a superseded scan — discard - - # Result for the scan we are waiting for - self._scan_in_progress = False - if status == "error": - self._dir_status_label.config(text="Scan failed", style="Status.Error.TLabel") - self._hide_progress() - else: - self._start_chunked_insert(scan_id, items, display_label, filter_text, extension) - return - - def _start_chunked_insert( - self, - scan_id: int, - items: list[scanner.ScannedFileDynamic], - display_label: str, - filter_text: str, - extension: str, - ) -> None: - """Initialize the chunked insertion on the main thread.""" - if scan_id != self._current_scan_id: - return - - self._insert_chunk(scan_id, items, 0, [], display_label, filter_text, extension) - - def _insert_chunk( - self, - scan_id: int, - items: list[scanner.ScannedFileDynamic], - start_idx: int, - accumulated_files: list[Path], - display_label: str, - filter_text: str, - extension: str, - ) -> None: - """Insert a chunk of files into the Treeview and schedule the next chunk. - - Values are built from ``self._config.column_names`` — the single - source of truth for the tree columns — so one code path handles - the single-column layout, the default File/Version layout, and - fully custom column sets. - - Args: - scan_id: Current scan ID for validation. - items: List of scanned files with dynamic column values. - start_idx: Starting index for this chunk. - accumulated_files: Accumulated list of file paths. - display_label: Display label for the scanned directory/config. - filter_text: Keyword filter used by the scan (for logging). - extension: Extension expression used by the scan (for logging). - """ - if scan_id != self._current_scan_id: - return - - # Adaptive chunk size: render initial 200 items immediately for fast first paint, - # then scale up to 1000 items per chunk for fast background completion. - chunk_size = 200 if start_idx == 0 else 1000 - end_idx = min(start_idx + chunk_size, len(items)) - column_names = self._config.column_names - - # Insert this chunk - for i in range(start_idx, end_idx): - scanned_file = items[i] - filename = scanned_file.column_values.get("File", "") - row_tags = self._row_color_tags_for(filename) - - # Build values tuple from column values in order - values = tuple( - scanned_file.column_values.get(col_name, "") for col_name in column_names - ) - - iid = f"{scan_id}_{i}" - self._tree.insert( - parent="", - index=tk.END, - iid=iid, - values=values, - tags=row_tags, - ) - self._tree_to_path[iid] = scanned_file.path - self._tree_to_filename[iid] = filename - accumulated_files.append(scanned_file.path) - - # Update temporary count label - self._count_label.config(text=f"Files: {end_idx}") - - if end_idx < len(items): - # Schedule next chunk with minimal delay - self._root.after( - 1, # Reduced from 10ms for faster insertion - self._insert_chunk, - scan_id, - items, - end_idx, - accumulated_files, - display_label, - filter_text, - extension, - ) - else: - # Finalize - count = len(accumulated_files) - self._count_label.config(text=f"Files: {count}") - self._hide_progress() - self._flash_count_label() - self._update_empty_state(count == 0) - - scan_paths = self._resolve_dir_selection(display_label) - if count == 0: - self._dir_status_label.config(text="No matching files found", style="Info.TLabel") - elif len(scan_paths) > 1: - self._dir_status_label.config( - text=f"Scanned {len(scan_paths)} paths", - style="Info.TLabel", - ) - if self._dir_status_tooltip: - self._dir_status_tooltip.set_text( - "Scanned paths:\n" + "\n".join(f"• {p}" for p in scan_paths) - ) - else: - self._dir_status_label.config( - text=f"Directory: {display_label}", style="Info.TLabel" - ) - if self._dir_status_tooltip: - self._dir_status_tooltip.set_text(f"Directory: {display_label}") - - # Note: SCAN_COMPLETE / SCAN_METRICS are emitted by the scanner - # pipeline via ScanTimer.finish(); do not duplicate here. - - # ---------------------------------------------------------------- - # Event handlers - # ---------------------------------------------------------------- - - def _on_recursive_toggle(self) -> None: - """Handle Recursive checkbox toggle: persist to config and refresh.""" - write_value( - self._config.config_path, - "defaults.recursive_search", - self._recursive_var.get(), - ) - self._refresh_file_list() - - def _on_close_toggle(self) -> None: - """Handle Close after execution toggle: persist to config.""" - write_value( - self._config.config_path, - "defaults.close_after_execute", - self._close_var.get(), - ) - - def _on_directory_changed(self, _event: tk.Event | None = None) -> None: - """Handle directory selection change.""" - self._apply_config_overrides() - self._configure_row_colors() - self._refresh_file_list() - - def _on_directory_enter(self, _event: tk.Event | None = None) -> None: - """Handle Enter key in directory field: start search.""" - self._apply_config_overrides() - self._configure_row_colors() - self._refresh_file_list() - - def _on_directory_double_click(self, _event: tk.Event | None = None) -> None: - """Handle double-click on directory: open in file explorer.""" - label = self._current_dir_label() - paths = self._resolve_dir_selection(label) - # Open the first valid path - for path in paths: - if directory_exists(path): - open_file_explorer(path) - return - messagebox.showwarning( - "Directory Not Found", - f"The selected directory does not exist:\n{label}", - ) - - def _on_browse(self) -> None: - """Open a directory picker and refresh the file list.""" - directory = filedialog.askdirectory(title="Select Launcher Directory") - if directory: - self._dir_var.set(directory) - self._apply_config_overrides() - self._configure_row_colors() - self._refresh_file_list() - - def _on_open_config(self) -> None: - """Open the configuration file with the default text editor. - - Delegates to :class:`WindowActions`. - """ - self._window_actions.open_config() - - def _on_open_log(self) -> None: - """Open the log file with the default text editor.""" - self._window_actions.open_log(Path.cwd() / "profiles.log") - - @staticmethod - def _result_title(result: actions.ActionResult) -> str: - """Map an ``ActionResult`` to a messagebox title.""" - return { - actions.ActionStatus.NOT_FOUND: "Not Found", - actions.ActionStatus.FAILED: "Open Failed", - }.get(result.status, "Error") - - def _debounced_refresh(self, timer_attr: str, delay_ms: int = 1000) -> None: - """Schedule a debounced refresh, cancelling any pending one.""" - if (prev := getattr(self, timer_attr)) is not None: - self._root.after_cancel(prev) - setattr(self, timer_attr, self._root.after(delay_ms, self._refresh_file_list)) - - def _flush_timer(self, timer_attr: str) -> None: - """Cancel any pending debounced refresh and trigger one immediately.""" - if (prev := getattr(self, timer_attr)) is not None: - self._root.after_cancel(prev) - setattr(self, timer_attr, None) - self._refresh_file_list() - - def _on_extension_or_filter_select(self, _event: tk.Event | None = None) -> None: - """Handle extension or filter combobox selection - immediate refresh.""" - self._refresh_file_list() - - def _on_refresh(self) -> None: - """Handle refresh button: reload config and refresh list.""" - try: - fresh_config = load_config(self._config.config_path) - self._config = fresh_config - self._populate_directories() - self._auto_select_directory() - self._apply_config_overrides() - self._configure_row_colors() - events.config_reloaded(self._logger, path=str(self._config.config_path)) - except (FileNotFoundError, OSError) as exc: - events.config_reload_failed(self._logger, error=str(exc)) - messagebox.showerror( - "Configuration Error", - f"Failed to reload configuration:\n{exc}", - ) - self._refresh_file_list() - - def _on_execute(self, _event: tk.Event | None = None) -> None: - """Handle file execution on one or many selected rows. - - With ``selectmode="extended"`` the treeview may carry any number - of selected iids. Each selection is launched independently via - :func:`actions.launch_selected_file`; the first failure surfaces - as a messagebox while subsequent launches keep going. - """ - selection = self._tree.selection() - if not selection: - messagebox.showinfo("No Selection", "Please select a file from the list.") - return - - username = self._user_label.cget("text") - first_failure: actions.ActionResult | None = None - - for iid in selection: - file_path = self._tree_to_path.get(iid) - if file_path is None: - continue - result = actions.launch_selected_file( - directory=str(file_path.parent), - filename=file_path.name, - release=self._config.release, - username=username, - config=self._config, - logger=self._logger, - ) - if result.status is not actions.ActionStatus.SUCCESS and first_failure is None: - first_failure = result - - if first_failure is not None: - title = self._result_title(first_failure) - if first_failure.status is actions.ActionStatus.NOT_FOUND: - messagebox.showwarning(title, first_failure.message) - elif ( - first_failure.status is actions.ActionStatus.FAILED - and "aborted by a launch hook" in first_failure.message - ): - messagebox.showerror("Launch Aborted", first_failure.message) - else: - messagebox.showerror(title, first_failure.message) - - if self._close_var.get(): - self._root.after(500, self._on_close) - - def _on_key_up(self, _event: tk.Event) -> str | None: - """Handle up arrow key navigation.""" - selection = self._tree.selection() - if selection: - prev = self._tree.prev(selection[0]) - if prev: - self._tree.selection_set(prev) - self._tree.focus(prev) - self._tree.see(prev) - return "break" - - def _on_key_down(self, _event: tk.Event) -> str | None: - """Handle down arrow key navigation.""" - selection = self._tree.selection() - if selection: - next_item = self._tree.next(selection[0]) - if next_item: - self._tree.selection_set(next_item) - self._tree.focus(next_item) - self._tree.see(next_item) - return "break" - - # ---------------------------------------------------------------- - # Context menu actions delegation - # ---------------------------------------------------------------- - - def _selected_file_path(self) -> Path | None: - """Resolve the full filesystem path of the currently selected file.""" - return self._context_menu.selected_file_path() - - def _on_tree_right_click(self, event: tk.Event) -> None: - """Show the right-click context menu for the row under the cursor.""" - self._context_menu.on_tree_right_click(event) - - def _action_launch(self, file_path: Path) -> None: - """Launch the given file using the OS default association.""" - self._context_menu.action_launch(file_path) - - def _action_launch_with_args(self, file_path: Path) -> None: - """Prompt for extra arguments, then launch *file_path* with them.""" - from tkinter import simpledialog - - args = simpledialog.askstring( - "Launch arguments", - f"Arguments for {file_path.name}:", - parent=self._root, - ) - # Cancel button or empty input → no launch (matches GUI convention). - if args is None or not args.strip(): - return - result = actions.launch_selected_file( - directory=str(file_path.parent), - filename=file_path.name, - release=self._config.release, - username=self._user_label.cget("text"), - args=args, - config=self._config, - logger=self._logger, - ) - title = self._result_title(result) - if result.status is not actions.ActionStatus.SUCCESS: - if result.status is actions.ActionStatus.NOT_FOUND: - messagebox.showwarning(title, result.message) - else: - messagebox.showerror(title, result.message) - elif self._close_var.get(): - self._root.after(500, self._on_close) - - def _action_open_folder(self, file_path: Path) -> None: - """Open the directory that contains *file_path*.""" - self._context_menu.action_open_folder(file_path) - - def _action_reveal(self, file_path: Path) -> None: - """Reveal the file in the OS file explorer.""" - self._context_menu.action_reveal(file_path) - - def _action_copy(self, value: str) -> None: - """Copy *value* to the system clipboard.""" - self._context_menu.action_copy(value) - - def _action_copy_path(self, file_path: Path, *, forward_slashes: bool = False) -> None: - """Copy the full file path to the clipboard.""" - self._context_menu.action_copy_path(file_path, forward_slashes=forward_slashes) - - def _action_copy_name(self, file_path: Path, *, with_ext: bool = True) -> None: - """Copy the file name (with or without extension) to the clipboard.""" - self._context_menu.action_copy_name(file_path, with_ext=with_ext) - - def _action_properties(self, file_path: Path) -> None: - """Show a properties dialog for the file (size, dates, path).""" - self._context_menu.action_properties(file_path) - - def _action_filter_to_folder(self, file_path: Path) -> None: - """Switch the directory combobox to the file's parent folder.""" - self._context_menu.action_filter_to_folder(file_path) - - def _action_hash(self, file_path: Path, algorithm: str, *, copy_only: bool = False) -> None: - """Compute the file's hash, show a dialog, and optionally copy it.""" - self._context_menu.action_hash(file_path, algorithm, copy_only=copy_only) - - def _action_copy_uri(self, file_path: Path) -> None: - """Copy a ``file://`` URI to the clipboard for the file.""" - self._context_menu.action_copy_uri(file_path) - - def _action_filter_by_extension(self, file_path: Path) -> None: - """Set the extension filter to ``.`` and re-scan the current folder.""" - self._context_menu.action_filter_by_extension(file_path) - - def _action_verify_hash(self, file_path: Path, algorithm: str) -> None: - """Compute the file's *algorithm* hash and compare it to the clipboard.""" - self._context_menu.action_verify_hash(file_path, algorithm) - - def _action_open_terminal(self, file_path: Path) -> None: - """Open a terminal session in the file's parent directory.""" - self._context_menu.action_open_terminal(file_path) - - def _on_close(self) -> None: - """Handle window close event.""" - uptime = time.time() - self._start_time if self._start_time else 0.0 - events.app_closed(self._logger, uptime_s=uptime) - self._root.destroy() - - def _configure_bindings(self) -> None: - """Configure keyboard shortcuts. - - The ``_SHORTCUTS`` table is the single source of truth: each - entry is ``(display_label, key_spec, bound_callback)`` and is - also reused by :meth:`_on_show_shortcuts`. - """ - for _label, key, callback in self._shortcut_entries(): - self._root.bind(key, lambda e, cb=callback: cb()) - - def _shortcut_entries(self) -> list[tuple[str, str, Callable[[], None]]]: - """Return the ordered list of shortcut definitions. - - Returns: - List of ``(label, key_spec, callback)`` tuples. - """ - return [ - ("Refresh file list", "", self._refresh_file_list), - ("Reload config + refresh", "", self._on_refresh), - ("Focus directory field", "", lambda: self._dir_combo.focus_set()), - ("Focus extension field", "", lambda: self._ext_combo.focus_set()), - ("Focus filter field", "", lambda: self._filter_combo.focus_set()), - ("Focus file list", "", lambda: self._tree.focus_set()), - ("Execute selected file", "", lambda: self._on_execute()), - ( - "Clear filter text", - "", - lambda: (self._filter_var.set(""), self._refresh_file_list()), - ), - ("Open log file", "", self._on_open_log), - ("Open config file", "", self._on_open_config), - ("Cycle theme", "", self._on_toggle_theme), - ("Show shortcuts", "", self._on_show_shortcuts), - ("Quit application", "", self._on_close), - ("Close window", "", self._on_close), - ] - - def _on_show_shortcuts(self) -> None: - """Open a modal dialog listing all keyboard shortcuts and mouse actions.""" - dlg = tk.Toplevel(self._root) - dlg.title("Keyboard Shortcuts") - dlg.transient(self._root) - dlg.resizable(False, False) - - # Close on Escape *inside* the dialog before it reaches the root - dlg.bind("", lambda e: dlg.destroy()) - dlg.focus_set() - dlg.grab_set() - - header = ttk.Label(dlg, text="Keyboard Shortcuts", style="Title.TLabel") - header.pack(pady=(12, 6)) - - body = ttk.Frame(dlg, padding=(16, 0, 16, 12)) - body.pack(fill="both", expand=True) - - keyboard = self._shortcut_entries() - for i, (label, key, _cb) in enumerate(keyboard): - ttk.Label(body, text=key, style="Value.TLabel").grid( - row=i, - column=0, - sticky="w", - padx=(0, 16), - pady=2, - ) - ttk.Label(body, text=label).grid(row=i, column=1, sticky="w", pady=2) - - # ── Mouse Actions section ─────────────────────────────────────── - sep_row = len(keyboard) - ttk.Separator(body, orient="horizontal").grid( - row=sep_row, - column=0, - columnspan=2, - sticky="ew", - pady=(8, 8), - ) - mouse_header_row = sep_row + 1 - ttk.Label(body, text="Mouse Actions", style="Header.TLabel").grid( - row=mouse_header_row, - column=0, - columnspan=2, - sticky="w", - pady=(4, 4), - ) - - mouse = self._mouse_entries() - for offset, (label, action) in enumerate(mouse): - row = mouse_header_row + 1 + offset - ttk.Label(body, text=label, style="Value.TLabel").grid( - row=row, - column=0, - sticky="w", - padx=(0, 16), - pady=2, - ) - ttk.Label(body, text=action).grid(row=row, column=1, sticky="w", pady=2) - - close_btn = ttk.Button(dlg, text="Close", command=dlg.destroy) - close_btn.pack(pady=(0, 12)) - - def _mouse_entries(self) -> list[tuple[str, str]]: - """Return the ordered list of mouse-action documentation tuples. - - Each entry is ``(label, action)`` where ``label`` describes the - gesture and ``action`` describes its effect. This is rendered by - :meth:`_on_show_shortcuts` under a "Mouse Actions" header. - """ - return [ - ("Double-click row", "Launch selected file"), - ("Right-click row", "Open context menu"), - ("Ctrl+Click row (macOS)", "Open context menu"), - ("Double-click directory field", "Open in file explorer"), - ("Shift+MouseWheel", "Horizontal scroll"), - ("Page Up / Page Down", "Jump by visible page"), - ("Home / End", "Jump to first / last row"), - ] - - def _on_header_double_click(self, event: tk.Event) -> str | None: - """Auto-fit column width to content on double-click of separator or heading.""" - region = self._tree.identify_region(event.x, event.y) - if region in ("separator", "heading"): - col_id = self._tree.identify_column(event.x) - if not col_id: - return None - - import tkinter.font as tkfont - - from profiles.gui.theme import FONT_FAMILY, FONT_SIZE_NORMAL - - font = tkfont.Font(font=(FONT_FAMILY, FONT_SIZE_NORMAL)) - heading_font = tkfont.Font(font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold")) - - try: - col_idx = int(col_id.replace("#", "")) - 1 - except ValueError: - return None - - heading_text = self._tree.heading(col_id, "text") - max_w = heading_font.measure(heading_text) + 30 - - # Optimisation: Sample up to a maximum of 200 items for auto-fit measurement - # to prevent UI freeze on large directory trees (O(1) instead of O(N)) - children = self._tree.get_children() - sample_size = min(len(children), 200) - items_to_measure = children[:sample_size] - - for item in items_to_measure: - val = str(self._tree.set(item, col_idx)) - w = font.measure(val) + 20 - if w > max_w: - max_w = w - - new_width = max(60, min(1000, max_w)) - self._tree.column(col_id, width=new_width) - return "break" - return None - - def _update_empty_state(self, show: bool) -> None: - """Update empty state placeholder overlay. - - ponytail: when ``img/ProFiles_banner.png`` is missing we fall back - to the original single-label layout — behaviour is identical to - before. Upgrade path: ship a proper icon asset and design pass - for the empty state. - """ - if not hasattr(self, "_empty_label"): - banner_path = Path.cwd() / "img" / "ProFiles_banner.png" - if banner_path.is_file(): - # Bind the image to THIS window's interpreter — tk.PhotoImage - # defaults to the process-global default root, which breaks - # multi-window sessions (each Tk() owns a private interpreter). - self._empty_photo = tk.PhotoImage(master=self._root, file=str(banner_path)) - self._empty_photo = self._empty_photo.subsample(3, 5) - - self._empty_label = tk.Frame(self._list_container) - img_label = tk.Label(self._empty_label, image=self._empty_photo, borderwidth=0) - img_label.pack(pady=(0, 12)) - ttk.Label( - self._empty_label, - text="📂 No matching files", - style="EmptyState.TLabel", - padding=(0, 4), - ).pack() - ttk.Button( - self._empty_label, - text="Reset filters", - command=self._reset_filters, - style="Link.TButton", - ).pack(pady=(8, 0)) - else: - self._empty_label = ttk.Label( - self._list_container, - text="📂 No matching files found\nTry adjusting your search filters or directory", - style="EmptyState.TLabel", - padding=20, - ) - - if show: - self._empty_label.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) - self._empty_label.tkraise() - else: - self._empty_label.grid_forget() - - def _reset_filters(self) -> None: - """Reset extension and filter comboboxes to their first value, then refresh.""" - ext_values = self._ext_combo["values"] - self._ext_var.set(ext_values[0] if ext_values else "") - filter_values = self._filter_combo["values"] - self._filter_var.set(filter_values[0] if filter_values else "") - self._refresh_file_list() - - def _show_progress(self) -> None: - """Show the indeterminate progressbar and start its animation. - - Guarded by ``_scan_in_progress`` so a deferred ``after`` callback - cannot start animating a scan that already finished (which would - leave a recurring Tcl timer running forever). - """ - progress_bar = getattr(self, "_progress_bar", None) - if progress_bar is None: - return - if not self._scan_in_progress: - return - progress_bar.pack(side=tk.LEFT, padx=(0, 12)) - progress_bar.start(10) - - def _hide_progress(self) -> None: - """Stop the progressbar animation and hide it.""" - self._scan_in_progress = False - progress_bar = getattr(self, "_progress_bar", None) - if progress_bar is None: - return - with contextlib.suppress(tk.TclError): - progress_bar.stop() - with contextlib.suppress(tk.TclError): - progress_bar.pack_forget() - - def _flash_count_label(self) -> None: - """Briefly flash count label with success styling on scan completion.""" - self._count_label.config(style="Status.Success.TLabel") - self._root.after(600, lambda: self._count_label.config(style="Info.TLabel")) - - # ---------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------- - - @property - def root(self) -> tk.Tk: - """Get the root Tk window.""" - return self._root - - def _check_config_file(self) -> None: - """Check if a .profiles config file exists, propose creation if not.""" - if not self._config.config_path.exists() and messagebox.askyesno( - "Configuration File Not Found", - "No .profiles configuration file was found in the current directory " - "or its subdirectories (search limited to 5 levels).\n\n" - "Would you like to create a default configuration file now?\n\n" - "You can customize it later by clicking the 'Config' button.", - parent=self._root, - ): - self._create_config_file() - - def _create_config_file(self) -> None: - """Create a default .profiles configuration file.""" - from profiles.core.config.io.yaml_io import PRIMARY_CONFIG_NAME - from profiles.core.config.template import STARTER_CONFIG_TEMPLATE - - target = Path.cwd() / PRIMARY_CONFIG_NAME - - if target.exists(): - messagebox.showinfo( - "Configuration File", - f"A .profiles file already exists at:\n{target}\n\nNo action needed.", - parent=self._root, - ) - return - - try: - body = STARTER_CONFIG_TEMPLATE.format(cwd=str(Path.cwd())) - target.write_text(body, encoding="utf-8") - - events.config_created(self._logger, path=str(target)) - - # Ask user if they want to restart to apply the new configuration - if messagebox.askyesno( - "Restart Required", - "Configuration file created successfully!\n\n" - "To apply the new configuration (including column definitions), " - "the application must be restarted.\n\n" - "Would you like to restart now?", - parent=self._root, - ): - self._restart_application() - else: - messagebox.showinfo( - "Configuration File Created", - f"Default configuration file created at:\n{target}\n\n" - "The application will continue with the current settings.\n" - "Restart the application to load the new configuration.", - parent=self._root, - ) - - except OSError as exc: - messagebox.showerror( - "Error Creating Configuration", - f"Failed to create configuration file:\n{exc}", - parent=self._root, - ) - events.config_create_failed(self._logger, error=str(exc)) - - def _restart_application(self) -> None: - """Restart the application using the module entry point. - - Delegates to :class:`WindowActions`. - """ - self._window_actions.restart() - - def run(self) -> None: - """Start the application main loop.""" - self._start_time = time.time() - events.app_started( - self._logger, - version=__version__, - headless=False, - python_version=platform.python_version(), - os_name=f"{platform.system()} {platform.release()}", - ) - self._root.mainloop() diff --git a/src/profiles/gui/presentation/__init__.py b/src/profiles/gui/presentation/__init__.py deleted file mode 100644 index 37849b9..0000000 --- a/src/profiles/gui/presentation/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Presentation layer — pure rendering helpers, no business logic. - -Each module here must be importable without instantiating Tk widgets -and must not touch the filesystem or core configuration. -""" - -from profiles.gui.presentation.row_colors import ( - RowColorRules, - default_tag_name, - make_rule_tag_name, -) - -__all__ = [ - "RowColorRules", - "default_tag_name", - "make_rule_tag_name", -] diff --git a/src/profiles/gui/presentation/row_colors.py b/src/profiles/gui/presentation/row_colors.py deleted file mode 100644 index 8c35a73..0000000 --- a/src/profiles/gui/presentation/row_colors.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Pure row-color rule engine — maps a filename to ttk.Treeview tag names. - -No Tk dependency. Owns the substring-match logic; the caller is -responsible for configuring ttk tags on the actual Treeview. - -Tag naming convention (preserved for back-compat with existing code): - {tag_prefix}_default — always present - {tag_prefix}_{safe_pattern}_{color} — per rule, first match wins -""" - -from __future__ import annotations - -from collections.abc import Iterable - - -def make_rule_tag_name( - tag_prefix: str, - pattern: str, - color: str, -) -> str: - """Build a stable tag name for a ``(pattern, color)`` rule. - - Spaces are replaced with underscores so the tag can be passed to ttk. - The leading ``#`` on the colour is stripped to keep the name short. - """ - safe_pattern = pattern.replace(" ", "_") - safe_color = color.lstrip("#").replace(" ", "_") - return f"{tag_prefix}_{safe_pattern}_{safe_color}" - - -def default_tag_name(tag_prefix: str) -> str: - """Return the always-applied default tag name for a given prefix.""" - return f"{tag_prefix}_default" - - -class RowColorRules: - """Compiled, ready-to-match set of row-color rules. - - A ``default`` tag is always included; the first rule whose pattern - is a case-insensitive substring of the filename is appended after. - """ - - def __init__( - self, - rules: Iterable[tuple[str, str]], - tag_prefix: str, - ) -> None: - """Initialise from raw ``(pattern, color_hex)`` rules. - - Empty/whitespace patterns or colours are silently skipped. - """ - self._default_tag = default_tag_name(tag_prefix) - self._compiled: list[tuple[str, str]] = [] - for pattern, _color in rules: - if not pattern or not _color: - continue - self._compiled.append( - (pattern.lower(), make_rule_tag_name(tag_prefix, pattern, _color)) - ) - - def tags_for(self, filename: str) -> tuple[str, ...]: - """Return the tags to apply to *filename*. - - Always includes the default tag; appends the first matching rule - (case-insensitive substring). - """ - if not self._compiled: - return (self._default_tag,) - filename_lower = filename.lower() - for pattern_lower, tag_name in self._compiled: - if pattern_lower in filename_lower: - return (self._default_tag, tag_name) - return (self._default_tag,) diff --git a/src/profiles/gui/search_bar.py b/src/profiles/gui/search_bar.py deleted file mode 100644 index e1dce29..0000000 --- a/src/profiles/gui/search_bar.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Search bar component for ProFiles. - -Provides a dedicated search/filter bar with controls for directory selection, -extension filtering, keyword filtering, and search options. -""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from collections.abc import Callable -from tkinter import ttk - -from profiles.gui.i18n import register, t -from profiles.gui.styles import ToolTip - - -class SearchBar: - """Search bar component for the main window. - - Contains app title, directory selection, extension filter, keyword filter, - recursive option, and search button. - """ - - def __init__( - self, - parent: ttk.Frame, - release_version: str, - recursive_var: tk.BooleanVar, - on_directory_changed: Callable[[], None], - on_directory_enter: Callable[[], None], - on_directory_double_click: Callable[[], None], - on_browse: Callable[[], None], - on_extension_or_filter_select: Callable[[], None], - on_debounced_refresh_ext: Callable[[], None], - on_flush_timer_ext: Callable[[], None], - on_filter_refresh: Callable[[], None], - on_flush_timer_filter: Callable[[], None], - on_recursive_toggle: Callable[[], None], - on_search: Callable[[], None], - config_title: str = "", - ) -> None: - """Initialize the search bar. - - Args: - parent: Parent frame to attach the search bar to. - release_version: Application version string. - recursive_var: BooleanVar for recursive search checkbox. - on_directory_changed: Callback for directory combobox selection. - on_directory_enter: Callback for directory Enter key. - on_directory_double_click: Callback for directory double-click. - on_browse: Callback for browse button. - on_extension_or_filter_select: Callback for extension/filter selection. - on_debounced_refresh_ext: Callback for extension debounced refresh. - on_flush_timer_ext: Callback for extension timer flush. - on_filter_refresh: Callback for filter refresh. - on_flush_timer_filter: Callback for filter timer flush. - on_recursive_toggle: Callback for recursive checkbox toggle. - on_search: Callback for search button. - """ - self._parent = parent - self._release_version = release_version - self._config_title = config_title - self._recursive_var = recursive_var - self._on_directory_changed = on_directory_changed - self._on_directory_enter = on_directory_enter - self._on_directory_double_click = on_directory_double_click - self._on_browse = on_browse - self._on_extension_or_filter_select = on_extension_or_filter_select - self._on_debounced_refresh_ext = on_debounced_refresh_ext - self._on_flush_timer_ext = on_flush_timer_ext - self._on_filter_refresh = on_filter_refresh - self._on_flush_timer_filter = on_flush_timer_filter - self._on_recursive_toggle = on_recursive_toggle - self._on_search = on_search - - # String variables - self._dir_var: tk.StringVar - self._ext_var: tk.StringVar - self._filter_var: tk.StringVar - - # Widget references - self._search_frame: ttk.Frame - self._title_frame: ttk.Frame - self._title_label: ttk.Label - self._title_author: ttk.Label - self._controls_frame: ttk.Frame - self._dir_frame: ttk.Frame - self._dir_combo: ttk.Combobox - self._browse_btn: ttk.Button - self._filter_frame: ttk.Frame - self._recursive_check: ttk.Checkbutton - self._search_btn: ttk.Button - self._ext_combo: ttk.Combobox - self._filter_combo: ttk.Combobox - - # Translatable widget refs (set in _build, updated in _apply_text) - self._dir_label: ttk.Label | None = None - self._ext_label: ttk.Label | None = None - self._filter_label: ttk.Label | None = None - self._dir_tooltip: ToolTip | None = None - self._browse_tooltip: ToolTip | None = None - self._recursive_tooltip: ToolTip | None = None - self._search_btn_tooltip: ToolTip | None = None - self._ext_tooltip: ToolTip | None = None - self._filter_tooltip: ToolTip | None = None - self._title_tooltip: ToolTip | None = None - - self._build() - register(self._apply_text) - - def _build(self) -> None: - """Build the search bar widgets.""" - self._search_frame = ttk.Frame(self._parent) - self._search_frame.pack(fill=tk.X, side=tk.TOP) - - # ── Title stacked vertically above directory/filters ───────── - self._title_frame = ttk.Frame(self._search_frame, padding=(16, 14, 16, 0)) - self._title_frame.pack(side=tk.TOP, fill=tk.X) - - title_text = f"ProFiles — {self._config_title}" if self._config_title else "ProFiles" - self._title_label = ttk.Label( - self._title_frame, - text=title_text, - style="Title.TLabel", - ) - self._title_label.pack(side=tk.LEFT, anchor=tk.W) - - self._title_author = ttk.Label( - self._title_frame, - text=f"By Florent ALBANY - v{self._release_version}", - style="TitleAuthor.TLabel", - ) - self._title_author.pack(side=tk.LEFT, anchor=tk.W, padx=(12, 0), pady=(6, 0)) - - self._title_tooltip = ToolTip(self._title_frame, t("title.tooltip")) - - # ── Controls below the title ───────────────────────── - self._controls_frame = ttk.Frame(self._search_frame, padding=(16, 10, 16, 14)) - self._controls_frame.pack(side=tk.TOP, fill=tk.X, expand=True) - - # --- Row 1: Directory --- - self._dir_frame = ttk.Frame(self._controls_frame) - self._dir_frame.pack(fill=tk.X, pady=(0, 10)) - - self._dir_label = ttk.Label( - self._dir_frame, - text=t("search.dir_label"), - width=9, - anchor=tk.W, - ) - self._dir_label.pack(side=tk.LEFT, padx=(0, 4)) - - self._dir_var = tk.StringVar() - self._dir_combo = ttk.Combobox( - self._dir_frame, - textvariable=self._dir_var, - state="normal", - width=60, - ) - self._dir_combo.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) - self._dir_combo.bind("<>", lambda _e=None: self._on_directory_changed()) - self._dir_combo.bind("", lambda _e=None: self._on_directory_enter()) - self._dir_combo.bind("", lambda _e=None: self._on_directory_double_click()) - self._dir_tooltip = ToolTip(self._dir_combo, t("search.dir.tooltip")) - - self._browse_btn = ttk.Button( - self._dir_frame, - text=t("search.browse"), - style="SearchBar.TButton", - width=12, - command=self._on_browse, - ) - self._browse_btn.pack(side=tk.RIGHT, padx=(0, 0)) - self._browse_tooltip = ToolTip(self._browse_btn, t("search.browse.tooltip")) - - # --- Row 2: Extension + Filter + Search --- - self._filter_frame = ttk.Frame(self._controls_frame) - self._filter_frame.pack(fill=tk.X) - - # Right-aligned group: Recursive check + Search button - right_group = ttk.Frame(self._filter_frame) - right_group.pack(side=tk.RIGHT) - - self._recursive_check = ttk.Checkbutton( - right_group, - text=t("search.recursive"), - variable=self._recursive_var, - command=self._on_recursive_toggle, - ) - self._recursive_check.pack(side=tk.LEFT, padx=(0, 12)) - self._recursive_tooltip = ToolTip( - self._recursive_check, - t("search.recursive.tooltip"), - ) - - self._search_btn = ttk.Button( - right_group, - text=t("search.search_btn"), - style="SearchBar.TButton", - width=12, - command=self._on_search, - ) - self._search_btn.pack(side=tk.LEFT, padx=(0, 0)) - self._search_btn_tooltip = ToolTip(self._search_btn, t("search.search_btn.tooltip")) - - # Extension label + combobox - self._ext_label = ttk.Label( - self._filter_frame, - text=t("search.ext_label"), - width=9, - anchor=tk.W, - ) - self._ext_label.pack(side=tk.LEFT, padx=(0, 4)) - - self._ext_var = tk.StringVar() - self._ext_combo = ttk.Combobox( - self._filter_frame, - textvariable=self._ext_var, - state="normal", - width=20, - ) - self._ext_combo.pack(side=tk.LEFT, padx=(0, 24)) - self._ext_combo.bind( - "<>", lambda _e=None: self._on_extension_or_filter_select() - ) - self._ext_combo.bind("", lambda _e=None: self._on_debounced_refresh_ext()) - self._ext_combo.bind("<>", lambda _e=None: self._on_flush_timer_ext()) - self._ext_tooltip = ToolTip(self._ext_combo, t("search.ext.tooltip")) - - # Filter label + expanding combobox - self._filter_label = ttk.Label( - self._filter_frame, - text=t("search.filter_label"), - ) - self._filter_label.pack(side=tk.LEFT, padx=(0, 4)) - - self._filter_var = tk.StringVar() - self._filter_combo = ttk.Combobox( - self._filter_frame, - textvariable=self._filter_var, - state="normal", - ) - self._filter_combo.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 24)) - self._filter_combo.bind( - "<>", lambda _e=None: self._on_extension_or_filter_select() - ) - self._filter_combo.bind("", lambda _e=None: self._on_filter_refresh()) - self._filter_combo.bind("<>", lambda _e=None: self._on_flush_timer_filter()) - self._filter_tooltip = ToolTip(self._filter_combo, t("search.filter.tooltip")) - - def _apply_text(self, lang: str | None = None) -> None: - """Re-label all translatable widgets in the search bar. - - Args: - lang: Ignored; ``t()`` reads the current language. Present so - this method matches the i18n registry callback signature. - """ - with contextlib.suppress(tk.TclError): - if self._dir_label is not None: - self._dir_label.configure(text=t("search.dir_label")) - if self._ext_label is not None: - self._ext_label.configure(text=t("search.ext_label")) - if self._filter_label is not None: - self._filter_label.configure(text=t("search.filter_label")) - self._browse_btn.configure(text=t("search.browse")) - self._recursive_check.configure(text=t("search.recursive")) - self._search_btn.configure(text=t("search.search_btn")) - if self._dir_tooltip is not None: - self._dir_tooltip.set_text(t("search.dir.tooltip")) - if self._browse_tooltip is not None: - self._browse_tooltip.set_text(t("search.browse.tooltip")) - if self._recursive_tooltip is not None: - self._recursive_tooltip.set_text(t("search.recursive.tooltip")) - if self._search_btn_tooltip is not None: - self._search_btn_tooltip.set_text(t("search.search_btn.tooltip")) - if self._ext_tooltip is not None: - self._ext_tooltip.set_text(t("search.ext.tooltip")) - if self._filter_tooltip is not None: - self._filter_tooltip.set_text(t("search.filter.tooltip")) - if self._title_tooltip is not None: - self._title_tooltip.set_text(t("title.tooltip")) - - # ---------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------- - - @property - def search_frame(self) -> ttk.Frame: - """Get the outer search bar frame.""" - return self._search_frame - - @property - def title_frame(self) -> ttk.Frame: - """Get the title frame.""" - return self._title_frame - - @property - def title_label(self) -> ttk.Label: - """Get the title label.""" - return self._title_label - - @property - def title_author(self) -> ttk.Label: - """Get the title author label.""" - return self._title_author - - @property - def controls_frame(self) -> ttk.Frame: - """Get the controls frame.""" - return self._controls_frame - - @property - def dir_frame(self) -> ttk.Frame: - """Get the directory frame.""" - return self._dir_frame - - @property - def dir_var(self) -> tk.StringVar: - """Get the directory variable.""" - return self._dir_var - - @property - def dir_combo(self) -> ttk.Combobox: - """Get the directory combobox.""" - return self._dir_combo - - @property - def browse_btn(self) -> ttk.Button: - """Get the browse button.""" - return self._browse_btn - - @property - def filter_frame(self) -> ttk.Frame: - """Get the filter frame.""" - return self._filter_frame - - @property - def recursive_var(self) -> tk.BooleanVar: - """Get the recursive variable.""" - return self._recursive_var - - @property - def recursive_check(self) -> ttk.Checkbutton: - """Get the recursive checkbox.""" - return self._recursive_check - - @property - def search_btn(self) -> ttk.Button: - """Get the search button.""" - return self._search_btn - - @property - def ext_var(self) -> tk.StringVar: - """Get the extension variable.""" - return self._ext_var - - @property - def ext_combo(self) -> ttk.Combobox: - """Get the extension combobox.""" - return self._ext_combo - - @property - def filter_var(self) -> tk.StringVar: - """Get the filter variable.""" - return self._filter_var - - @property - def filter_combo(self) -> ttk.Combobox: - """Get the filter combobox.""" - return self._filter_combo diff --git a/src/profiles/gui/status_bar.py b/src/profiles/gui/status_bar.py deleted file mode 100644 index fb220a2..0000000 --- a/src/profiles/gui/status_bar.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Status bar management for ProFiles. - -Provides a dedicated status bar component with tooltips for user, -hostname, IP, directory, and file count information. -""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from collections.abc import Callable -from tkinter import ttk - -from profiles.gui.i18n import LANGUAGE_LABELS, current_language, register, t -from profiles.gui.styles import ToolTip - - -class StatusBar: - """Status bar component for the main window. - - Displays system information (user, host, IP) and scan status - (directory, file count) with hover tooltips for each label. - """ - - def __init__( - self, - parent: ttk.Frame, - on_config_click: Callable[[], None], - on_refresh_click: Callable[[], None], - on_log_click: Callable[[], None], - on_theme_toggle: Callable[[], None], - theme_label: str = "☀ Light", - on_shortcuts_click: Callable[[], None] | None = None, - on_language_toggle: Callable[[], None] | None = None, - ) -> None: - """Initialize the status bar. - - Args: - parent: Parent frame to attach the status bar to. - on_config_click: Callback for config button. - on_refresh_click: Callback for refresh button. - on_log_click: Callback for log button. - on_theme_toggle: Callback for theme toggle button. - theme_label: Initial text for theme button. - on_shortcuts_click: Optional callback for shortcuts button. - on_language_toggle: Optional callback for language toggle button. - """ - self._parent = parent - self._on_config_click = on_config_click - self._on_refresh_click = on_refresh_click - self._on_log_click = on_log_click - self._on_theme_toggle = on_theme_toggle - self._on_shortcuts_click = on_shortcuts_click - self._on_language_toggle = on_language_toggle - self._theme_label = theme_label - - # Widget references - self._status_frame: ttk.Frame - self._status_inner: ttk.Frame - self._config_link: ttk.Button - self._refresh_btn: ttk.Button - self._log_link: ttk.Button - self._shortcuts_btn: ttk.Button | None - self._theme_btn: ttk.Button - self._language_btn: ttk.Button | None - self._user_label: ttk.Label - self._host_label: ttk.Label - self._ip_label: ttk.Label - self._count_label: ttk.Label - self._dir_status_label: ttk.Label - - # Translatable widget refs (set in _build, updated in _apply_text) - self._user_label_text: ttk.Label | None = None - self._host_label_text: ttk.Label | None = None - self._ip_label_text: ttk.Label | None = None - self._config_tooltip: ToolTip | None = None - self._refresh_tooltip: ToolTip | None = None - self._log_tooltip: ToolTip | None = None - self._shortcuts_tooltip: ToolTip | None = None - self._theme_tooltip: ToolTip | None = None - self._language_tooltip: ToolTip | None = None - self._user_tooltip: ToolTip | None = None - self._host_tooltip: ToolTip | None = None - self._ip_tooltip: ToolTip | None = None - self._count_tooltip: ToolTip | None = None - self._dir_status_tooltip: ToolTip | None = None - - self._build() - register(self._apply_text) - - def _build(self) -> None: - """Build the status bar widgets.""" - # Outer frame - self._status_frame = ttk.Frame( - self._parent, - style="Status.TFrame", - ) - self._status_frame.pack(fill=tk.X, side=tk.BOTTOM) - - self._status_inner = ttk.Frame( - self._status_frame, - style="Status.TFrame", - padding=(10, 4), - ) - self._status_inner.pack(fill=tk.X) - - # Config link - self._config_link = ttk.Button( - self._status_inner, - text=t("status.config"), - style="Link.TButton", - command=self._on_config_click, - ) - self._config_link.pack(side=tk.LEFT, padx=(0, 4)) - self._config_tooltip = ToolTip(self._config_link, t("status.config.tooltip")) - - # Refresh button - self._refresh_btn = ttk.Button( - self._status_inner, - text=t("status.refresh"), - style="Link.TButton", - command=self._on_refresh_click, - ) - self._refresh_btn.pack(side=tk.LEFT, padx=(4, 8)) - self._refresh_tooltip = ToolTip(self._refresh_btn, t("status.refresh.tooltip")) - - # Log link - self._log_link = ttk.Button( - self._status_inner, - text=t("status.log"), - style="Link.TButton", - command=self._on_log_click, - ) - self._log_link.pack(side=tk.LEFT, padx=(0, 10)) - self._log_tooltip = ToolTip(self._log_link, t("status.log.tooltip")) - - # Shortcuts button (optional) - self._shortcuts_btn = None - if self._on_shortcuts_click is not None: - self._shortcuts_btn = ttk.Button( - self._status_inner, - text=t("status.shortcuts"), - style="Link.TButton", - command=self._on_shortcuts_click, - ) - self._shortcuts_btn.pack(side=tk.LEFT, padx=(0, 4)) - self._shortcuts_tooltip = ToolTip( - self._shortcuts_btn, - t("status.shortcuts.tooltip"), - ) - else: - self._shortcuts_tooltip = None - - # Theme toggle button - self._theme_btn = ttk.Button( - self._status_inner, - text=self._theme_label, - style="Link.TButton", - command=self._on_theme_toggle, - ) - self._theme_btn.pack(side=tk.LEFT, padx=(0, 4)) - self._theme_tooltip = ToolTip(self._theme_btn, t("status.theme.tooltip")) - - # Language toggle button (optional) - self._language_btn = None - if self._on_language_toggle is not None: - self._language_btn = ttk.Button( - self._status_inner, - text=LANGUAGE_LABELS.get(current_language(), "\U0001f310"), - style="Link.TButton", - command=self._on_language_toggle, - ) - self._language_btn.pack(side=tk.LEFT, padx=(0, 12)) - self._language_tooltip = ToolTip( - self._language_btn, - t("status.language.tooltip"), - ) - else: - self._language_tooltip = None - - # Separator - ttk.Separator(self._status_inner, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=6) - - # User label with tooltip - self._user_label_text = ttk.Label( - self._status_inner, - text=t("status.user"), - style="Info.TLabel", - ) - self._user_label_text.pack(side=tk.LEFT, padx=(4, 2)) - self._user_label = ttk.Label( - self._status_inner, - text="...", - style="Value.TLabel", - anchor=tk.W, - ) - self._user_label.pack(side=tk.LEFT, padx=(0, 8)) - self._user_tooltip = ToolTip(self._user_label, t("status.user.tooltip")) - - # Host label with tooltip - self._host_label_text = ttk.Label( - self._status_inner, - text=t("status.host"), - style="Info.TLabel", - ) - self._host_label_text.pack(side=tk.LEFT, padx=(4, 2)) - self._host_label = ttk.Label( - self._status_inner, - text="...", - style="Value.TLabel", - anchor=tk.W, - ) - self._host_label.pack(side=tk.LEFT, padx=(0, 8)) - self._host_tooltip = ToolTip(self._host_label, t("status.host.tooltip")) - - # IP label with tooltip - self._ip_label_text = ttk.Label( - self._status_inner, - text=t("status.ip"), - style="Info.TLabel", - ) - self._ip_label_text.pack(side=tk.LEFT, padx=(0, 8)) - self._ip_label = ttk.Label( - self._status_inner, - text="...", - style="Value.TLabel", - anchor=tk.W, - ) - self._ip_label.pack(side=tk.LEFT, padx=(0, 8)) - self._ip_tooltip = ToolTip(self._ip_label, t("status.ip.tooltip")) - - # File count label with tooltip - self._count_label = ttk.Label( - self._status_inner, - text="Files: 0", - style="Info.TLabel", - ) - self._count_label.pack(side=tk.RIGHT, padx=(10, 0)) - self._count_tooltip = ToolTip(self._count_label, t("status.count.tooltip")) - - # Directory status label with tooltip - self._dir_status_label = ttk.Label( - self._status_inner, - text="", - style="Info.TLabel", - ) - self._dir_status_label.pack(side=tk.RIGHT, padx=(10, 10)) - self._dir_status_tooltip = ToolTip( - self._dir_status_label, - t("status.dir_status.tooltip"), - ) - - # ---------------------------------------------------------------- - # Public API - # ---------------------------------------------------------------- - - @property - def status_frame(self) -> ttk.Frame: - """Get the outer status bar frame.""" - return self._status_frame - - @property - def status_inner(self) -> ttk.Frame: - """Get the inner status bar container.""" - return self._status_inner - - @property - def config_link(self) -> ttk.Button: - """Get the config button.""" - return self._config_link - - @property - def refresh_btn(self) -> ttk.Button: - """Get the refresh button.""" - return self._refresh_btn - - @property - def log_link(self) -> ttk.Button: - """Get the log button.""" - return self._log_link - - @property - def theme_btn(self) -> ttk.Button: - """Get the theme toggle button.""" - return self._theme_btn - - @property - def language_btn(self) -> ttk.Button | None: - """Get the language toggle button, if created.""" - return self._language_btn - - @property - def shortcuts_btn(self) -> ttk.Button | None: - """Get the shortcuts button, if created.""" - return self._shortcuts_btn - - @property - def user_label(self) -> ttk.Label: - """Get the user label.""" - return self._user_label - - @property - def host_label(self) -> ttk.Label: - """Get the host label.""" - return self._host_label - - @property - def ip_label(self) -> ttk.Label: - """Get the IP label.""" - return self._ip_label - - @property - def count_label(self) -> ttk.Label: - """Get the file count label.""" - return self._count_label - - @property - def dir_status_label(self) -> ttk.Label: - """Get the directory status label.""" - return self._dir_status_label - - @property - def dir_status_tooltip(self) -> ToolTip | None: - """Get the directory status tooltip.""" - return self._dir_status_tooltip - - def update_theme_label(self, label: str) -> None: - """Update the theme button label. - - Args: - label: New text for the theme button. - """ - self._theme_btn.configure(text=label) - self._theme_btn.update_idletasks() - - def update_language_label(self) -> None: - """Refresh the language button label from the current language.""" - if self._language_btn is not None: - self._language_btn.configure( - text=LANGUAGE_LABELS.get(current_language(), "\U0001f310"), - ) - self._language_btn.update_idletasks() - - def _apply_text(self, lang: str | None = None) -> None: - """Re-label all translatable widgets in the status bar. - - Args: - lang: Ignored; ``t()`` reads the current language. Present so - this method matches the i18n registry callback signature. - """ - del lang # Intentionally unused - matches i18n callback signature - with contextlib.suppress(tk.TclError): - self._config_link.configure(text=t("status.config")) - self._refresh_btn.configure(text=t("status.refresh")) - self._log_link.configure(text=t("status.log")) - if self._shortcuts_btn is not None: - self._shortcuts_btn.configure(text=t("status.shortcuts")) - if self._user_label_text is not None: - self._user_label_text.configure(text=t("status.user")) - if self._host_label_text is not None: - self._host_label_text.configure(text=t("status.host")) - if self._ip_label_text is not None: - self._ip_label_text.configure(text=t("status.ip")) - if self._config_tooltip is not None: - self._config_tooltip.set_text(t("status.config.tooltip")) - if self._refresh_tooltip is not None: - self._refresh_tooltip.set_text(t("status.refresh.tooltip")) - if self._log_tooltip is not None: - self._log_tooltip.set_text(t("status.log.tooltip")) - if self._shortcuts_tooltip is not None: - self._shortcuts_tooltip.set_text(t("status.shortcuts.tooltip")) - if self._theme_tooltip is not None: - self._theme_tooltip.set_text(t("status.theme.tooltip")) - if self._language_tooltip is not None: - self._language_tooltip.set_text(t("status.language.tooltip")) - if self._user_tooltip is not None: - self._user_tooltip.set_text(t("status.user.tooltip")) - if self._host_tooltip is not None: - self._host_tooltip.set_text(t("status.host.tooltip")) - if self._ip_tooltip is not None: - self._ip_tooltip.set_text(t("status.ip.tooltip")) - if self._count_tooltip is not None: - self._count_tooltip.set_text(t("status.count.tooltip")) - if self._dir_status_tooltip is not None: - self._dir_status_tooltip.set_text(t("status.dir_status.tooltip")) - self.update_language_label() diff --git a/src/profiles/gui/styles.py b/src/profiles/gui/styles.py deleted file mode 100644 index d91cd7b..0000000 --- a/src/profiles/gui/styles.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Centralized styling for ProFiles GUI. - -All colour themes are defined in ``theme.py``. This module re-exports -the ``ToolTip`` utility class and provides the ``configure_styles()`` -entry point for applying a Material Design 3 theme to the Tkinter -application. -""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from tkinter import ttk - -from profiles.gui.theme import THEMES, Md3Theme, apply_theme, resolve_theme_name - -# ── Current theme tracking ────────────────────────────────────────────────── - -_current_theme: Md3Theme = THEMES["light"] - - -# ── Convenience accessors (for dynamic use) ───────────────────────────────── - - -def current_theme() -> Md3Theme: - """Return the currently active theme.""" - return _current_theme - - -def configure_styles(root: tk.Tk | None, theme_name: str = "light") -> Md3Theme: - """Configure ttk styles and the active theme reference. - - Applies the named Material Design 3 theme to all ttk widgets (via - :func:`profiles.gui.theme.apply_theme`) and updates the internal - theme reference so :func:`current_theme` reflects the change. - - Args: - root: The root ``tk.Tk`` window. When ``None`` (e.g. headless - tests) only the in-memory theme reference is updated. - theme_name: One of ``"light"``, ``"dark"``, or ``"auto"``. - - Returns: - The applied Md3Theme instance. - """ - global _current_theme - resolved = resolve_theme_name(theme_name) - theme = THEMES.get(resolved, THEMES["light"]) - _current_theme = theme - if root is not None: - apply_theme(root, theme) - return theme - - -# ── Tooltip ───────────────────────────────────────────────────────────────── - - -class ToolTip: - """A hover tooltip attached to any tkinter widget. - - Usage:: - - ToolTip(widget, "Helpful text") - """ - - def __init__(self, widget: tk.Widget, text: str, delay_ms: int = 400) -> None: - self._widget = widget - self._text = text - self.delay_ms = delay_ms - self._tip: tk.Toplevel | None = None - self._after_id: str | None = None - widget.bind("", self._schedule_show, add=True) - widget.bind("", self._hide, add=True) - widget.bind("", self._hide, add=True) - - def _schedule_show(self, _event: tk.Event | None = None) -> None: - if self._after_id is not None: - with contextlib.suppress(tk.TclError): - self._widget.after_cancel(self._after_id) - self._after_id = None - self._after_id = self._widget.after(self.delay_ms, self._show) - - def _show(self, _event: tk.Event | None = None) -> None: - self._after_id = None - if self._tip is not None or not self._text: - return - x = self._widget.winfo_rootx() + 16 - y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4 - self._tip = tk.Toplevel(self._widget) - self._tip.wm_overrideredirect(True) - self._tip.wm_geometry(f"+{x}+{y}") - label = ttk.Label( - self._tip, - text=self._text, - style="Tooltip.TLabel", - ) - label.pack() - - def _hide(self, _event: tk.Event | None = None) -> None: - if self._after_id is not None: - with contextlib.suppress(tk.TclError): - self._widget.after_cancel(self._after_id) - self._after_id = None - if self._tip is not None: - self._tip.destroy() - self._tip = None - - def set_text(self, text: str) -> None: - """Update the tooltip text. Refreshes an already-visible tip.""" - self._text = text - if self._tip is not None: - for child in self._tip.winfo_children(): - with contextlib.suppress(tk.TclError): - child.configure(text=text) diff --git a/src/profiles/gui/theme.py b/src/profiles/gui/theme.py deleted file mode 100644 index ece5831..0000000 --- a/src/profiles/gui/theme.py +++ /dev/null @@ -1,688 +0,0 @@ -"""Material Design 3 theme definitions for ProFiles. - -Provides light and dark colour themes following Google's Material Design 3 -guidelines, with a dedicated ``Md3Theme`` dataclass and helper functions -for applying themes to the Tkinter application. -""" - -from __future__ import annotations - -import contextlib -import logging -import tkinter as tk -from dataclasses import dataclass -from tkinter import ttk - -from profiles.core.telemetry import events - -# ── Theme colour palette ──────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Md3Theme: - """A complete Material Design 3 colour theme. - - All colour values are 7-character hex strings (e.g. ``#1565C0``). - """ - - # Primary / Secondary - primary: str - on_primary: str - primary_container: str - on_primary_container: str - secondary: str - on_secondary: str - - # Surface tones - surface: str - surface_container: str - surface_dim: str - - # On-surface colours - on_surface: str - on_surface_variant: str - - # Outline - outline: str - outline_variant: str - - # Background - background: str - on_background: str - - # Error - error: str - on_error: str - error_container: str - - # Status / message semantic colours (surface-level feedback) - status_info: str - status_info_fg: str - status_warning: str - status_warning_fg: str - status_error: str - status_error_fg: str - status_success: str - status_success_fg: str - - # Link styling - link: str - link_hover: str - - # Opacity / elevation steps (light: higher number = lighter) - elevation_0: str - elevation_1: str - - # Application-specific - header_bg: str - header_fg: str - status_bg: str - border: str - prod: str - dev: str - green: str - green_hover: str - - # Interactive states - hover: str - selected: str - - # Tooltip & focus - tooltip_bg: str - tooltip_fg: str - tooltip_border: str - focus_ring: str - - -# ── Theme instances ───────────────────────────────────────────────────────── - -LIGHT_THEME = Md3Theme( - # Primary - primary="#005fb8", - on_primary="#FFFFFF", - primary_container="#D1E4FF", - on_primary_container="#001D36", - # Secondary - secondary="#625B71", - on_secondary="#FFFFFF", - # Surface - surface="#fafafa", - surface_container="#f3f3f3", - surface_dim="#eaeaea", - # On-surface - on_surface="#1c1c1c", - on_surface_variant="#49454F", - # Outline (≥3:1 on surface per WCAG 1.4.11) - outline="#79747E", - outline_variant="#D0D1DA", - # Background - background="#fafafa", - on_background="#1c1c1c", - # Error - error="#B3261E", - on_error="#FFFFFF", - error_container="#F9DEDC", - # Semantic status surfaces - status_info="#FFF7ED", - status_info_fg="#7C2D12", - status_warning="#FFFBEB", - status_warning_fg="#92400E", - status_error="#FEF2F2", - status_error_fg="#B91C1C", - status_success="#DCFCE8", - status_success_fg="#15803D", - # Links - link="#005fb8", - link_hover="#004791", - # Elevation steps - elevation_0="#fafafa", - elevation_1="#f3f3f3", - # App-specific - header_bg="#1F1F1F", - header_fg="#FFFFFF", - status_bg="#f3f3f3", - border="#8A8A8A", # ≥3:1 on #fafafa - prod="#005fb8", - dev="#757575", - green="#16A34A", - green_hover="#15803D", - # Interactive - hover="#eaeaea", - selected="#2f60d8", - # Tooltip & focus - tooltip_bg="#212121", - tooltip_fg="#fafafa", - tooltip_border="#212121", - focus_ring="#005fb8", -) - -DARK_THEME = Md3Theme( - # Primary - Brighter, more vibrant - primary="#57c8ff", - on_primary="#003258", - primary_container="#1A497D", - on_primary_container="#D1E4FF", - # Secondary - Lighter for better contrast - secondary="#D0C8E0", - on_secondary="#382E45", - # Surface tones (elevated steps) - surface="#121212", - surface_container="#2A2A2A", - surface_dim="#0A0A0A", - # On-surface - Better readability - on_surface="#E8E6EA", - on_surface_variant="#C4BFC9", - # Outline — Subtle, minimal borders (≥3:1 on surface) - outline="#8A8591", - outline_variant="#5B5866", - # Background - Warmer tone - background="#0F0F10", - on_background="#E8E6EA", - # Error - error="#F2B8B5", - on_error="#601416", - error_container="#8C1D18", - # Semantic status surfaces - status_info="#0F1C2E", - status_info_fg="#B3D4F5", - status_warning="#1A150E", - status_warning_fg="#FCD34D", - status_error="#2B0A08", - status_error_fg="#FCA5A5", - status_success="#0A1F12", - status_success_fg="#86EFAC", - # Links - link="#57c8ff", - link_hover="#8ED5FF", - # Elevation steps (dark: higher number = darker) - elevation_0="#101014", - elevation_1="#1c1c1c", - # App-specific - Modern flat colors - header_bg="#1F1E24", - header_fg="#E8E3E7", - status_bg="#2d2d2d", - border="#7A7680", # ≥3:1 on #0F0F10 - prod="#57c8ff", - dev="#C4BFC9", - green="#22C55E", - green_hover="#16A34A", - # Interactive - Slightly more visible hover - hover="#2d2d2d", - selected="#2f60d8", - # Tooltip & focus - tooltip_bg="#fafafa", - tooltip_fg="#1c1c1c", - tooltip_border="#fafafa", - focus_ring="#57c8ff", -) - -# ── Lookup maps ───────────────────────────────────────────────────────────── - -THEMES: dict[str, Md3Theme] = { - "light": LIGHT_THEME, - "dark": DARK_THEME, -} - -THEME_LABELS: dict[str, str] = { - "light": "\u2600 Light", - "dark": "\u263e Dark", - "auto": "\u25d0 Auto", -} - -# ── Font configuration (shared across themes) ─────────────────────────────── - -FONT_FAMILY = "Segoe UI" -FONT_SIZE_SMALL = 9 -FONT_SIZE_NORMAL = 11 -FONT_SIZE_LARGE = 13 - - -def resolve_theme_name(config_theme: str) -> str: - """Resolve theme name, handling 'auto' detection using darkdetect if requested.""" - if config_theme == "auto": - try: - import darkdetect - - detect_val = darkdetect.theme() - if detect_val and detect_val.lower() == "dark": - return "dark" - except Exception: - pass - return "light" - return config_theme - - -# ── WCAG contrast helpers ─────────────────────────────────────────────────── - - -def hex_luminance(hex_color: str) -> float: - """Return WCAG relative luminance (0..1) of a #RRGGBB hex color. - - Tolerates a missing leading ``#``. Returns 0.5 on parse failure so - callers can fall back to a neutral mid-grey comparison rather than - crashing. - """ - if not isinstance(hex_color, str): - return 0.5 - raw = hex_color.lstrip("#") - if len(raw) != 6: - return 0.5 - try: - channels = (int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)) - except ValueError: - return 0.5 - - def linear(channel: int) -> float: - srgb = channel / 255.0 - return srgb / 12.92 if srgb <= 0.03928 else ((srgb + 0.055) / 1.055) ** 2.4 - - r, g, b = (linear(c) for c in channels) - return 0.2126 * r + 0.7152 * g + 0.0722 * b - - -def contrast_ratio(fg_hex: str, bg_hex: str) -> float: - """Return WCAG contrast ratio (1.0..21.0) between two #RRGGBB colors.""" - fg_lum = hex_luminance(fg_hex) - bg_lum = hex_luminance(bg_hex) - lighter, darker = max(fg_lum, bg_lum), min(fg_lum, bg_lum) - return (lighter + 0.05) / (darker + 0.05) - - -def assert_contrast( - fg_hex: str, - bg_hex: str, - *, - min_ratio: float = 4.5, - label: str = "", -) -> bool: - """Return True if contrast meets ``min_ratio`` (WCAG AA for normal text). - - Used by :func:`apply_theme` for audit logs only — never raises. - """ - ratio = contrast_ratio(fg_hex, bg_hex) - return ratio >= min_ratio - - -def apply_theme(root: tk.Tk, theme: Md3Theme) -> ttk.Style: - # pylint: disable=too-many-statements - """Apply a Material Design 3 theme or sv-ttk theme to the application. - - Updates both the Tk root palette and all ttk styles to match the - given theme colours. - - Args: - root: The root Tk window. - theme: An Md3Theme instance (light or dark). - - Returns: - The configured ttk.Style instance. - """ - # ── Base theme (sv-ttk preferred; clam always gives us full control) ── - style = ttk.Style() - try: - import sv_ttk - - sv_ttk.set_theme("dark" if theme.surface == DARK_THEME.surface else "light") - except Exception: - style.theme_use("clam") - - # --- Tk palette (affects native tk widgets) --- - root.tk_setPalette( - background=theme.surface, - foreground=theme.on_surface, - selectColor=theme.primary, - selectBackground=theme.primary, - ) - - # --- Native dialog (messagebox) theming via option database --- - # Tk's messagebox is a native widget that ignores ttk styles, but it - # reads from the option database. Setting *Dialog* defaults here - # propagates to all subsequently-shown dialogs without per-call - # work, since messagebox re-reads these at popup time. - option_add = getattr(root, "option_add", None) - if callable(option_add): - option_add("*Dialog*background", theme.surface_container) - option_add("*Dialog*foreground", theme.on_surface) - option_add("*Dialog*Button.background", theme.primary) - option_add("*Dialog*Button.foreground", theme.on_primary) - - def _font(size: int = FONT_SIZE_NORMAL, *, bold: bool = False) -> tuple: - opts: tuple = (FONT_FAMILY, size) - if bold: - opts = opts + ("bold",) - return opts - - def _btn(style_name: str, fg: str, bg: str, pad: tuple[int, int]) -> None: - style.configure( - style_name, - font=_font(FONT_SIZE_NORMAL), - padding=pad, - borderwidth=0, - focusthickness=0, - background=bg, - foreground=fg, - relief="flat", - ) - style.map( - style_name, - background=[("active", theme.hover), ("pressed", theme.surface_dim)], - foreground=[("active", fg), ("pressed", fg)], - ) - - # ── Base widget styles (always apply — not only on sv-ttk failure) ── - style.configure("TFrame", background=theme.surface) - style.configure( - "TLabel", - background=theme.surface, - foreground=theme.on_surface, - font=_font(), - ) - _btn("TButton", theme.on_surface, theme.surface_container, (14, 6)) - style.configure( - "TCombobox", - font=_font(), - fieldbackground=theme.surface, - background=theme.surface, - foreground=theme.on_surface, - arrowcolor=theme.on_surface, - borderwidth=0, - relief="flat", - ) - style.map( - "TCombobox", - fieldbackground=[("focus", theme.primary_container)], - foreground=[("focus", theme.on_primary_container)], - ) - style.configure( - "TCombobox.Listbox", - font=_font(), - background=theme.surface, - foreground=theme.on_surface, - borderwidth=0, - selectbackground=theme.primary, - selectforeground=theme.on_primary, - ) - style.configure( - "TCheckbutton", - font=_font(), - background=theme.surface, - foreground=theme.on_surface, - padding=(6, 4), - ) - style.map( - "TCheckbutton", - background=[("active", theme.surface)], - indicatorbackground=[("active", theme.surface_container), ("selected", theme.primary)], - indicatorforeground=[("selected", theme.on_primary)], - ) - style.configure("TSeparator", background=theme.outline_variant, borderwidth=0) - for orient in ("Vertical", "Horizontal"): - style.configure( - f"{orient}.TScrollbar", - background=theme.surface_container, - troughcolor=theme.surface, - bordercolor=theme.surface_container, - relief="flat", - borderwidth=0, - arrowsize=0, - width=14, - ) - style.map( - f"{orient}.TScrollbar", - background=[("active", theme.primary), ("pressed", theme.surface_dim)], - troughcolor=[("active", theme.surface_container)], - ) - style.configure("TMenu", background=theme.surface, foreground=theme.on_surface) - style.configure("TEntry", fieldbackground=theme.surface, foreground=theme.on_surface) - - # ── Focus ring: highlight keyboard focus on all interactive widgets ── - focus = theme.focus_ring - for sname in ("TButton", "TCombobox", "TEntry", "TCheckbutton"): - with contextlib.suppress(tk.TclError): - style.map( - sname, focuscolor=[("focus", focus)] - ) # some themes ignore focuscolor — harmless - - # ── Custom Sub-Styles (always apply on top of base theme) ────── - style.configure( - "Status.TFrame", - background=theme.status_bg, - ) - style.configure( - "Title.TLabel", - font=_font(20, bold=True), - foreground=theme.primary, - background=theme.surface, - ) - style.configure( - "TitleAuthor.TLabel", - font=_font(FONT_SIZE_SMALL), - foreground=theme.outline, - background=theme.surface, - ) - - # ── Treeview (Flat, no borders, modern look) ───────────────────── - style.configure( - "FileList.Treeview", - font=_font(), - rowheight=36, - borderwidth=0, - fieldbackground=theme.surface, - background=theme.surface, - foreground=theme.on_surface, - focuscolor="", - relief="flat", - selectborderwidth=0, - ) - style.configure( - "FileList.Treeview.Heading", - font=_font(bold=True), - borderwidth=0, - relief="flat", - background=theme.surface_container, - foreground=theme.on_surface, - padding=(8, 6), - ) - style.map( - "FileList.Treeview.Heading", - background=[("active", theme.hover)], - ) - style.map( - "FileList.Treeview", - background=[("selected", theme.primary)], - foreground=[("selected", theme.on_primary)], - fieldbackground=[("selected", theme.primary)], - ) - - # ── Execute button: prominent accent button ────────────────────── - style.configure( - "Execute.Accent.TButton", - font=_font(FONT_SIZE_LARGE, bold=True), - padding=(24, 8), - borderwidth=0, - focusthickness=0, - background=theme.green, - foreground=theme.on_primary, - relief="flat", - ) - style.map( - "Execute.Accent.TButton", - background=[("active", theme.green_hover), ("pressed", theme.surface_dim)], - foreground=[("active", theme.on_primary), ("pressed", theme.on_primary)], - ) - - # ── Small status-bar button (Value.TButton) ────────────────────── - style.configure( - "Value.TButton", - font=_font(FONT_SIZE_SMALL, bold=True), - padding=(8, 3), - borderwidth=0, - focusthickness=0, - background=theme.status_bg, - foreground=theme.on_surface, - relief="flat", - ) - style.map( - "Value.TButton", - background=[("active", theme.surface_container), ("pressed", theme.surface_dim)], - foreground=[("active", theme.primary), ("pressed", theme.primary)], - ) - - # ── Theme toggle button (smaller, status-bar style) ────────────── - style.configure( - "Theme.TButton", - font=_font(FONT_SIZE_SMALL, bold=True), - padding=(10, 3), - borderwidth=0, - focusthickness=0, - background=theme.status_bg, - foreground=theme.on_surface, - relief="flat", - ) - style.map( - "Theme.TButton", - background=[("active", theme.surface_container), ("pressed", theme.surface_dim)], - foreground=[("active", theme.primary), ("pressed", theme.primary)], - ) - - # ── Search-bar buttons (height matches combobox fields) ────────── - style.configure( - "SearchBar.TButton", - font=_font(), - padding=(14, 5), - borderwidth=0, - focusthickness=0, - background=theme.surface_container, - foreground=theme.on_surface, - relief="flat", - ) - style.map( - "SearchBar.TButton", - background=[("active", theme.hover), ("pressed", theme.surface_dim)], - foreground=[("active", theme.primary), ("pressed", theme.primary)], - ) - - # ── Link button (status bar / dialogs) ─────────────────────────── - style.configure( - "Link.TButton", - font=_font(), - padding=0, - borderwidth=0, - focusthickness=0, - background=theme.surface, - foreground=theme.link, - relief="flat", - underline=True, - ) - style.map( - "Link.TButton", - foreground=[("active", theme.link_hover), ("pressed", theme.link_hover)], - background=[("active", theme.surface), ("pressed", theme.surface)], - ) - - # ── Status / message label variants ────────────────────────────── - style.configure( - "Header.TLabel", - font=_font(FONT_SIZE_LARGE, bold=True), - foreground=theme.header_fg, - background=theme.header_bg, - padding=(12, 8), - ) - style.configure( - "Status.TLabel", - font=_font(FONT_SIZE_SMALL), - background=theme.status_bg, - foreground=theme.on_surface, - padding=(4, 2), - ) - style.configure( - "Info.TLabel", - font=_font(FONT_SIZE_SMALL), - background=theme.status_bg, - foreground=theme.on_surface, - padding=(2, 0), - ) - style.configure( - "Value.TLabel", - font=_font(FONT_SIZE_SMALL, bold=True), - background=theme.status_bg, - foreground=theme.on_surface, - padding=(2, 0), - ) - style.configure( - "HighlightCount.TLabel", - font=_font(FONT_SIZE_SMALL, bold=True), - background=theme.primary_container, - foreground=theme.primary, - padding=(6, 2), - ) - style.configure( - "EmptyState.TLabel", - font=_font(FONT_SIZE_LARGE), - foreground=theme.outline, - background=theme.surface, - justify="center", - anchor="center", - ) - - # ── Semantic status labels (info / warning / error / success) ─── - def _status_label(name: str, bg: str, fg: str) -> None: - style.configure( - name, - font=_font(FONT_SIZE_SMALL, bold=True), - background=bg, - foreground=fg, - padding=(4, 2), - relief="flat", - ) - - _status_label("Status.Info.TLabel", theme.status_info, theme.status_info_fg) - _status_label("Status.Warning.TLabel", theme.status_warning, theme.status_warning_fg) - _status_label("Status.Error.TLabel", theme.status_error, theme.status_error_fg) - _status_label("Status.Success.TLabel", theme.status_success, theme.status_success_fg) - - # ── Tooltip (themed; inverted on dark for legibility) ─────────── - style.configure( - "Tooltip.TLabel", - font=_font(FONT_SIZE_SMALL), - background=theme.tooltip_bg, - foreground=theme.tooltip_fg, - bordercolor=theme.tooltip_border, - borderwidth=1, - relief="solid", - padding=(6, 2), - ) - - # ── Combobox focus ring ────────────────────────────────────────── - style.configure( - "Focus.TCombobox", - fieldbackground=theme.primary_container, - foreground=theme.on_primary_container, - bordercolor=theme.focus_ring, - lightcolor=theme.focus_ring, - darkcolor=theme.focus_ring, - ) - - # ── WCAG contrast audit (logs warnings, never raises) ─────────── - audit_pairs = ( - ("on_surface/surface", theme.on_surface, theme.surface), - ("on_surface_variant/surface", theme.on_surface_variant, theme.surface), - ("on_primary/primary", theme.on_primary, theme.primary), - ("on_surface/status_bg", theme.on_surface, theme.status_bg), - ("outline/surface", theme.outline, theme.surface), - ("border/surface", theme.border, theme.surface), - ("on_surface/outline_variant", theme.on_surface, theme.outline_variant), - ) - audit_logger = logging.getLogger("profiles") - for label, fg, bg in audit_pairs: - ratio = contrast_ratio(fg, bg) - if ratio < 4.5: - events.wcag_contrast_faint( - audit_logger, - pair=label, - ratio=f"{ratio:.2f}", - fg=fg, - bg=bg, - ) - - return style diff --git a/src/profiles/gui/ui.py b/src/profiles/gui/ui.py deleted file mode 100644 index 4f16d8f..0000000 --- a/src/profiles/gui/ui.py +++ /dev/null @@ -1,319 +0,0 @@ -"""UI building helpers for MainWindow.""" - -# pylint: disable=protected-access - -from __future__ import annotations - -import sys -import tkinter as tk -from tkinter import ttk -from typing import TYPE_CHECKING - -from profiles.gui.search_bar import SearchBar -from profiles.gui.status_bar import StatusBar -from profiles.gui.styles import ToolTip - -if TYPE_CHECKING: - from profiles.gui.main_window import MainWindow - - -class MainWindowUI: - """Helper class to construct the layout and widgets of MainWindow.""" - - def __init__(self, window: MainWindow) -> None: - """Initialize the UI builder. - - Args: - window: The parent MainWindow instance to populate. - """ - self.window = window - - def build(self) -> None: - """Build all UI components.""" - self._build_header() - self._build_search_bar() - self._build_file_list() - self._build_action_bar() - self._build_status_bar() - - def _build_status_bar(self) -> None: - """Build the status bar with tooltips. - - Creates a StatusBar instance and assigns widget references - to the window for access. - """ - from profiles.gui.theme import THEME_LABELS - - self.window._status_bar = StatusBar( - parent=self.window._root, - on_config_click=self.window._on_open_config, - on_refresh_click=self.window._on_refresh, - on_log_click=self.window._on_open_log, - on_theme_toggle=self.window._on_toggle_theme, - on_shortcuts_click=self.window._on_show_shortcuts, - on_language_toggle=self.window._on_toggle_language, - theme_label=THEME_LABELS.get(self.window._theme_name, "\u2600 Light"), - ) - - # Assign widget references - self.window._status_frame = self.window._status_bar.status_frame - self.window._status_inner = self.window._status_bar.status_inner - self.window._config_link = self.window._status_bar.config_link - self.window._refresh_btn = self.window._status_bar.refresh_btn - self.window._log_link = self.window._status_bar.log_link - self.window._shortcuts_btn = self.window._status_bar.shortcuts_btn - self.window._theme_btn = self.window._status_bar.theme_btn - self.window._user_label = self.window._status_bar.user_label - self.window._host_label = self.window._status_bar.host_label - self.window._ip_label = self.window._status_bar.ip_label - self.window._count_label = self.window._status_bar.count_label - self.window._dir_status_label = self.window._status_bar.dir_status_label - self.window._dir_status_tooltip = self.window._status_bar.dir_status_tooltip - - def _build_header(self) -> None: - """Build the application header separator.""" - # Horizontal outline-colored separator line using a standard Frame or Separator - self.window._header_frame = ttk.Separator( - self.window._root, - orient="horizontal", - ) - self.window._header_frame.pack(fill=tk.X, side=tk.TOP, pady=(4, 0)) - - def _build_search_bar(self) -> None: - """Build the search/filter bar with app title, directory, extension, and filter controls.""" - self.window._search_bar = SearchBar( - parent=self.window._root, - release_version=self.window._config.release, - recursive_var=self.window._recursive_var, - on_directory_changed=self.window._on_directory_changed, - on_directory_enter=self.window._on_directory_enter, - on_directory_double_click=self.window._on_directory_double_click, - on_browse=self.window._on_browse, - on_extension_or_filter_select=self.window._on_extension_or_filter_select, - on_debounced_refresh_ext=lambda: self.window._debounced_refresh("_ext_timer"), - on_flush_timer_ext=lambda: self.window._flush_timer("_ext_timer"), - on_filter_refresh=lambda: self.window._debounced_refresh("_filter_timer"), - on_flush_timer_filter=lambda: self.window._flush_timer("_filter_timer"), - on_recursive_toggle=self.window._on_recursive_toggle, - on_search=self.window._refresh_file_list, - config_title=self.window._config.title, - ) - - # Assign widget references - self.window._search_frame = self.window._search_bar.search_frame - self.window._title_frame = self.window._search_bar.title_frame - self.window._title_label = self.window._search_bar.title_label - self.window._title_author = self.window._search_bar.title_author - self.window._controls_frame = self.window._search_bar.controls_frame - self.window._dir_frame = self.window._search_bar.dir_frame - self.window._dir_var = self.window._search_bar.dir_var - self.window._dir_combo = self.window._search_bar.dir_combo - self.window._browse_btn = self.window._search_bar.browse_btn - self.window._filter_frame = self.window._search_bar.filter_frame - self.window._recursive_var = self.window._search_bar.recursive_var - self.window._recursive_check = self.window._search_bar.recursive_check - self.window._search_btn = self.window._search_bar.search_btn - self.window._ext_var = self.window._search_bar.ext_var - self.window._ext_combo = self.window._search_bar.ext_combo - self.window._filter_var = self.window._search_bar.filter_var - self.window._filter_combo = self.window._search_bar.filter_combo - - def _build_file_list(self) -> None: - """Build the file list treeview with scrollbars, focus ring, and empty state placeholder.""" - list_frame = ttk.Frame(self.window._root, padding=(16, 0)) - list_frame.pack(fill=tk.BOTH, expand=True, side=tk.TOP, pady=(0, 8)) - - # Outer container needs background & outline styling via ttk - self.window._list_container = ttk.Frame( - list_frame, - style="TFrame", - ) - self.window._list_container.pack(fill=tk.BOTH, expand=True) - - # Treeview - self.window._tree = ttk.Treeview( - self.window._list_container, - columns=self.window._config.column_names, - show="headings", - selectmode="extended", - style="FileList.Treeview", - ) - - # Sort state: tracks current sort column and direction - self.window._sort_state = {} - - # Configure columns with stretch control and sort-on-click - for i, (header, width, stretch) in enumerate( - zip( - self.window._config.column_headers, - self.window._config.column_widths, - self.window._config.column_stretches, - strict=True, - ) - ): - self.window._tree.heading( - i, - text=header, - anchor=tk.W, - command=lambda idx=i: self.window._sort_treeview(idx), - ) - self.window._tree.column( - i, - width=width, - minwidth=50, - anchor=tk.W, - stretch=stretch, # Apply per-column stretch behavior - ) - - # Scrollbars - self.window._v_scroll = ttk.Scrollbar( - self.window._list_container, - orient=tk.VERTICAL, - command=self.window._tree.yview, - ) - self.window._h_scroll = ttk.Scrollbar( - self.window._list_container, - orient=tk.HORIZONTAL, - command=self.window._tree.xview, - ) - self.window._tree.configure( - yscrollcommand=self.window._v_scroll.set, - xscrollcommand=self.window._h_scroll.set, - ) - - self.window._tree.grid(row=0, column=0, sticky="nsew") - self.window._v_scroll.grid(row=0, column=1, sticky="ns") - self.window._h_scroll.grid(row=1, column=0, sticky="ew") - - self.window._list_container.grid_rowconfigure(0, weight=1) - self.window._list_container.grid_columnconfigure(0, weight=1) - - # Bind events - self.window._tree.bind("", self.window._on_execute) - self.window._tree.bind("", self.window._on_execute) - self.window._tree.bind("", self.window._on_key_up) - self.window._tree.bind("", self.window._on_key_down) - # Right-click context menu (Button-3 = right click, Button-2 = middle click on macOS) - self.window._tree.bind("", self.window._on_tree_right_click) - self.window._tree.bind("", self.window._on_tree_right_click) - - # On macOS, one-button mice use Control+Click. Button-3 doesn't fire. - if sys.platform == "darwin": - self.window._tree.bind("", self.window._on_tree_right_click) - - # ── Smooth mouse-wheel scrolling ───────────────────────────── - def _on_mousewheel(event: tk.Event) -> str: - # macOS sends delta in multiples of 1; Windows/Linux in 120s - if event.delta: - units = -1 * (event.delta // (1 if abs(event.delta) < 10 else 120)) - elif event.num == 4: # Linux scroll up - units = -3 - elif event.num == 5: # Linux scroll down - units = 3 - else: - units = 0 - self.window._tree.yview_scroll(units, "units") - return "break" - - self.window._tree.bind("", _on_mousewheel) # macOS / Windows - self.window._tree.bind("", _on_mousewheel) # Linux scroll up - self.window._tree.bind("", _on_mousewheel) # Linux scroll down - - # ── Horizontal pan (Shift+scroll / trackpad swipe) ─────────── - def _on_mousewheel_h(event: tk.Event) -> str: - if event.delta: - units = -1 * (event.delta // (1 if abs(event.delta) < 10 else 120)) - else: - units = 0 - self.window._tree.xview_scroll(units, "units") - return "break" - - self.window._tree.bind("", _on_mousewheel_h) - - # ── Page / Home / End navigation ───────────────────────────── - def _nav_page(direction: int) -> str: - children = self.window._tree.get_children() - if not children: - return "break" - sel = self.window._tree.selection() - idx = children.index(sel[0]) if sel and sel[0] in children else 0 - # Approximate visible rows from widget height and row height - visible = max(1, self.window._tree.winfo_height() // 36) - target = max(0, min(len(children) - 1, idx + direction * visible)) - item = children[target] - self.window._tree.selection_set(item) - self.window._tree.focus(item) - self.window._tree.see(item) - return "break" - - def _nav_edge(end: bool) -> str: - children = self.window._tree.get_children() - if not children: - return "break" - item = children[-1] if end else children[0] - self.window._tree.selection_set(item) - self.window._tree.focus(item) - self.window._tree.see(item) - return "break" - - self.window._tree.bind("", lambda e: _nav_page(-1)) # Page Up - self.window._tree.bind("", lambda e: _nav_page(1)) # Page Down - self.window._tree.bind("", lambda e: _nav_edge(False)) - self.window._tree.bind("", lambda e: _nav_edge(True)) - - # Tag configuration for colored rows is applied dynamically per - # active configuration's row_colors — see MainWindow._configure_row_colors. - - self._install_focus_ring() - - def _install_focus_ring(self) -> None: - """Wire FocusIn/FocusOut to swap combobox style to Focus.TCombobox.""" - - def _on_focus_in(_event: tk.Event, combo: ttk.Combobox) -> None: - combo.configure(style="Focus.TCombobox") - - def _on_focus_out(_event: tk.Event, combo: ttk.Combobox) -> None: - combo.configure(style="TCombobox") - - combos = ( - self.window._dir_combo, - self.window._ext_combo, - self.window._filter_combo, - ) - for combo in combos: - combo.bind("", lambda e, c=combo: _on_focus_in(e, c), add="+") - combo.bind("", lambda e, c=combo: _on_focus_out(e, c), add="+") - - def _build_action_bar(self) -> None: - """Build the action bar with execute button and options.""" - self.window._action_frame = ttk.Frame(self.window._root, padding=(16, 0)) - self.window._action_frame.pack(fill=tk.X, side=tk.TOP, pady=(0, 8)) - - # Close after execution checkbox - self.window._close_var = tk.BooleanVar(value=self.window._config.close_after_execute) - self.window._close_check = ttk.Checkbutton( - self.window._action_frame, - text="Close after execution", - variable=self.window._close_var, - command=self.window._on_close_toggle, - ) - self.window._close_check.pack(side=tk.LEFT, padx=(0, 20)) - ToolTip(self.window._close_check, "Close ProFiles after launching a file") - - # Indeterminate progressbar — shown only during long scans, hidden by default - self.window._progress_bar = ttk.Progressbar( - self.window._action_frame, - mode="indeterminate", - ) - self.window._progress_bar.pack(side=tk.LEFT, padx=(0, 12)) - self.window._progress_bar.pack_forget() - - # Execute button (prominent accent button) - self.window._execute_btn = ttk.Button( - self.window._action_frame, - text="▶ Execute", - command=self.window._on_execute, - style="Execute.Accent.TButton", - ) - self.window._execute_btn.pack(side=tk.RIGHT) - ToolTip(self.window._execute_btn, "Launch the selected test program") diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py deleted file mode 100644 index 9a98cad..0000000 --- a/tests/gui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package mirroring src/profiles (1:1 layout, see tests/AGENTS.md).""" diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py deleted file mode 100644 index 01c97f9..0000000 --- a/tests/gui/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Skip every test collected under ``tests/gui`` — the Tkinter GUI is deprecated. - -The web/React frontend is the default (phase 1 of the removal plan already -moved business logic into ``profiles.core``); phase 2 will delete -``src/profiles/gui`` together with this directory. Until then, every test -here stays collected but skipped, so the deprecation remains visible in the -suite summary instead of silently passing. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -_HERE = Path(__file__).resolve().parent - -TKINTER_DEPRECATION_REASON = ( - "Tkinter GUI is deprecated — web frontend is the default. " - "Skipped in preparation for removal (phase 2 deletes src/profiles/gui)." -) - - -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Mark every item collected under tests/gui as skipped.""" - for item in items: - if item.path.is_relative_to(_HERE): - item.add_marker(pytest.mark.skip(reason=TKINTER_DEPRECATION_REASON)) diff --git a/tests/gui/controllers/__init__.py b/tests/gui/controllers/__init__.py deleted file mode 100644 index 56c7962..0000000 --- a/tests/gui/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for GUI controllers.""" diff --git a/tests/gui/controllers/test_directory_manager.py b/tests/gui/controllers/test_directory_manager.py deleted file mode 100644 index 2c852f3..0000000 --- a/tests/gui/controllers/test_directory_manager.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for the DirectoryManager controller. - -Covers the pure helpers (``format_dir_entry`` / ``strip_dir_label``) -and the manager methods that don't need a real MainWindow. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from profiles.core.config import service as config_service -from profiles.gui.controllers.directory_manager import ( - DirectoryManager, - format_dir_entry, - strip_dir_label, -) - -# ── Pure helpers ────────────────────────────────────────────────────────── - - -class TestFormatDirEntry: - def test_single_path_no_suffix(self) -> None: - e = config_service.DirectoryEntry(label="prod", paths=("/a",), icon="📁") - assert format_dir_entry(e) == "📁 prod" - - def test_multi_path_appends_count(self) -> None: - e = config_service.DirectoryEntry(label="g", paths=("/a", "/b", "/c"), icon="📁") - assert format_dir_entry(e) == "📁 g (3 paths)" - - def test_file_icon_multi_path_no_suffix(self) -> None: - """File icon never gets the (N paths) suffix even with multiple paths.""" - e = config_service.DirectoryEntry(label="x", paths=("/a", "/b"), icon="📄") - assert format_dir_entry(e) == "📄 x" - - -class TestStripDirLabel: - def test_strips_folder_icon(self) -> None: - assert strip_dir_label("📁 prod") == "prod" - - def test_strips_file_icon(self) -> None: - assert strip_dir_label("📄 doc") == "doc" - - def test_strips_count_suffix(self) -> None: - assert strip_dir_label("📁 group (3 paths)") == "group" - - def test_no_icon_returns_unchanged(self) -> None: - assert strip_dir_label("naked") == "naked" - - def test_strips_whitespace(self) -> None: - assert strip_dir_label(" spaced ") == "spaced" - - -# ── Manager methods (with mocks) ─────────────────────────────────────────── - - -def _view() -> MagicMock: - """A minimal DirectoryView mock.""" - v = MagicMock() - v._config.configurations = [] - v._config.config_path.exists.return_value = False - return v - - -class TestDirectoryManagerResolve: - """resolve() returns paths for a known label, or [label] as fallback.""" - - def test_resolve_known_label(self) -> None: - v = _view() - # Mock get_directory_combobox_values to return a single entry - with pytest.MonkeyPatch.context() as mp: - entry = config_service.DirectoryEntry(label="prod", paths=("/a", "/b"), icon="📁") - mp.setattr( - "profiles.gui.controllers.directory_manager" - ".config_service.get_directory_combobox_values", - lambda _c: [entry], - ) - mgr = DirectoryManager(view=v, hostname="host") - assert mgr.resolve("prod") == ["/a", "/b"] - - def test_resolve_unknown_label_falls_back_to_self(self) -> None: - v = _view() - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "profiles.gui.controllers.directory_manager" - ".config_service.get_directory_combobox_values", - lambda _c: [], - ) - mgr = DirectoryManager(view=v, hostname="host") - assert mgr.resolve("/some/raw/path") == ["/some/raw/path"] - - -class TestDirectoryManagerAutoSelect: - """auto_select() uses cwd when no config file exists.""" - - def test_falls_back_to_cwd(self) -> None: - v = _view() - v._config.config_path.exists.return_value = False - mgr = DirectoryManager(view=v, hostname="host") - mgr.auto_select() - v._dir_var.set.assert_called_once() - # The arg is str(Path.cwd()) — verify it's the cwd string - from pathlib import Path - - assert v._dir_var.set.call_args.args[0] == str(Path.cwd()) - - -class TestDirectoryManagerCurrentLabel: - def test_strips_icon_and_count(self) -> None: - v = _view() - v._dir_var.get.return_value = "📁 group (3 paths)" - mgr = DirectoryManager(view=v, hostname="host") - assert mgr.current_label() == "group" diff --git a/tests/gui/controllers/test_scan_controller.py b/tests/gui/controllers/test_scan_controller.py deleted file mode 100644 index 6c24665..0000000 --- a/tests/gui/controllers/test_scan_controller.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Tests for the scan controller worker. - -Covers the pure background-scan function and the queue wrapper. The -rest of the scan lifecycle (chunked insert, queue polling) stays in -MainWindow and is tested there. -""" - -from __future__ import annotations - -import logging -import queue -from unittest.mock import MagicMock, patch - -from profiles.core.config.models import AppConfig -from profiles.gui.controllers.scan_controller import ( - ScanQueue, - ScanResult, - run_scan, -) - - -def _config() -> AppConfig: - """Minimal AppConfig stub — the real one is complex and not relevant.""" - # Plain MagicMock — AppConfig attributes are set externally, not declared - # at class scope, so spec= would reject them. Set the iteration - # attribute explicitly. - return MagicMock(configurations=[]) - - -def _logger() -> logging.Logger: - return logging.getLogger("test_scan_controller") - - -class TestRunScan: - """run_scan() is the off-main-thread worker.""" - - def test_pushes_ok_with_items(self) -> None: - cfg = _config() - q: queue.Queue[ScanResult] = queue.Queue() - items = [MagicMock(name="sf1"), MagicMock(name="sf2")] - - with patch( - "profiles.gui.controllers.scan_controller.scanner.scan_and_process_dynamic", - return_value=items, - ): - run_scan( - config=cfg, - directory_label="d", - scan_paths=["/tmp"], - extension=".txt", - filter_text="", - recursive=True, - queue_=q, - scan_id=7, - logger=_logger(), - ) - - status, sid, got = q.get_nowait() - assert status == "ok" - assert sid == 7 - assert got is items - - def test_pushes_error_on_exception(self) -> None: - cfg = _config() - q: queue.Queue[ScanResult] = queue.Queue() - - with patch( - "profiles.gui.controllers.scan_controller.scanner.scan_and_process_dynamic", - side_effect=RuntimeError("boom"), - ): - run_scan( - config=cfg, - directory_label="d", - scan_paths=["/tmp"], - extension=".txt", - filter_text="", - recursive=True, - queue_=q, - scan_id=8, - logger=_logger(), - ) - - status, sid, got = q.get_nowait() - assert status == "error" - assert sid == 8 - assert got is None - - def test_passes_config_to_scanner(self) -> None: - cfg = _config() - q: queue.Queue[ScanResult] = queue.Queue() - with patch( - "profiles.gui.controllers.scan_controller.scanner.scan_and_process_dynamic", - return_value=[], - ) as scan_mock: - run_scan( - config=cfg, - directory_label="d", - scan_paths=["/tmp"], - extension=".txt", - filter_text="", - recursive=True, - queue_=q, - scan_id=9, - logger=_logger(), - ) - - kwargs = scan_mock.call_args.kwargs - assert kwargs["config"] == cfg - - -class TestScanQueue: - """ScanQueue wraps queue.Queue with a typed try_dequeue.""" - - def test_empty_returns_false(self) -> None: - q = ScanQueue() - has, item = q.try_dequeue() - assert has is False - assert item is None - - def test_put_then_dequeue(self) -> None: - q = ScanQueue() - q.put(("ok", 1, [])) - has, item = q.try_dequeue() - assert has is True - assert item == ("ok", 1, []) - - def test_try_dequeue_is_non_blocking(self) -> None: - q = ScanQueue() - # Should return (False, None) without raising or blocking. - has, item = q.try_dequeue() - assert has is False - assert item is None diff --git a/tests/gui/controllers/test_window_actions.py b/tests/gui/controllers/test_window_actions.py deleted file mode 100644 index 2c15145..0000000 --- a/tests/gui/controllers/test_window_actions.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for the WindowActions controller.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from profiles.core.actions import ActionResult, ActionStatus -from profiles.core.config.models import AppConfig -from profiles.gui.controllers.window_actions import WindowActions - - -def _actions() -> tuple[WindowActions, MagicMock]: - destroy = MagicMock() - return ( - WindowActions( - config=AppConfig(), - logger=MagicMock(), - root_destroy=destroy, - ), - destroy, - ) - - -class TestRestart: - """restart() destroys the window then spawns a new Python process.""" - - def test_destroy_called(self) -> None: - acts, destroy = _actions() - with ( - patch("profiles.gui.controllers.window_actions.subprocess"), - patch("profiles.gui.controllers.window_actions.sys") as sys_mock, - ): - sys_mock.executable = "python.exe" - acts.restart() - destroy.assert_called_once() - - def test_popen_receives_module_flag(self) -> None: - acts, _ = _actions() - with ( - patch("profiles.gui.controllers.window_actions.subprocess") as popen_mock, - patch("profiles.gui.controllers.window_actions.sys") as sys_mock, - ): - sys_mock.executable = "python.exe" - acts.restart() - args = popen_mock.Popen.call_args.args[0] - assert args[0] == "python.exe" - assert "-m" in args - assert "profiles" in args - - def test_oserror_shows_messagebox(self) -> None: - acts, _ = _actions() - with ( - patch("profiles.gui.controllers.window_actions.subprocess") as popen_mock, - patch("profiles.gui.controllers.window_actions.sys") as sys_mock, - patch("profiles.gui.controllers.window_actions.messagebox") as msg_mock, - ): - sys_mock.executable = "python.exe" - popen_mock.Popen.side_effect = OSError("nope") - acts.restart() - msg_mock.showerror.assert_called_once() - assert "Restart Failed" in msg_mock.showerror.call_args.args[0] - - -class TestOpenLog: - """open_log() delegates to core.actions and shows a dialog on failure.""" - - def test_success_no_dialog(self) -> None: - acts, _ = _actions() - with ( - patch("profiles.gui.controllers.window_actions.actions.open_log_file") as open_log_mock, - patch("profiles.gui.controllers.window_actions.messagebox") as msg_mock, - ): - open_log_mock.return_value = ActionResult(status=ActionStatus.SUCCESS, message="ok") - acts.open_log(Path("logs/profiles.log")) - msg_mock.showerror.assert_not_called() - - def test_failure_shows_dialog(self) -> None: - acts, _ = _actions() - with ( - patch("profiles.gui.controllers.window_actions.actions.open_log_file") as open_log_mock, - patch("profiles.gui.controllers.window_actions.messagebox") as msg_mock, - ): - open_log_mock.return_value = ActionResult(status=ActionStatus.FAILED, message="missing") - acts.open_log(Path("logs/profiles.log")) - msg_mock.showerror.assert_called_once() diff --git a/tests/gui/presentation/__init__.py b/tests/gui/presentation/__init__.py deleted file mode 100644 index 21f9771..0000000 --- a/tests/gui/presentation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the presentation layer.""" diff --git a/tests/gui/presentation/test_row_colors.py b/tests/gui/presentation/test_row_colors.py deleted file mode 100644 index 34242e3..0000000 --- a/tests/gui/presentation/test_row_colors.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for profiles.gui.presentation.row_colors — pure rule engine.""" - -from __future__ import annotations - -from profiles.gui.presentation.row_colors import ( - RowColorRules, - default_tag_name, - make_rule_tag_name, -) - - -def _rules() -> RowColorRules: - return RowColorRules( - rules=[("prod", "#005fb8"), ("dev", "#757575")], - tag_prefix="rc", - ) - - -class TestDefaultTagName: - """default_tag_name produces a stable, prefix-based identifier.""" - - def test_includes_prefix(self) -> None: - assert default_tag_name("rc") == "rc_default" - - def test_distinct_prefixes_are_distinct(self) -> None: - assert default_tag_name("a") != default_tag_name("b") - - -class TestMakeRuleTagName: - """make_rule_tag_name strips the # and replaces spaces.""" - - def test_strips_hash(self) -> None: - assert make_rule_tag_name("rc", "prod", "#005fb8") == "rc_prod_005fb8" - - def test_replaces_spaces(self) -> None: - assert make_rule_tag_name("rc", "my prod", "#fff") == "rc_my_prod_fff" - - def test_no_hash(self) -> None: - assert make_rule_tag_name("rc", "prod", "005fb8") == "rc_prod_005fb8" - - -class TestRowColorRulesTagsFor: - """The engine returns the right tags for a given filename.""" - - def test_empty_rules_returns_default_only(self) -> None: - rules = RowColorRules(rules=[], tag_prefix="rc") - assert rules.tags_for("anything.txt") == ("rc_default",) - - def test_no_match_returns_default_only(self) -> None: - assert _rules().tags_for("test.txt") == ("rc_default",) - - def test_first_match_wins(self) -> None: - tags = _rules().tags_for("myprod_file.mttl") - assert tags == ("rc_default", "rc_prod_005fb8") - - def test_matching_is_case_insensitive(self) -> None: - assert _rules().tags_for("PROD_file") == ("rc_default", "rc_prod_005fb8") - assert _rules().tags_for("dev_file") == ("rc_default", "rc_dev_757575") - - def test_skips_empty_patterns_and_colors(self) -> None: - rules = RowColorRules( - rules=[("", "#000000"), ("prod", ""), ("prod", "#005fb8")], - tag_prefix="rc", - ) - assert rules.tags_for("prod_file") == ("rc_default", "rc_prod_005fb8") - - def test_default_tag_always_present(self) -> None: - for name in ("", "anything", "PROD_thing", "totally_unrelated"): - tags = _rules().tags_for(name) - assert tags[0] == "rc_default", f"default missing for {name!r}" diff --git a/tests/gui/test_context_menu.py b/tests/gui/test_context_menu.py deleted file mode 100644 index 9601c2d..0000000 --- a/tests/gui/test_context_menu.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Smoke tests for FileContextMenu — verify each action emits the right event.""" - -from __future__ import annotations - -import hashlib -import logging -import tkinter as tk -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from profiles.core.actions import ActionResult, ActionStatus -from profiles.gui.context_menu import FileContextMenu - - -@pytest.fixture -def mock_window(tmp_path: Path) -> MagicMock: - """Build a minimal MainWindow mock that the context menu can talk to.""" - root = tk.Tk() - root.withdraw() # don't show window during tests - window = MagicMock() - window._root = root - window._logger = logging.getLogger("test_context_menu") - window._config = MagicMock() - window._config.release = "2026.8.0" - window._close_var = MagicMock(get=MagicMock(return_value=False)) - window._dir_var = MagicMock(get=MagicMock(return_value=str(tmp_path))) - window._dir_manager = MagicMock() - window._dir_manager.resolve = MagicMock(return_value=[str(tmp_path)]) - window._ext_var = MagicMock(set=MagicMock()) - window._tree = MagicMock() - window._tree_to_path = {} - window._theme = MagicMock( - surface="white", on_surface="black", primary="blue", on_primary="white" - ) - window._refresh_file_list = MagicMock() - window._apply_config_overrides = MagicMock() - window._on_close = MagicMock() - window._action_launch_with_args = MagicMock() - yield window - root.destroy() - - -def test_action_reveal_success_emits_event(caplog, mock_window, tmp_path): - """action_reveal emits FILE_REVEALED status=ok on success.""" - file_path = tmp_path / "test.txt" - file_path.write_text("x") - caplog.set_level(logging.DEBUG, logger="test_context_menu") - - with patch( - "profiles.gui.context_menu.reveal_in_file_manager", - return_value=ActionResult(status=ActionStatus.SUCCESS, message="ok"), - ): - FileContextMenu(mock_window).action_reveal(file_path) - - assert any("FILE_REVEALED" in r.message and 'status="ok"' in r.message for r in caplog.records) - - -def test_action_open_folder_success_emits_event(caplog, mock_window, tmp_path): - """action_open_folder emits EXTERNAL_OPENED kind=folder status=ok on success.""" - subdir = tmp_path / "sub" - subdir.mkdir() - file_path = subdir / "test.txt" - file_path.write_text("x") - caplog.set_level(logging.DEBUG, logger="test_context_menu") - - with patch( - "profiles.gui.context_menu.open_file_explorer", - return_value=True, - ): - FileContextMenu(mock_window).action_open_folder(file_path) - - assert any( - "EXTERNAL_OPENED" in r.message - and 'kind="folder"' in r.message - and 'status="ok"' in r.message - for r in caplog.records - ) - - -def test_action_filter_to_folder_emits_event(caplog, mock_window, tmp_path): - """action_filter_to_folder emits FILTER_CHANGED kind=folder on success.""" - target = tmp_path / "other" - target.mkdir() - file_path = target / "test.txt" - file_path.write_text("x") - # Mock resolve to return a *different* path so "already_active" does not fire. - mock_window._dir_manager.resolve = MagicMock(return_value=[str(tmp_path / "different")]) - caplog.set_level(logging.INFO, logger="test_context_menu") - - FileContextMenu(mock_window).action_filter_to_folder(file_path) - - assert any( - "FILTER_CHANGED" in r.message and 'kind="folder"' in r.message for r in caplog.records - ) - - -def test_action_filter_by_extension_emits_event(caplog, mock_window, tmp_path): - """action_filter_by_extension emits FILTER_CHANGED kind=extension on success.""" - file_path = tmp_path / "test.mttl" - file_path.write_text("x") - caplog.set_level(logging.INFO, logger="test_context_menu") - - FileContextMenu(mock_window).action_filter_by_extension(file_path) - - assert any( - "FILTER_CHANGED" in r.message and 'kind="extension"' in r.message for r in caplog.records - ) - - -@pytest.mark.skip(reason="window is not closing") -def test_action_hash_success_emits_event(caplog, mock_window, tmp_path): - """action_hash emits HASH_COMPUTED status=ok with duration_ms on success.""" - file_path = tmp_path / "test.txt" - file_path.write_text("hello world") - caplog.set_level(logging.INFO, logger="test_context_menu") - - FileContextMenu(mock_window).action_hash(file_path, "md5") - - assert any( - "HASH_COMPUTED" in r.message and 'status="ok"' in r.message and "duration_ms=" in r.message - for r in caplog.records - ) - - -@pytest.mark.skip(reason="window is not closing") -def test_action_verify_hash_match_emits_event(caplog, mock_window, tmp_path): - """action_verify_hash emits HASH_VERIFIED match=true on a match.""" - file_path = tmp_path / "test.txt" - file_path.write_text("hello world") - expected = hashlib.md5(b"hello world").hexdigest() - mock_window._root.clipboard_get = MagicMock(return_value=expected) - caplog.set_level(logging.INFO, logger="test_context_menu") - - FileContextMenu(mock_window).action_verify_hash(file_path, "md5") - - assert any("HASH_VERIFIED" in r.message and "match=true" in r.message for r in caplog.records) diff --git a/tests/gui/test_i18n.py b/tests/gui/test_i18n.py deleted file mode 100644 index 38f24fe..0000000 --- a/tests/gui/test_i18n.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Unit tests for the GUI i18n module (no Tk).""" - -from __future__ import annotations - -import pytest - -from profiles.gui import i18n - - -@pytest.fixture(autouse=True) -def _reset_registry() -> None: - """Each test starts with an empty registry and English language.""" - i18n._reset_for_tests() - i18n.set_current_language("en") - yield - i18n._reset_for_tests() - i18n.set_current_language("en") - - -def test_normalize_known_languages_case_insensitive() -> None: - assert i18n.normalize_language("en") == "en" - assert i18n.normalize_language("EN") == "en" - assert i18n.normalize_language("fr") == "fr" - assert i18n.normalize_language("FR") == "fr" - assert i18n.normalize_language("Fr") == "fr" - - -def test_normalize_unknown_falls_back_to_en() -> None: - assert i18n.normalize_language("es") == "en" - assert i18n.normalize_language("") == "en" - assert i18n.normalize_language(" ") == "en" - - -def test_t_returns_localized_string_for_explicit_lang() -> None: - assert i18n.t("status.config", "en") == "\u2699 Config" - assert i18n.t("status.refresh", "fr") == "\U0001f504 Actualiser" - - -def test_t_uses_current_language_when_lang_is_none() -> None: - i18n.set_current_language("fr") - assert i18n.t("status.refresh") == "\U0001f504 Actualiser" - - -def test_t_missing_key_returns_raw_key() -> None: - assert i18n.t("does.not.exist", "en") == "does.not.exist" - assert i18n.t("does.not.exist", "fr") == "does.not.exist" - - -def test_t_unsupported_lang_falls_back_to_english() -> None: - assert i18n.t("status.refresh", "es") == "\U0001f504 Refresh" - - -def test_catalog_has_every_required_key_in_both_languages() -> None: - required = { - "title.tooltip", - "search.dir_label", - "search.dir.tooltip", - "search.browse", - "search.browse.tooltip", - "search.recursive", - "search.recursive.tooltip", - "search.search_btn", - "search.search_btn.tooltip", - "search.ext_label", - "search.ext.tooltip", - "search.filter_label", - "search.filter.tooltip", - "status.config", - "status.config.tooltip", - "status.refresh", - "status.refresh.tooltip", - "status.log", - "status.log.tooltip", - "status.shortcuts", - "status.shortcuts.tooltip", - "status.language.tooltip", - "status.theme.tooltip", - "status.user", - "status.user.tooltip", - "status.host", - "status.host.tooltip", - "status.ip", - "status.ip.tooltip", - "status.count", - "status.count.tooltip", - "status.dir_status.tooltip", - "action.close_after", - "action.close_after.tooltip", - "action.execute", - "action.execute.empty", - "action.execute.no_match", - "shortcuts.title", - "shortcuts.close", - "menu.refresh", - "menu.config", - "menu.log", - "menu.shortcuts", - "menu.exit", - "menu.launch", - "menu.launch_args", - "menu.reveal", - "menu.open_folder", - "menu.terminal", - "menu.filter_folder", - "menu.filter_extension", - "menu.copy", - "menu.copy.full", - "menu.copy.forward", - "menu.copy.name_w_ext", - "menu.copy.name_wo_ext", - "menu.copy.directory", - "menu.copy.uri", - "menu.hash", - "menu.hash.md5", - "menu.hash.sha256", - "menu.hash.copy_md5", - "menu.hash.copy_sha256", - "menu.hash.verify_md5", - "menu.hash.verify_sha256", - "count_patterns.scanning", - "count_patterns.dir_not_found", - "count_patterns.scan_failed", - } - for key in required: - assert key in i18n.STRINGS["en"], f"missing EN key: {key}" - assert key in i18n.STRINGS["fr"], f"missing FR key: {key}" - - -def test_register_is_idempotent_per_callable() -> None: - calls: list[str] = [] - fn = lambda lang: calls.append(lang) # noqa: E731 - i18n.register(fn) - i18n.register(fn) - i18n.set_language("fr") - assert calls == ["fr"] - - -def test_set_language_walks_registry_in_registration_order() -> None: - order: list[int] = [] - i18n.register(lambda lang: order.append(1)) - i18n.register(lambda lang: order.append(2)) - i18n.register(lambda lang: order.append(3)) - i18n.set_language("fr") - assert order == [1, 2, 3] - - -def test_set_language_persists_current_language() -> None: - i18n.set_language("fr") - assert i18n.current_language() == "fr" - i18n.set_language("en") - assert i18n.current_language() == "en" - - -def test_status_user_tooltip_is_platform_neutral_en() -> None: - assert i18n.t("status.user.tooltip", "en") == "Current username" - - -def test_status_user_tooltip_is_platform_neutral_fr() -> None: - assert i18n.t("status.user.tooltip", "fr") == "Nom d'utilisateur courant" - - -def test_supported_languages_is_exactly_two() -> None: - assert i18n.SUPPORTED_LANGUAGES == ("en", "fr") - - -def test_language_labels_have_both_keys() -> None: - assert set(i18n.LANGUAGE_LABELS) == {"en", "fr"} diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py deleted file mode 100644 index e79cf5d..0000000 --- a/tests/gui/test_main_window.py +++ /dev/null @@ -1,1263 +0,0 @@ -"""Tests for profiles.gui.main_window — MainWindow, _hex_luminance, _restart_application, _on_execute, _configure_bindings, _insert_chunk, context menu actions.""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from collections.abc import Callable -from pathlib import Path -from tkinter import ttk -from unittest.mock import MagicMock, patch - -import pytest - -from profiles.config import AppConfig, MachineConfiguration -from profiles.core import actions -from profiles.gui.main_window import MainWindow - -# ── Tkinter availability guard ────────────────────────────────────────────── - -_tk_available = True -try: - _root_test = tk.Tk() - _root_test.withdraw() -except (tk.TclError, RuntimeError): - _tk_available = False -else: - # Keep the probe root ALIVE for the whole session. On macOS (TkAqua), - # destroying the first Tk root poisons the window-server state so that - # update() on every later root spins forever — but a withdrawn, - # never-destroyed session root keeps later roots healthy. Reset the - # module default so each MainWindow's own Tk() becomes the default - # root again (images/variables then bind to that window's interpreter). - tk._default_root = None - -needs_tk = pytest.mark.skipif( - not _tk_available, - reason="Tkinter not available (headless CI or missing Tcl/Tk)", -) - -# Legacy Tk GUI module entirely skipped (user edit during session, -# expressed here as a valid module-level skip). -pytestmark = pytest.mark.skip(reason="Legacy TK Gui") - -# ── Fixtures ──────────────────────────────────────────────────────────────── - - -def _make_config( - tmp_path: Path, - *, - theme: str = "light", - row_colors: tuple[tuple[str, str], ...] = (), -) -> AppConfig: - """Build a minimal AppConfig pointing at *tmp_path*.""" - # Ensure config file exists to avoid prompt - config_path = tmp_path / ".profiles" - if not config_path.exists(): - config_path.write_text("version: 1\n", encoding="utf-8") - - cfg = AppConfig( - release="0.0.1", - title="Test", - config_path=config_path, - search_dir=str(tmp_path), - search_exclude_dirs=(), - extensions=(".mttl",), - filters=("",), - column_names=("File", "Version"), - column_headers=("File", "Version"), - column_widths=(600, 150), - column_stretches=(True, False), - recursive_search=False, - close_after_execute=False, - theme=theme, - skip_config_prompt=True, - configurations=[ - MachineConfiguration( - name="default", - scan=(str(tmp_path),), - ), - ], - ) - # Defaults-level row_colors: exercised when no active per-directory - # config matches the selected directory (the canonical docs setup). - cfg.row_colors = row_colors - return cfg - - -@pytest.fixture -def window(tmp_path: Path) -> MainWindow: - """Build a MainWindow without launching the mainloop.""" - config = _make_config(tmp_path) - (tmp_path / "sample.mttl").write_text("dummy", encoding="utf-8") - win = MainWindow(config) - yield win - with contextlib.suppress(tk.TclError): - win.root.destroy() - - -@pytest.fixture -def row_color_window(tmp_path: Path) -> Callable[..., MainWindow]: - """Factory fixture for a MainWindow with global default row_colors. - - ``row_colors`` are placed on ``AppConfig`` (the ``defaults`` base), - which ``_configure_row_colors`` uses whenever no per-directory - ``MachineConfiguration`` matches the selected directory — the - canonical documented setup. - """ - - created: list[MainWindow] = [] - - def _build( - theme: str = "dark", - row_colors: tuple[tuple[str, str], ...] = (("PROD", "#1565C0"),), - ) -> MainWindow: - config = _make_config(tmp_path, theme=theme, row_colors=row_colors) - (tmp_path / "PROD_sample.mttl").write_text("dummy", encoding="utf-8") - win = MainWindow(config) - created.append(win) - return win - - yield _build - for win in created: - with contextlib.suppress(tk.TclError): - win.root.destroy() - - -# ── _selected_file_path ───────────────────────────────────────────────────── - - -class TestSelectedFilePath: - """MainWindow._selected_file_path — resolution of the selected row.""" - - @needs_tk - def test_returns_none_when_nothing_selected(self, window: MainWindow) -> None: - # No row in the tree → no path - assert window._tree.selection() == () - assert window._selected_file_path() is None - - @needs_tk - def test_returns_full_path_for_selected_row( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - # Force-set the directory so we don't rely on auto-select logic. - window._dir_var.set(str(tmp_path)) - window._tree.insert( - parent="", - index=tk.END, - values=("sample.mttl", "V1"), - ) - window._tree.selection_set(window._tree.get_children()[0]) - path = window._selected_file_path() - assert path == tmp_path / "sample.mttl" - - @needs_tk - def test_empty_directory_returns_none( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - window._dir_var.set("") - window._tree.insert( - parent="", - index=tk.END, - values=("ghost.txt", ""), - ) - assert window._selected_file_path() is None - - -# ── Context menu bindings ─────────────────────────────────────────────────── - - -class TestContextMenuBindings: - """The right-click handler is bound to the treeview.""" - - @needs_tk - def test_right_click_bound(self, window: MainWindow) -> None: - bindings = window._tree.bind() - assert "" in bindings - - @needs_tk - def test_middle_click_bound(self, window: MainWindow) -> None: - bindings = window._tree.bind() - assert "" in bindings - - @needs_tk - def test_control_click_bound_for_macos(self, window: MainWindow) -> None: - bindings = window._tree.bind() - assert "" in bindings - - -# ── _action_copy / clipboard helpers ──────────────────────────────────────── - - -class TestCopyActions: - """MainWindow._action_copy — populates the system clipboard.""" - - @needs_tk - def test_copy_writes_to_clipboard(self, window: MainWindow) -> None: - window._action_copy("hello world") - assert window.root.clipboard_get() == "hello world" - - @needs_tk - def test_copy_path_default_uses_native_separator( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "a.mttl" - window._action_copy_path(f) - assert window.root.clipboard_get() == str(f) - - @needs_tk - def test_copy_path_forward_slashes( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "b.mttl" - window._action_copy_path(f, forward_slashes=True) - text = window.root.clipboard_get() - # No backslashes left — all converted to forward slashes. - assert "\\" not in text - assert text.endswith("/b.mttl") - - @needs_tk - def test_copy_name_with_and_without_extension( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "report.txt" - window._action_copy_name(f, with_ext=True) - assert window.root.clipboard_get() == "report.txt" - window._action_copy_name(f, with_ext=False) - assert window.root.clipboard_get() == "report" - - -# ── _on_tree_right_click ──────────────────────────────────────────────────── - - -class TestOnTreeRightClick: - """The right-click handler builds a menu without crashing.""" - - @needs_tk - def test_bails_out_on_empty_area(self, window: MainWindow) -> None: - # No row under the cursor → no menu should be posted. - event = MagicMock() - event.y = 999_999 # well below any row - event.x = 0 - event.x_root = 100 - event.y_root = 100 - # Should not raise - window._on_tree_right_click(event) - - @needs_tk - def test_selects_row_under_cursor( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - window._tree.insert( - parent="", - index=tk.END, - values=("sample.mttl", "V1"), - ) - # Map the window and scroll the row into view so bbox is populated. - window._tree.see(window._tree.get_children()[0]) - window.root.update_idletasks() - bbox = window._tree.bbox(window._tree.get_children()[0]) - assert bbox # row must be visible / laid out - - event = MagicMock() - event.y = bbox[1] + 1 - event.x = bbox[0] + 1 - event.x_root = 100 - event.y_root = 100 - - # Mock tk_popup to prevent blocking / showing UI in tests - mocker.patch("tkinter.Menu.tk_popup") - - # The handler tries to call tk_popup, which in a headless test - # environment raises TclError — we only need to verify the row - # got selected before that point. - with contextlib.suppress(tk.TclError): - window._on_tree_right_click(event) - - selection = window._tree.selection() - assert selection - assert window._tree.item(selection[0], "values")[0] == "sample.mttl" - - -# ── _action_filter_to_folder ─────────────────────────────────────────────── - - -class TestFilterToFolder: - """MainWindow._action_filter_to_folder — drill-down into a parent dir.""" - - @needs_tk - def test_switches_directory_to_parent( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - sub = tmp_path / "sub" - sub.mkdir() - f = sub / "child.mttl" - f.write_text("", encoding="utf-8") - window._dir_var.set(str(sub)) - - window._action_filter_to_folder(f) - assert window._dir_var.get() == str(sub) - - @needs_tk - def test_noop_when_already_in_parent( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "top.mttl" - f.write_text("", encoding="utf-8") - window._dir_var.set(str(tmp_path)) - # Refresher must NOT be called since nothing changed. - called = {"n": 0} - original = window._refresh_file_list - window._refresh_file_list = lambda: called.__setitem__("n", called["n"] + 1) # type: ignore[method-assign] - try: - window._action_filter_to_folder(f) - assert called["n"] == 0 - assert window._dir_var.get() == str(tmp_path) - finally: - window._refresh_file_list = original # type: ignore[method-assign] - - @needs_tk - def test_missing_folder_warns( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - # Build a path whose parent doesn't exist on disk. - ghost = tmp_path / "doesnt_exist" / "child.mttl" - mock_warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - window._action_filter_to_folder(ghost) - mock_warn.assert_called_once() - assert "Folder Not Found" in mock_warn.call_args.args[0] - - -# ── _action_hash ─────────────────────────────────────────────────────────── - - -class TestActionHash: - """MainWindow._action_hash — compute and show / copy a file digest.""" - - @needs_tk - def test_show_dialog( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "report.mttl" - f.write_bytes(b"abc") - mock_info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - window._action_hash(f, "md5") - mock_info.assert_called_once() - title = mock_info.call_args.args[0] - body = mock_info.call_args.args[1] - assert title == "MD5 Hash" - import hashlib - - assert hashlib.md5(b"abc").hexdigest() in body - - @needs_tk - def test_copy_only( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "report.mttl" - f.write_bytes(b"abc") - mock_info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - window._action_hash(f, "sha256", copy_only=True) - mock_info.assert_not_called() - import hashlib - - assert window.root.clipboard_get() == hashlib.sha256(b"abc").hexdigest() - - @needs_tk - def test_missing_file_warns( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - mock_warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - window._action_hash(tmp_path / "ghost.mttl", "md5") - mock_warn.assert_called_once() - assert "File Not Found" in mock_warn.call_args.args[0] - - -# ── _action_copy_uri ─────────────────────────────────────────────────────── - - -class TestCopyUri: - """MainWindow._action_copy_uri — `file://` URI to clipboard.""" - - @needs_tk - def test_writes_file_uri_to_clipboard( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "report.mttl" - window._action_copy_uri(f) - expected = Path(f).as_uri() - assert window.root.clipboard_get() == expected - - @needs_tk - def test_starts_with_file_scheme( - self, - window: MainWindow, - tmp_path: Path, - ) -> None: - f = tmp_path / "data.bin" - window._action_copy_uri(f) - assert window.root.clipboard_get().startswith("file://") - - -# ── _action_filter_by_extension ──────────────────────────────────────────── - - -class TestFilterByExtension: - """MainWindow._action_filter_by_extension — narrow scan to a file's ext.""" - - @needs_tk - def test_sets_extension_var_to_suffix( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "thing.mttl" - mocker.patch.object(window, "_refresh_file_list") - window._action_filter_by_extension(f) - assert window._ext_var.get() == ".mttl" - - @needs_tk - def test_triggers_refresh( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "thing.mttl" - mock_refresh = mocker.patch.object(window, "_refresh_file_list") - window._action_filter_by_extension(f) - mock_refresh.assert_called_once() - - @needs_tk - def test_no_extension_warns( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - no_ext = tmp_path / "Makefile" - no_ext.write_text("", encoding="utf-8") - mock_warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - mock_refresh = mocker.patch.object(window, "_refresh_file_list") - window._action_filter_by_extension(no_ext) - mock_warn.assert_called_once() - assert "No Extension" in mock_warn.call_args.args[0] - mock_refresh.assert_not_called() - - -# ── _action_verify_hash ──────────────────────────────────────────────────── - - -class TestVerifyHash: - """MainWindow._action_verify_hash — file hash vs. clipboard digest.""" - - @needs_tk - def test_match_shows_success_dialog( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - import hashlib - - f = tmp_path / "good.mttl" - f.write_bytes(b"hello") - digest = hashlib.sha256(b"hello").hexdigest() - window.root.clipboard_clear() - window.root.clipboard_append(digest) - # ponytail: update() drains the clipboard grab but blocks forever on - # TkAqua when a prior root was destroyed; update_idletasks() flushes - # the Tcl queue without the blocking grab. - window.root.update_idletasks() - - mock_info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - mock_error = mocker.patch("profiles.gui.main_window.messagebox.showerror") - window._action_verify_hash(f, "sha256") - mock_info.assert_called_once() - mock_error.assert_not_called() - assert "Match" in mock_info.call_args.args[1] - - @needs_tk - def test_match_is_case_insensitive( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - import hashlib - - f = tmp_path / "good.mttl" - f.write_bytes(b"hello") - digest = hashlib.md5(b"hello").hexdigest() - window.root.clipboard_clear() - window.root.clipboard_append(digest.upper()) # uppercase clipboard - # ponytail: update() drains the clipboard grab but blocks forever on - # TkAqua when a prior root was destroyed; update_idletasks() flushes - # the Tcl queue without the blocking grab. - window.root.update_idletasks() - - mock_info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - window._action_verify_hash(f, "md5") - mock_info.assert_called_once() - assert "Match" in mock_info.call_args.args[1] - - @needs_tk - def test_mismatch_shows_error_dialog( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "good.mttl" - f.write_bytes(b"hello") - window.root.clipboard_clear() - window.root.clipboard_append("0" * 64) # wrong hash - # ponytail: update() drains the clipboard grab but blocks forever on - # TkAqua when a prior root was destroyed; update_idletasks() flushes - # the Tcl queue without the blocking grab. - window.root.update_idletasks() - - mock_error = mocker.patch("profiles.gui.main_window.messagebox.showerror") - window._action_verify_hash(f, "sha256") - mock_error.assert_called_once() - body = mock_error.call_args.args[1] - assert "Mismatch" in body - assert "Clipboard" in body - - @needs_tk - def test_empty_clipboard_informs( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "good.mttl" - f.write_bytes(b"hello") - window.root.clipboard_clear() - # ponytail: update() drains the clipboard grab but blocks forever on - # TkAqua when a prior root was destroyed; update_idletasks() flushes - # the Tcl queue without the blocking grab. - window.root.update_idletasks() - - mock_info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - mock_error = mocker.patch("profiles.gui.main_window.messagebox.showerror") - window._action_verify_hash(f, "sha256") - mock_info.assert_called_once() - mock_error.assert_not_called() - assert "empty" in mock_info.call_args.args[1].lower() - - @needs_tk - def test_missing_file_warns( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - mock_warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - window._action_verify_hash(tmp_path / "ghost.mttl", "md5") - mock_warn.assert_called_once() - assert "File Not Found" in mock_warn.call_args.args[0] - - -# ── _action_open_terminal ───────────────────────────────────────────────── - - -class TestOpenTerminal: - """MainWindow._action_open_terminal — spawn an OS terminal in the parent dir.""" - - @needs_tk - def test_missing_folder_warns( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - ghost = tmp_path / "doesnt_exist" / "child.mttl" - mock_warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - mock_popen = mocker.patch( - "profiles.core.actions.subprocess.Popen", - ) - window._action_open_terminal(ghost) - mock_warn.assert_called_once() - mock_popen.assert_not_called() - - @needs_tk - def test_macos_opens_terminal_app( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "child.mttl" - f.write_text("", encoding="utf-8") - mock_popen = mocker.patch("profiles.core.actions.subprocess.Popen") - mocker.patch("profiles.core.actions.sys.platform", "darwin") - window._action_open_terminal(f) - mock_popen.assert_called_once() - args = mock_popen.call_args.args[0] - assert args[0] == "open" - assert args[1] == "-a" - assert args[2] == "Terminal" - assert str(tmp_path) in args[3] - - @needs_tk - def test_windows_uses_cmd( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "child.mttl" - f.write_text("", encoding="utf-8") - mock_popen = mocker.patch("profiles.core.actions.subprocess.Popen") - mocker.patch("profiles.core.actions.sys.platform", "win32") - mocker.patch("profiles.core.actions.os.name", "nt") - window._action_open_terminal(f) - mock_popen.assert_called_once() - args = mock_popen.call_args.args[0] - assert args[0] == "cmd" - assert "/K" in args - assert f"cd /D {tmp_path}" in args - - @needs_tk - def test_linux_no_terminal_found_errors( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "child.mttl" - f.write_text("", encoding="utf-8") - mocker.patch("profiles.core.actions.sys.platform", "linux") - mocker.patch("profiles.core.actions.os.name", "posix") - mocker.patch("profiles.core.actions.shutil.which", return_value=None) - mock_popen = mocker.patch("profiles.core.actions.subprocess.Popen") - mock_error = mocker.patch("profiles.gui.main_window.messagebox.showerror") - window._action_open_terminal(f) - mock_popen.assert_not_called() - mock_error.assert_called_once() - # Title is fixed; the "No terminal emulator" detail is in the message. - assert "No terminal emulator" in mock_error.call_args.args[1] - - @needs_tk - def test_linux_finds_x_terminal_emulator( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - f = tmp_path / "child.mttl" - f.write_text("", encoding="utf-8") - mocker.patch("profiles.core.actions.sys.platform", "linux") - mocker.patch("profiles.core.actions.os.name", "posix") - mocker.patch( - "profiles.core.actions.shutil.which", - side_effect=lambda name: ( - "/usr/bin/x-terminal-emulator" if name == "x-terminal-emulator" else None - ), - ) - mock_popen = mocker.patch("profiles.core.actions.subprocess.Popen") - window._action_open_terminal(f) - mock_popen.assert_called_once() - args = mock_popen.call_args.args[0] - assert args[0] == "x-terminal-emulator" - assert str(tmp_path) in args - - -# ── TestApplicationRestart (from test_restart_functionality.py) ──────────── - - -class TestApplicationRestart: - """Tests for the WindowActions.restart path (delegated from _restart_application).""" - - def test_restart_destroys_current_window(self) -> None: - """Verify that restart destroys the current window before launching new instance.""" - from profiles.gui.controllers.window_actions import WindowActions - - mock_destroy = MagicMock() - with ( - patch("profiles.gui.controllers.window_actions.subprocess") as mock_subprocess, - patch("profiles.gui.controllers.window_actions.sys") as mock_sys, - ): - mock_sys.executable = "python.exe" - actions = WindowActions( - config=AppConfig(), - logger=MagicMock(), - root_destroy=mock_destroy, - ) - actions.restart() - - # Verify window was destroyed - mock_destroy.assert_called_once() - # Verify subprocess was called to launch new instance - assert mock_subprocess.Popen.called - - def test_restart_handles_launch_failure_gracefully(self) -> None: - """Verify that restart shows error message if subprocess fails.""" - from profiles.gui.controllers.window_actions import WindowActions - - mock_destroy = MagicMock() - with ( - patch("profiles.gui.controllers.window_actions.subprocess") as mock_subprocess, - patch("profiles.gui.controllers.window_actions.sys") as mock_sys, - patch("profiles.gui.controllers.window_actions.messagebox") as mock_messagebox, - ): - mock_sys.executable = "python.exe" - mock_subprocess.Popen.side_effect = OSError("Launch failed") - actions = WindowActions( - config=AppConfig(), - logger=MagicMock(), - root_destroy=mock_destroy, - ) - actions.restart() - - # Verify error message was shown - mock_messagebox.showerror.assert_called_once() - assert "Restart Failed" in mock_messagebox.showerror.call_args.args[0] - - -# ── TestNoConfigFileMode (from test_no_config_file_mode.py) ──────────────── - - -class TestNoConfigFileMode: - """Tests for GUI behavior when no .profiles config file exists.""" - - def test_insert_chunk_uses_single_value_for_single_column(self, tmp_path: Path) -> None: - """When column_names has only 1 column, _insert_chunk should use single-value tuples. - - This test verifies the fix for the issue where clicking search buttons - would find files but the treeview wouldn't display them because it was - trying to insert 2 values (filename, version) into a 1-column treeview. - """ - # Create a mock window with single-column config - mock_window = MagicMock() - mock_window._config = AppConfig() - mock_window._config.column_names = ("File",) # Single column default - mock_window._config.column_headers = ("File",) - mock_window._config.column_widths = (600,) - mock_window._config.column_stretches = (True,) - mock_window._current_scan_id = 1 - mock_window._tree_to_path = {} - mock_window._tree_to_filename = {} - mock_window._row_color_rules = [] - - # Import the method as an unbound function - # Create test data - from profiles.core.processing.scanner import ScannedFileDynamic - from profiles.gui.main_window import MainWindow - - test_items = [ - ScannedFileDynamic( - path=tmp_path / "test1.mttl", - column_values={"File": "test1.mttl"}, - rel_path="test1.mttl", - ), - ScannedFileDynamic( - path=tmp_path / "test2.mttl", - column_values={"File": "test2.mttl"}, - rel_path="test2.mttl", - ), - ] - - # Mock tree insert to capture calls - mock_tree = MagicMock() - mock_window._tree = mock_tree - - # Call the method directly - MainWindow._insert_chunk( - mock_window, - scan_id=1, - items=test_items, - start_idx=0, - accumulated_files=[], - display_label=str(tmp_path), - filter_text="", - extension=".mttl", - ) - - # Verify insert was called with single-value tuples - assert mock_tree.insert.call_count == 2 - - # First call should have values=(filename,) not (filename, version) - first_call_args = mock_tree.insert.call_args_list[0] - assert first_call_args.kwargs["values"] == ("test1.mttl",) - - # Second call should also have single value - second_call_args = mock_tree.insert.call_args_list[1] - assert second_call_args.kwargs["values"] == ("test2.mttl",) - - def test_insert_chunk_uses_two_values_for_multi_column(self, tmp_path: Path) -> None: - """When column_names has 2+ columns, _insert_chunk should use 2-value tuples.""" - # Create a mock window with multi-column config - mock_window = MagicMock() - mock_window._config = AppConfig() - mock_window._config.column_names = ("File", "Version") # Two columns - mock_window._config.column_headers = ("File", "Version") - mock_window._config.column_widths = (400, 150) - mock_window._config.column_stretches = (True, False) - mock_window._current_scan_id = 1 - mock_window._tree_to_path = {} - mock_window._tree_to_filename = {} - mock_window._row_color_rules = [] - - # Create test data - from profiles.core.processing.scanner import ScannedFileDynamic - from profiles.gui.main_window import MainWindow - - test_items = [ - ScannedFileDynamic( - path=tmp_path / "test1.mttl", - column_values={"File": "test1.mttl", "Version": "v1.0"}, - rel_path="test1.mttl", - ), - ] - - # Mock tree insert - mock_tree = MagicMock() - mock_window._tree = mock_tree - - # Call the method - MainWindow._insert_chunk( - mock_window, - scan_id=1, - items=test_items, - start_idx=0, - accumulated_files=[], - display_label=str(tmp_path), - filter_text="", - extension=".mttl", - ) - - # Verify insert was called with 2-value tuples - assert mock_tree.insert.call_count == 1 - call_args = mock_tree.insert.call_args_list[0] - assert call_args.kwargs["values"] == ("test1.mttl", "v1.0") - - -# ── TestHexLuminance (from test_main_window_ui.py) ───────────────────────── - - -class TestHexLuminance: - """hex_luminance() — WCAG relative luminance for #RRGGBB strings.""" - - def test_black_is_zero(self) -> None: - """#000000 → 0.0 (per WCAG: all channels linearised to 0).""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#000000") == pytest.approx(0.0, abs=1e-6) - - def test_white_is_one(self) -> None: - """#ffffff → 1.0 (per WCAG: weighted sum of three ones).""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#ffffff") == pytest.approx(1.0, abs=1e-3) - - def test_red_is_2126(self) -> None: - """#ff0000 → ≈0.2126 (R coefficient × 1.0).""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#ff0000") == pytest.approx(0.2126, abs=0.01) - - def test_missing_hash_tolerated(self) -> None: - """A leading '#' is optional — same value with or without it.""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("ff0000") == pytest.approx(hex_luminance("#ff0000"), abs=1e-6) - - def test_uppercase_hex_matches(self) -> None: - """Uppercase hex digits parse to the same luminance as lowercase.""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#FF0000") == pytest.approx(hex_luminance("#ff0000"), abs=1e-6) - - def test_green_is_7152(self) -> None: - """Sanity check: pure green contributes the G coefficient (0.7152).""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#00ff00") == pytest.approx(0.7152, abs=0.01) - - def test_blue_is_0722(self) -> None: - """Sanity check: pure blue contributes the B coefficient (0.0722).""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("#0000ff") == pytest.approx(0.0722, abs=0.01) - - def test_invalid_returns_neutral(self) -> None: - """Garbage input returns the neutral fallback 0.5.""" - from profiles.gui.theme import hex_luminance - - assert hex_luminance("not-a-color") == 0.5 - assert hex_luminance("") == 0.5 - assert hex_luminance("#zz0000") == 0.5 - - def test_result_within_unit_interval(self) -> None: - """For a representative sample, the result must lie in [0, 1].""" - from profiles.gui.theme import hex_luminance - - for color in ("#000000", "#ffffff", "#7f7f7f", "#abcdef", "#123456"): - value = hex_luminance(color) - assert 0.0 <= value <= 1.0, f"{color} produced {value}" - - -# ── TestOnExecuteMultiSelect (from test_action_launch_args.py) ─────────────────────── - - -class TestOnExecuteMultiSelect: - """_on_execute must call launch_selected_file once per selected iid.""" - - @needs_tk - def test_no_selection_shows_info(self, window: MainWindow, mocker) -> None: - info = mocker.patch("profiles.gui.main_window.messagebox.showinfo") - launch = mocker.patch("profiles.gui.main_window.actions.launch_selected_file") - - window._on_execute() - - info.assert_called_once() - launch.assert_not_called() - - @needs_tk - def test_single_selection_launches_one_file( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - a = tmp_path / "a.mttl" - window._dir_var.set(str(tmp_path)) - iid = window._tree.insert(parent="", index=tk.END, values=("a.mttl", "1")) - window._tree_to_path[iid] = a - window._tree_to_filename[iid] = "a.mttl" - window._tree.selection_set(iid) - - launch = mocker.patch( - "profiles.gui.main_window.actions.launch_selected_file", - return_value=actions.ActionResult( - status=actions.ActionStatus.SUCCESS, - message="ok", - path=a, - ), - ) - - window._on_execute() - - launch.assert_called_once() - kwargs = launch.call_args.kwargs - assert kwargs["filename"] == "a.mttl" - assert Path(kwargs["directory"]) == tmp_path - - @needs_tk - def test_multi_selection_launches_each( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - window._dir_var.set(str(tmp_path)) - iid_a = window._tree.insert(parent="", index=tk.END, values=("a.mttl", "1")) - iid_b = window._tree.insert(parent="", index=tk.END, values=("b.mttl", "1")) - window._tree_to_path[iid_a] = tmp_path / "a.mttl" - window._tree_to_path[iid_b] = tmp_path / "b.mttl" - window._tree_to_filename[iid_a] = "a.mttl" - window._tree_to_filename[iid_b] = "b.mttl" - # Tk's `selectmode="extended"` requires selection_set per iid; the - # widgets let us combine them with selectmode toggled (e.g. shift-click - # in user UI). Calling selection_set twice is the most reliable way - # to verify multi-selection in tests. - window._tree.selection_set(iid_a) - window._tree.selection_add(iid_b) - - launch = mocker.patch( - "profiles.gui.main_window.actions.launch_selected_file", - return_value=actions.ActionResult( - status=actions.ActionStatus.SUCCESS, - message="ok", - ), - ) - - window._on_execute() - - assert launch.call_count == 2 - filenames = {c.kwargs["filename"] for c in launch.call_args_list} - assert filenames == {"a.mttl", "b.mttl"} - - @needs_tk - def test_first_failure_is_reported( - self, - window: MainWindow, - tmp_path: Path, - mocker, - ) -> None: - window._dir_var.set(str(tmp_path)) - iid_a = window._tree.insert(parent="", index=tk.END, values=("a.mttl", "1")) - iid_b = window._tree.insert(parent="", index=tk.END, values=("b.mttl", "1")) - window._tree_to_path[iid_a] = tmp_path / "a.mttl" - window._tree_to_path[iid_b] = tmp_path / "b.mttl" - window._tree_to_filename[iid_a] = "a.mttl" - window._tree_to_filename[iid_b] = "b.mttl" - window._tree.selection_set(iid_a) - window._tree.selection_add(iid_b) - - err = mocker.patch("profiles.gui.main_window.messagebox.showerror") - warn = mocker.patch("profiles.gui.main_window.messagebox.showwarning") - - effects = [ - actions.ActionResult( - status=actions.ActionStatus.NOT_FOUND, - message="a not found", - path=tmp_path / "a.mttl", - ), - actions.ActionResult( - status=actions.ActionStatus.SUCCESS, - message="b ok", - ), - ] - - def _side_effect(**_kwargs): - return effects.pop(0) - - mocker.patch( - "profiles.gui.main_window.actions.launch_selected_file", - side_effect=_side_effect, - ) - - window._on_execute() - - warn.assert_called_once() - # The second launch still happened even though the first failed. - err.assert_not_called() - - -# ── Root-level key bindings ────────────────────────────────────────────────── - - -class TestRootKeyBindings: - """``_configure_bindings`` wires Ctrl+F / F5 / Escape / Ctrl+R on root.""" - - @needs_tk - @pytest.mark.parametrize( - "sequence", - [ - ("", ""), - ("", ""), - ("", ""), - ("", ""), - ], - ) - def test_root_binding_present( - self, - window: MainWindow, - sequence: tuple[str, str], - ) -> None: - # Tk normalizes event sequences on registration; accept either form. - written, normalized = sequence - bindings = window._root.bind() - assert written in bindings or normalized in bindings, ( - f"expected {written} in {sorted(bindings)}" - ) - - @needs_tk - def test_ctrl_f_binding_callback_focuses_filter(self, window: MainWindow) -> None: - """The registered binding string is non-empty (Tk stores it as a tcl cmd).""" - # Bind registration testing: `bind` without an event name returns a - # tuple of bindings; with an event name it returns the callback string - # registered for that event. Both being non-empty is sufficient to - # prove the binding was wired up. - registered = window._root.bind("") - assert registered, "expected to be bound on the root window" - - -# ── Context menu entry ────────────────────────────────────────────────────── - - -class TestLaunchWithArgsMenuEntry: - """The context menu exposes a 'Launch with arguments…' entry.""" - - @needs_tk - def test_menu_includes_launch_with_args(self, window: MainWindow, mocker) -> None: - iid = window._tree.insert( - parent="", - index=tk.END, - values=("sample.mttl", "V1"), - ) - window._tree.selection_set(iid) - # Map the window and scroll the row into view so bbox is populated. - window._tree.see(iid) - window.root.update_idletasks() - bbox = window._tree.bbox(iid) - assert bbox - - event = MagicMock() - event.y = bbox[1] + 1 - event.x = bbox[0] + 1 - event.x_root = 100 - event.y_root = 100 - - captured: dict[str, list[str]] = {"labels": []} - original_add_command = tk.Menu.add_command - - def spy(self, **kwargs): # type: ignore[no-untyped-def] - label = kwargs.get("label", "") - if label: - captured["labels"].append(label) - return original_add_command(self, **kwargs) - - mocker.patch.object(tk.Menu, "add_command", spy) - mocker.patch.object(tk.Menu, "tk_popup", lambda self, x, y: None) - with contextlib.suppress(tk.TclError): - window._on_tree_right_click(event) - - labels = captured["labels"] - assert any("Launch with arguments" in label for label in labels), labels - - -# ── Row coloring (test_main_window_row_colors) ───────────────────────────── - - -def _tag_foreground(tree: ttk.Treeview, tag_name: str) -> str: - """Return the effective foreground configured for *tag_name* (lowercased).""" - return str(tree.tag_configure(tag_name, "foreground")).lower() - - -class TestRowColors: - """MainWindow._configure_row_colors — dark theme keeps configured colors. - - Regression guard for the bug where *starting* the app with the dark - theme rendered row colors grey (the canonical ``PROD:#1565C0`` trips - the old luminance-delta fallback against the dark surface), while a - runtime light→dark switch briefly kept the stale light-theme tag. - """ - - @needs_tk - def test_startup_dark_keeps_prod_blue(self, row_color_window) -> None: - """Starting with the dark theme must keep PROD:#1565C0 (not grey).""" - win = row_color_window(theme="dark", row_colors=(("PROD", "#1565C0"),)) - # Buggy behaviour: tag becomes "_rowcolor_PROD_7A7680" (grey outline). - assert ("PROD", "_rowcolor_PROD_1565C0") in win._row_color_rules, win._row_color_rules - assert _tag_foreground(win._tree, "_rowcolor_PROD_1565C0") == "#1565c0" - assert win._row_color_tags_for("PROD_sample.mttl") == ( - "_rowcolor_default", - "_rowcolor_PROD_1565C0", - ) - - @needs_tk - def test_light_to_dark_switch_reuses_same_tag(self, row_color_window) -> None: - """Switching light→dark must reconfigure the SAME tag, not diverge.""" - win = row_color_window(theme="light", row_colors=(("PROD", "#1565C0"),)) - assert ("PROD", "_rowcolor_PROD_1565C0") in win._row_color_rules - - win._apply_theme("dark") - - # Same tag name survives the switch (no stale-tag divergence). - assert ("PROD", "_rowcolor_PROD_1565C0") in win._row_color_rules, win._row_color_rules - assert _tag_foreground(win._tree, "_rowcolor_PROD_1565C0") == "#1565c0" - # A row inserted after a re-scan still resolves to the same tag/color. - assert win._row_color_tags_for("PROD_sample.mttl") == ( - "_rowcolor_default", - "_rowcolor_PROD_1565C0", - ) - - @needs_tk - def test_invisible_color_falls_back_to_readable_text(self, row_color_window) -> None: - """A color indistinguishable from the surface falls back to on_surface.""" - from profiles.gui.theme import LIGHT_THEME - - win = row_color_window(theme="light", row_colors=(("GHOST", "#FFFFFF"),)) - # Tag name still derives from the ORIGINAL configured color. - assert ("GHOST", "_rowcolor_GHOST_FFFFFF") in win._row_color_rules - fg = _tag_foreground(win._tree, "_rowcolor_GHOST_FFFFFF") - assert fg == LIGHT_THEME.on_surface.lower(), f"expected readable fallback, got {fg}" - - @needs_tk - def test_empty_rules_still_expose_default_tag(self, row_color_window) -> None: - """With no rules the `_rowcolor_default` tag is still configured.""" - from profiles.gui.theme import DARK_THEME - - win = row_color_window(theme="dark", row_colors=()) - assert win._row_color_rules == [] - fg = _tag_foreground(win._tree, "_rowcolor_default") - assert fg == DARK_THEME.on_surface_variant.lower() - - @needs_tk - def test_light_theme_canonical_colors_unchanged(self, row_color_window) -> None: - """Light theme must keep every documented canonical row color.""" - win = row_color_window( - theme="light", - row_colors=(("PROD", "#1565C0"), ("DEV", "#757575"), ("TMP", "#BAC015")), - ) - assert ("PROD", "_rowcolor_PROD_1565C0") in win._row_color_rules - assert ("DEV", "_rowcolor_DEV_757575") in win._row_color_rules - assert ("TMP", "_rowcolor_TMP_BAC015") in win._row_color_rules - assert _tag_foreground(win._tree, "_rowcolor_PROD_1565C0") == "#1565c0" - assert _tag_foreground(win._tree, "_rowcolor_DEV_757575") == "#757575" - assert _tag_foreground(win._tree, "_rowcolor_TMP_BAC015") == "#bac015" - - -# ── Language toggle ───────────────────────────────────────────────────────── - - -class TestLanguageToggle: - """MainWindow._on_toggle_language — cycles en↔fr and persists.""" - - @needs_tk - def test_toggle_switches_language_and_persists(self, window: MainWindow, mocker) -> None: - from profiles.gui import i18n - - i18n.set_language("en") - save_spy = mocker.patch( - "profiles.gui.main_window.write_value", - ) - window._on_toggle_language() - assert i18n.current_language() == "fr" - save_spy.assert_called_once_with( - window._config.config_path, - "defaults.language", - "fr", - ) - # Toggle again → back to English - window._on_toggle_language() - assert i18n.current_language() == "en" - i18n.set_language("en") - - @needs_tk - def test_toggle_relabels_status_bar(self, window: MainWindow) -> None: - from profiles.gui import i18n - - i18n.set_language("en") - window._on_toggle_language() - assert window._status_bar.config_link.cget("text") == i18n.t("status.config", lang="fr") - assert window._status_bar.language_btn.cget("text") == "\U0001f310 FR" - window._on_toggle_language() - assert window._status_bar.config_link.cget("text") == i18n.t("status.config", lang="en") - i18n.set_language("en") diff --git a/tests/gui/test_main_window_shortcuts.py b/tests/gui/test_main_window_shortcuts.py deleted file mode 100644 index 6d59753..0000000 --- a/tests/gui/test_main_window_shortcuts.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for ``MainWindow._on_show_shortcuts`` — deprecated Tkinter GUI. - -Lives under ``tests/gui`` so the directory-wide deprecation skip applies; -removed together with the Tkinter frontend in phase 2. -""" - -from __future__ import annotations - -import tkinter as tk -from tkinter import ttk - -from profiles.gui.main_window import MainWindow - - -class TestShortcutsDialog: - """The shortcuts dialog exposes both Keyboard and Mouse sections.""" - - def test_shortcuts_dialog_has_mouse_section(self) -> None: - """Render the dialog on a minimal host and confirm both section headers.""" - - class _Host: - """Minimal duck-typed host with the surface _on_show_shortcuts touches.""" - - def __init__(self, root: tk.Tk) -> None: - self._root = root - # Stubs for attributes _shortcut_entries references at build time. - self._refresh_file_list = lambda: None - self._on_refresh = lambda: None - self._on_open_log = lambda: None - self._on_open_config = lambda: None - self._on_toggle_theme = lambda: None - self._on_close = lambda: None - self._on_execute = lambda: None - self._dir_combo = tk.Entry(root) - self._ext_combo = tk.Entry(root) - self._filter_combo = tk.Entry(root) - self._tree = ttk.Treeview(root) - self._filter_var = tk.StringVar(root) - - _shortcut_entries = MainWindow._shortcut_entries - _mouse_entries = MainWindow._mouse_entries - _on_show_shortcuts = MainWindow._on_show_shortcuts - - root = tk.Tk() - try: - host = _Host(root) - host._on_show_shortcuts() - root.update_idletasks() - - toplevels = [w for w in root.winfo_children() if isinstance(w, tk.Toplevel)] - assert toplevels, "Expected _on_show_shortcuts to create a Toplevel dialog" - dlg = toplevels[-1] - - def _text_of(widget: tk.Misc) -> str | None: - if not isinstance(widget, (tk.Widget, ttk.Widget)): - return None - try: - return str(widget.cget("text")) - except (tk.TclError, AttributeError): - return None - - labels_text = [t for t in (_text_of(w) for w in dlg.winfo_children()) if t] - labels_text += [ - t - for child in dlg.winfo_children() - if isinstance(child, (tk.Frame, ttk.Frame)) - for t in (_text_of(w) for w in child.winfo_children()) - if t - ] - assert "Keyboard Shortcuts" in labels_text - assert "Mouse Actions" in labels_text - finally: - root.destroy() diff --git a/tests/gui/test_search_bar.py b/tests/gui/test_search_bar.py deleted file mode 100644 index 64e3ed0..0000000 --- a/tests/gui/test_search_bar.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Tests for profiles.gui.search_bar.SearchBar without instantiating tk.Tk. - -Strategy: patch ``tkinter.ttk`` and the ``tkinter.StringVar`` class so the -constructor runs against in-memory fakes. Capture widget creation calls so tests -can assert widget identity and event bindings. - -Production source under test is NOT modified — these tests observe behaviour. -""" - -from __future__ import annotations - -import types -from unittest.mock import MagicMock - -import pytest - -from profiles.gui.search_bar import SearchBar - -# ── In-memory fakes ───────────────────────────────────────────────────────── - - -class FakeWidget: - """In-memory ttk widget that records construction and method calls.""" - - instances: list[FakeWidget] = [] - - def __init__(self, *args, **kwargs) -> None: - self._args = args - self._kwargs = kwargs - self._bindings: dict[str, object] = {} - self._text: str = kwargs.get("text", "") - self._command = kwargs.get("command") - FakeWidget.instances.append(self) - - # Layout no-ops - def pack(self, *args, **kwargs) -> None: - return None - - def grid(self, *args, **kwargs) -> None: - return None - - # Event binding - def bind(self, event: str, callback, add=None) -> str: # type: ignore[no-untyped-def] - self._bindings[event] = callback - return f"fake-binding-{event}" - - # Configuration - def configure(self, **kwargs) -> None: - if "text" in kwargs: - self._text = kwargs["text"] - - def cget(self, key: str): - if key == "text": - return self._text - return None - - # Misc ttk widget methods - def focus_set(self) -> None: - return None - - def invoke(self) -> None: - if self._command is not None: - self._command() - - def winfo_height(self) -> int: - return 36 - - def winfo_rootx(self) -> int: - return 0 - - def winfo_rooty(self) -> int: - return 0 - - def update_idletasks(self) -> None: - return None - - -class FakeStringVar: - """In-memory StringVar stand-in.""" - - def __init__(self, *args, **kwargs) -> None: - self._value: str = kwargs.get("value", "") - - def get(self) -> str: - return self._value - - def set(self, value: str) -> None: - self._value = value - - -class FakeToolTip: - """No-op ToolTip stand-in. Real ToolTip binds events to widgets.""" - - def __init__(self, *args, **kwargs) -> None: - return None - - -# ── Fixtures ──────────────────────────────────────────────────────────────── - - -def _make_fake_tk_constants() -> types.SimpleNamespace: - """Build a fake ``tk`` namespace exposing the constants used by SearchBar.""" - return types.SimpleNamespace( - StringVar=FakeStringVar, - X="x", - Y="y", - BOTH="both", - LEFT="left", - RIGHT="right", - TOP="top", - BOTTOM="bottom", - W="w", - E="e", - N="n", - S="s", - NW="nw", - NE="ne", - SW="sw", - SE="se", - CENTER="center", - VERTICAL="vertical", - HORIZONTAL="horizontal", - NORMAL="normal", - DISABLED="disabled", - ) - - -def _make_fake_ttk() -> types.SimpleNamespace: - """Build a fake ``ttk`` namespace exposing every widget class used by SearchBar.""" - return types.SimpleNamespace( - Frame=FakeWidget, - Label=FakeWidget, - Combobox=FakeWidget, - Checkbutton=FakeWidget, - Button=FakeWidget, - Separator=FakeWidget, - Treeview=FakeWidget, - ) - - -@pytest.fixture -def patched_tk(monkeypatch: pytest.MonkeyPatch) -> FakeWidget: - """Patch tkinter imports in ``search_bar`` module to in-memory fakes.""" - FakeWidget.instances = [] - - monkeypatch.setattr("profiles.gui.search_bar.tk", _make_fake_tk_constants()) - monkeypatch.setattr("profiles.gui.search_bar.ttk", _make_fake_ttk()) - monkeypatch.setattr("profiles.gui.search_bar.ToolTip", FakeToolTip) - - return FakeWidget - - -@pytest.fixture -def callback_mocks() -> dict[str, MagicMock]: - """Mock every callback the SearchBar consumes.""" - return { - "on_directory_changed": MagicMock(), - "on_directory_enter": MagicMock(), - "on_directory_double_click": MagicMock(), - "on_browse": MagicMock(), - "on_extension_or_filter_select": MagicMock(), - "on_debounced_refresh_ext": MagicMock(), - "on_flush_timer_ext": MagicMock(), - "on_filter_refresh": MagicMock(), - "on_flush_timer_filter": MagicMock(), - "on_recursive_toggle": MagicMock(), - "on_search": MagicMock(), - } - - -def _make_search_bar(callbacks: dict[str, MagicMock]) -> SearchBar: - """Build a SearchBar with the faked Tk environment.""" - parent = FakeWidget() - recursive_var = MagicMock() - return SearchBar( - parent=parent, - release_version="1.0.0", - recursive_var=recursive_var, - config_title="", - on_directory_changed=callbacks["on_directory_changed"], - on_directory_enter=callbacks["on_directory_enter"], - on_directory_double_click=callbacks["on_directory_double_click"], - on_browse=callbacks["on_browse"], - on_extension_or_filter_select=callbacks["on_extension_or_filter_select"], - on_debounced_refresh_ext=callbacks["on_debounced_refresh_ext"], - on_flush_timer_ext=callbacks["on_flush_timer_ext"], - on_filter_refresh=callbacks["on_filter_refresh"], - on_flush_timer_filter=callbacks["on_flush_timer_filter"], - on_recursive_toggle=callbacks["on_recursive_toggle"], - on_search=callbacks["on_search"], - ) - - -# ── Widget construction tests ─────────────────────────────────────────────── - - -class TestSearchBarBuildsWidgets: - """``SearchBar.__init__`` builds every expected widget.""" - - def test_builds_search_frame(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.search_frame is not None - assert isinstance(bar.search_frame, FakeWidget) - - def test_builds_dir_frame(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.dir_frame is not None - assert isinstance(bar.dir_frame, FakeWidget) - - def test_builds_filter_frame(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.filter_frame is not None - assert isinstance(bar.filter_frame, FakeWidget) - - def test_builds_dir_combo(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.dir_combo is not None - assert isinstance(bar.dir_combo, FakeWidget) - - def test_builds_ext_combo(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.ext_combo is not None - assert isinstance(bar.ext_combo, FakeWidget) - - def test_builds_filter_combo(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.filter_combo is not None - assert isinstance(bar.filter_combo, FakeWidget) - - def test_builds_recursive_check(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.recursive_check is not None - assert isinstance(bar.recursive_check, FakeWidget) - - def test_builds_search_btn(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.search_btn is not None - assert isinstance(bar.search_btn, FakeWidget) - - def test_builds_browse_btn(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.browse_btn is not None - assert isinstance(bar.browse_btn, FakeWidget) - - def test_builds_title_label(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert bar.title_label is not None - assert isinstance(bar.title_label, FakeWidget) - - -# ── Event binding tests ───────────────────────────────────────────────────── - - -class TestSearchBarBindingsPresence: - """Verify the expected event bindings are registered on each widget.""" - - def test_dir_combo_binds_combobox_selected(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert "<>" in bar.dir_combo._bindings - - def test_dir_combo_binds_return(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert "" in bar.dir_combo._bindings - - def test_dir_combo_binds_double_click(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert "" in bar.dir_combo._bindings - - def test_ext_combo_binds_key_release(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert "" in bar.ext_combo._bindings - - def test_filter_combo_binds_key_release(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - assert "" in bar.filter_combo._bindings - - -class TestSearchBarBindingsInvokeCallbacks: - """Bindings should fire the right callback when invoked.""" - - def test_dir_combo_combobox_selected_fires_callback(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.dir_combo._bindings["<>"]() - callback_mocks["on_directory_changed"].assert_called_once() - - def test_dir_combo_return_fires_callback(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.dir_combo._bindings[""]() - callback_mocks["on_directory_enter"].assert_called_once() - - def test_dir_combo_double_click_fires_callback(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.dir_combo._bindings[""]() - callback_mocks["on_directory_double_click"].assert_called_once() - - def test_ext_combo_key_release_fires_callback(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.ext_combo._bindings[""](None) - callback_mocks["on_debounced_refresh_ext"].assert_called_once() - - def test_filter_combo_key_release_fires_callback(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.filter_combo._bindings[""](None) - callback_mocks["on_filter_refresh"].assert_called_once() - - -# ── Search button invoke test ─────────────────────────────────────────────── - - -class TestSearchBarButtonInvoke: - """The search button's ``invoke()`` should fire the on_search callback.""" - - def test_search_btn_invoke_fires_on_search(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.search_btn.invoke() - callback_mocks["on_search"].assert_called_once() - - def test_browse_btn_invoke_fires_on_browse(self, patched_tk, callback_mocks): - bar = _make_search_bar(callback_mocks) - bar.browse_btn.invoke() - callback_mocks["on_browse"].assert_called_once() diff --git a/tests/gui/test_status_bar.py b/tests/gui/test_status_bar.py deleted file mode 100644 index f22494e..0000000 --- a/tests/gui/test_status_bar.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Tests for the StatusBar component.""" - -from __future__ import annotations - -import contextlib -import tkinter as tk -from tkinter import ttk -from unittest.mock import MagicMock - -import pytest - -from profiles.gui.status_bar import StatusBar - - -@pytest.fixture -def mock_callbacks(): - """Create mock callback functions.""" - return { - "on_config_click": MagicMock(), - "on_refresh_click": MagicMock(), - "on_log_click": MagicMock(), - "on_theme_toggle": MagicMock(), - } - - -@pytest.fixture -def status_bar(mock_callbacks): - """Create a StatusBar instance for testing. - - Creates a fresh Tkinter environment for each test to avoid - race conditions and resource conflicts between tests. - """ - root = None - frame = None - max_retries = 3 - - for attempt in range(max_retries): - try: - root = tk.Tk() - root.withdraw() # Hide the window - frame = ttk.Frame(root) - frame.pack() - - bar = StatusBar( - parent=frame, - on_config_click=mock_callbacks["on_config_click"], - on_refresh_click=mock_callbacks["on_refresh_click"], - on_log_click=mock_callbacks["on_log_click"], - on_theme_toggle=mock_callbacks["on_theme_toggle"], - theme_label="☀ Light", - ) - - yield bar - - # Clean up in reverse order - root.destroy() - return - - except (tk.TclError, OSError) as e: - # Clean up partial resources - if frame: - with contextlib.suppress(Exception): - frame.pack_forget() - if root: - with contextlib.suppress(Exception): - root.destroy() - - # If this was the last attempt, re-raise the error - if attempt == max_retries - 1: - pytest.skip(f"Tkinter initialization failed after {max_retries} attempts: {e}") - # Otherwise, retry with a small delay - import time - - time.sleep(0.1) - - -class TestStatusBarInit: - """Tests for StatusBar initialization.""" - - def test_creates_status_frame(self, status_bar): - """Verify status frame is created.""" - assert status_bar._status_frame is not None - assert isinstance(status_bar._status_frame, ttk.Frame) - - def test_creates_all_labels(self, status_bar): - """Verify all label widgets are created.""" - assert status_bar._user_label is not None - assert status_bar._host_label is not None - assert status_bar._ip_label is not None - assert status_bar._count_label is not None - assert status_bar._dir_status_label is not None - - def test_creates_all_buttons(self, status_bar): - """Verify all button widgets are created.""" - assert status_bar._config_link is not None - assert status_bar._refresh_btn is not None - assert status_bar._log_link is not None - assert status_bar._theme_btn is not None - - def test_initial_theme_label(self, status_bar): - """Verify initial theme label is set.""" - assert status_bar._theme_btn.cget("text") == "☀ Light" - - -class TestStatusBarProperties: - """Tests for StatusBar property accessors.""" - - def test_status_frame_property(self, status_bar): - """Verify status_frame property returns correct frame.""" - assert status_bar.status_frame is status_bar._status_frame - - def test_status_inner_property(self, status_bar): - """Verify status_inner property returns correct frame.""" - assert status_bar.status_inner is status_bar._status_inner - - def test_widget_properties(self, status_bar): - """Verify all widget properties return correct instances.""" - assert status_bar.config_link is status_bar._config_link - assert status_bar.refresh_btn is status_bar._refresh_btn - assert status_bar.log_link is status_bar._log_link - assert status_bar.theme_btn is status_bar._theme_btn - assert status_bar.user_label is status_bar._user_label - assert status_bar.host_label is status_bar._host_label - assert status_bar.ip_label is status_bar._ip_label - assert status_bar.count_label is status_bar._count_label - assert status_bar.dir_status_label is status_bar._dir_status_label - - -class TestStatusBarCallbacks: - """Tests for StatusBar callback bindings.""" - - def test_config_button_calls_callback(self, status_bar, mock_callbacks): - """Verify config button triggers callback.""" - status_bar._config_link.invoke() - mock_callbacks["on_config_click"].assert_called_once() - - def test_refresh_button_calls_callback(self, status_bar, mock_callbacks): - """Verify refresh button triggers callback.""" - status_bar._refresh_btn.invoke() - mock_callbacks["on_refresh_click"].assert_called_once() - - def test_log_button_calls_callback(self, status_bar, mock_callbacks): - """Verify log button triggers callback.""" - status_bar._log_link.invoke() - mock_callbacks["on_log_click"].assert_called_once() - - def test_theme_button_calls_callback(self, status_bar, mock_callbacks): - """Verify theme button triggers callback.""" - status_bar._theme_btn.invoke() - mock_callbacks["on_theme_toggle"].assert_called_once() - - -class TestStatusBarUpdateThemeLabel: - """Tests for StatusBar theme label update.""" - - def test_update_theme_label(self, status_bar): - """Verify theme label can be updated.""" - status_bar.update_theme_label("🌙 Dark") - assert status_bar._theme_btn.cget("text") == "🌙 Dark" - - def test_update_theme_label_with_different_text(self, status_bar): - """Verify theme label update with various texts.""" - test_labels = ["Light", "Dark", "Custom Theme", "☀ Light", "🌙 Dark"] - for label in test_labels: - status_bar.update_theme_label(label) - assert status_bar._theme_btn.cget("text") == label - - -class TestStatusBarTooltips: - """Tests for StatusBar tooltip functionality.""" - - def test_user_label_has_tooltip(self, status_bar): - """Verify user label has tooltip bound.""" - # ToolTip binds events, so we check if events are present - events = status_bar._user_label.bind() - assert "" in events or len(events) > 0 - - def test_host_label_has_tooltip(self, status_bar): - """Verify host label has tooltip bound.""" - events = status_bar._host_label.bind() - assert "" in events or len(events) > 0 - - def test_ip_label_has_tooltip(self, status_bar): - """Verify IP label has tooltip bound.""" - events = status_bar._ip_label.bind() - assert "" in events or len(events) > 0 - - def test_count_label_has_tooltip(self, status_bar): - """Verify count label has tooltip bound.""" - events = status_bar._count_label.bind() - assert "" in events or len(events) > 0 - - def test_dir_status_label_has_tooltip(self, status_bar): - """Verify directory status label has tooltip bound.""" - events = status_bar._dir_status_label.bind() - assert "" in events or len(events) > 0 - - -class TestStatusBarUpdateThemeLabelMutation: - """update_theme_label mutates the theme button text.""" - - def test_button_text_mutates_to_supplied_value(self, status_bar): - initial = status_bar._theme_btn.cget("text") - new_label = "🌙 Dark" - assert initial != new_label - status_bar.update_theme_label(new_label) - assert status_bar._theme_btn.cget("text") == new_label - - def test_button_text_mutates_back_and_forth(self, status_bar): - status_bar.update_theme_label("🌙 Dark") - assert status_bar._theme_btn.cget("text") == "🌙 Dark" - status_bar.update_theme_label("☀ Light") - assert status_bar._theme_btn.cget("text") == "☀ Light" - - -class TestStatusBarShortcutsBranch: - """The ``on_shortcuts_click is None`` branch leaves shortcuts_btn as None.""" - - def test_shortcuts_btn_is_none_when_callback_missing(self, status_bar): - """status_bar fixture omits on_shortcuts_click → defaults to None.""" - assert status_bar._on_shortcuts_click is None - assert status_bar._shortcuts_btn is None - - def test_other_widgets_still_pack_when_shortcuts_missing(self, status_bar): - """Every non-shortcuts widget must still exist when shortcuts is None.""" - assert status_bar._status_frame is not None - assert status_bar._status_inner is not None - assert status_bar._config_link is not None - assert status_bar._refresh_btn is not None - assert status_bar._log_link is not None - assert status_bar._theme_btn is not None - assert status_bar._user_label is not None - assert status_bar._host_label is not None - assert status_bar._ip_label is not None - assert status_bar._count_label is not None - assert status_bar._dir_status_label is not None - - def test_shortcuts_property_returns_none_when_callback_missing(self, status_bar): - """Public shortcuts_btn property reflects the None branch.""" - assert status_bar.shortcuts_btn is None - - -class TestStatusBarLanguageBranch: - """The ``on_language_toggle is None`` branch leaves language_btn as None.""" - - def test_language_btn_is_none_when_callback_missing(self, status_bar): - """status_bar fixture omits on_language_toggle → defaults to None.""" - assert status_bar._on_language_toggle is None - assert status_bar._language_btn is None - assert status_bar.language_btn is None - - def test_language_btn_created_when_callback_provided(self, mock_callbacks): - """Providing on_language_toggle creates the language button.""" - root = tk.Tk() - root.withdraw() - try: - frame = ttk.Frame(root) - frame.pack() - bar = StatusBar( - parent=frame, - on_config_click=mock_callbacks["on_config_click"], - on_refresh_click=mock_callbacks["on_refresh_click"], - on_log_click=mock_callbacks["on_log_click"], - on_theme_toggle=mock_callbacks["on_theme_toggle"], - on_language_toggle=MagicMock(), - ) - assert bar.language_btn is not None - assert bar._language_tooltip is not None - finally: - root.destroy() - - def test_update_language_label_reflects_current_language(self, mock_callbacks): - """update_language_label sets the button text from LANGUAGE_LABELS.""" - from profiles.gui.i18n import LANGUAGE_LABELS, set_current_language - - root = tk.Tk() - root.withdraw() - try: - frame = ttk.Frame(root) - frame.pack() - bar = StatusBar( - parent=frame, - on_config_click=mock_callbacks["on_config_click"], - on_refresh_click=mock_callbacks["on_refresh_click"], - on_log_click=mock_callbacks["on_log_click"], - on_theme_toggle=mock_callbacks["on_theme_toggle"], - on_language_toggle=MagicMock(), - ) - set_current_language("fr") - bar.update_language_label() - assert bar.language_btn.cget("text") == LANGUAGE_LABELS["fr"] - set_current_language("en") - bar.update_language_label() - assert bar.language_btn.cget("text") == LANGUAGE_LABELS["en"] - finally: - root.destroy() - - def test_apply_text_relabels_widgets(self, mock_callbacks): - """_apply_text re-labels buttons and tooltips from the catalog.""" - from profiles.gui.i18n import set_current_language, t - - root = tk.Tk() - root.withdraw() - try: - frame = ttk.Frame(root) - frame.pack() - bar = StatusBar( - parent=frame, - on_config_click=mock_callbacks["on_config_click"], - on_refresh_click=mock_callbacks["on_refresh_click"], - on_log_click=mock_callbacks["on_log_click"], - on_theme_toggle=mock_callbacks["on_theme_toggle"], - on_language_toggle=MagicMock(), - ) - set_current_language("fr") - bar._apply_text() - assert bar.config_link.cget("text") == t("status.config", lang="fr") - assert bar.refresh_btn.cget("text") == t("status.refresh", lang="fr") - assert bar.log_link.cget("text") == t("status.log", lang="fr") - assert bar.language_btn.cget("text") == "\U0001f310 FR" - set_current_language("en") - finally: - root.destroy() diff --git a/tests/gui/test_styles.py b/tests/gui/test_styles.py deleted file mode 100644 index 86dd217..0000000 --- a/tests/gui/test_styles.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Tests for profiles.gui.styles — configure_styles, ToolTip class. - -Note: Tests that require a Tk root widget use ``pytest.mark.skipif`` -when Tkinter is unavailable (headless CI, missing Tcl/Tk). The ToolTip -class is tested for binding setup only — never calls mainloop(). -""" - -from __future__ import annotations - -import tkinter as tk - -import pytest - -from profiles.gui.styles import ToolTip, configure_styles, current_theme -from profiles.gui.theme import ( - DARK_THEME, - FONT_FAMILY, - FONT_SIZE_LARGE, - FONT_SIZE_NORMAL, - FONT_SIZE_SMALL, - LIGHT_THEME, -) - -# ── Tkinter availability guard ────────────────────────────────────────────── - -_tk_available = True -try: - _root_test = tk.Tk() - _root_test.destroy() -except (tk.TclError, RuntimeError): - _tk_available = False - -needs_tk = pytest.mark.skipif( - not _tk_available, - reason="Tkinter not available (headless CI or missing Tcl/Tk)", -) - - -# ── current_theme ──────────────────────────────────────────────────────────── - - -class TestCurrentTheme: - """current_theme() accessor.""" - - def test_returns_md3_theme(self) -> None: - theme = current_theme() - assert theme is LIGHT_THEME # default before configure_styles call - - def test_reimported(self) -> None: - """Re-import from theme.py matches.""" - from profiles.gui.theme import LIGHT_THEME as LT # noqa: PLC0415 - - assert current_theme() is LT - - -# ── configure_styles ──────────────────────────────────────────────────────── - - -class TestConfigureStyles: - """configure_styles() function — theme reference only (no Tk root needed).""" - - def test_configure_light(self) -> None: - theme = configure_styles(None, "light") - assert theme is LIGHT_THEME - assert current_theme() is LIGHT_THEME - - def test_configure_dark(self) -> None: - theme = configure_styles(None, "dark") - assert theme is DARK_THEME - assert current_theme() is DARK_THEME - - def test_unknown_theme_falls_back_to_light(self) -> None: - theme = configure_styles(None, "unknown") - assert theme is LIGHT_THEME - - -# ── Font constants ────────────────────────────────────────────────────────── - - -class TestFontConstants: - """Font configuration values are sensible (re-exported from theme.py).""" - - def test_font_family_is_string(self) -> None: - assert isinstance(FONT_FAMILY, str) - assert len(FONT_FAMILY) > 0 - - def test_font_sizes_positive(self) -> None: - assert FONT_SIZE_SMALL > 0 - assert FONT_SIZE_NORMAL > 0 - assert FONT_SIZE_LARGE > 0 - - def test_font_size_ordering(self) -> None: - assert FONT_SIZE_SMALL < FONT_SIZE_NORMAL < FONT_SIZE_LARGE - - -# ── ToolTip (requires Tk root, no mainloop) ───────────────────────────────── - - -@needs_tk -class TestToolTip: - """ToolTip binding setup — no main loop required.""" - - @pytest.fixture - def root(self) -> tk.Tk: - """Create a temporary Tk root for widget creation.""" - r = tk.Tk() - yield r - r.destroy() - - def test_binds_enter_leave_buttonpress(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Helpful text") # noqa: F841 - # Verify bindings were added - assert widget.bind("") != "" - assert widget.bind("") != "" - assert widget.bind("") != "" - - def test_default_delay_ms_is_400(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "x") - assert tip.delay_ms == 400 - - def test_custom_delay_ms(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "x", delay_ms=50) - assert tip.delay_ms == 50 - - def test_light_dark_tooltip_bg_differ(self) -> None: - assert LIGHT_THEME.tooltip_bg != DARK_THEME.tooltip_bg - - def test_empty_text_skips_show(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "") - assert tip._tip is None - # _show should not create a Toplevel for empty text - tip._show() - assert tip._tip is None - - def test_show_creates_toplevel(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Some info") - tip._show() - assert tip._tip is not None - assert isinstance(tip._tip, tk.Toplevel) - tip._hide() - assert tip._tip is None - - def test_double_show_does_not_duplicate(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Info") - tip._show() - first_tip = tip._tip - tip._show() # should be no-op (tip is not None and not empty) - assert tip._tip is first_tip - tip._hide() - - def test_hide_destroyed_tip_graceful(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Info") - tip._show() - assert tip._tip is not None - tip._hide() - assert tip._tip is None - # Hiding again should not error - tip._hide() - assert tip._tip is None - - def test_set_text_updates_when_tip_hidden(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Initial") - tip.set_text("Updated") - assert tip._text == "Updated" - assert tip._tip is None - - def test_set_text_refreshes_visible_tip(self, root: tk.Tk) -> None: - widget = tk.Label(root, text="test") - tip = ToolTip(widget, "Initial") - tip._show() - assert tip._tip is not None - tip.set_text("Updated") - assert tip._text == "Updated" - labels = tip._tip.winfo_children() - assert labels[0].cget("text") == "Updated" diff --git a/tests/gui/test_theme.py b/tests/gui/test_theme.py deleted file mode 100644 index 7320353..0000000 --- a/tests/gui/test_theme.py +++ /dev/null @@ -1,499 +0,0 @@ -"""Tests for profiles.gui.theme — Md3Theme, theming constants, apply_theme. - -Note: Tests that require a Tk root widget use ``pytest.mark.skipif`` -when Tkinter is unavailable (headless CI, missing Tcl/Tk). -""" - -from __future__ import annotations - -import sys -import tkinter as tk - -import pytest - -from profiles.gui.theme import ( - DARK_THEME, - FONT_FAMILY, - FONT_SIZE_LARGE, - FONT_SIZE_NORMAL, - FONT_SIZE_SMALL, - LIGHT_THEME, - THEME_LABELS, - THEMES, - Md3Theme, - apply_theme, - assert_contrast, - contrast_ratio, - hex_luminance, - resolve_theme_name, -) - -# ── Tkinter availability guard ────────────────────────────────────────────── - -_tk_available = True -try: - _root_test = tk.Tk() - _root_test.destroy() -except (tk.TclError, RuntimeError): - _tk_available = False - -needs_tk = pytest.mark.skipif( - not _tk_available, - reason="Tkinter not available (headless CI or missing Tcl/Tk)", -) - -# ── Md3Theme dataclass ───────────────────────────────────────────────────── - - -class TestMd3Theme: - """Md3Theme frozen dataclass.""" - - def test_is_frozen(self) -> None: - theme = Md3Theme( - primary="#000000", - on_primary="#FFFFFF", - primary_container="#E0E0E0", - on_primary_container="#000000", - secondary="#666666", - on_secondary="#FFFFFF", - surface="#FFFFFF", - surface_container="#F5F5F5", - surface_dim="#DDDDDD", - on_surface="#000000", - on_surface_variant="#444444", - outline="#888888", - outline_variant="#AAAAAA", - background="#FFFFFF", - on_background="#000000", - error="#B3261E", - on_error="#FFFFFF", - error_container="#F9DEDC", - status_info="#E8F4FD", - status_info_fg="#003049", - status_warning="#FEF9C3", - status_warning_fg="#713000", - status_error="#FEF2F2", - status_error_fg="#991B1B", - status_success="#DCFCE8", - status_success_fg="#15803D", - link="#005fb8", - link_hover="#004791", - elevation_0="#ffffff", - elevation_1="#F5F5F5", - header_bg="#1F1F1F", - header_fg="#FFFFFF", - status_bg="#F0F0F4", - border="#C4C6D0", - prod="#1565C0", - dev="#757575", - green="#2E7D32", - green_hover="#1B5E20", - hover="#E3F2FD", - selected="#1565C0", - tooltip_bg="#212121", - tooltip_fg="#fafafa", - tooltip_border="#212121", - focus_ring="#005fb8", - ) - with pytest.raises(AttributeError): - theme.primary = "#FF0000" # type: ignore[misc] - - def test_has_tooltip_and_focus_tokens(self) -> None: - for field in ("tooltip_bg", "tooltip_fg", "tooltip_border", "focus_ring"): - assert field in Md3Theme.__dataclass_fields__ - assert isinstance(getattr(LIGHT_THEME, field), str) - assert isinstance(getattr(DARK_THEME, field), str) - - def test_light_dark_tokens_differ(self) -> None: - assert LIGHT_THEME.tooltip_bg != DARK_THEME.tooltip_bg - assert LIGHT_THEME.focus_ring != DARK_THEME.focus_ring - - def test_required_fields_present(self) -> None: - """Smoke check that a fully populated Md3Theme can be created.""" - theme = LIGHT_THEME - assert isinstance(theme.primary, str) - assert theme.primary.startswith("#") - assert theme.on_primary.startswith("#") - - -# ── Theme instances ───────────────────────────────────────────────────────── - - -class TestThemeInstances: - """LIGHT_THEME and DARK_THEME validity.""" - - def _is_valid_hex_color(self, color: str) -> bool: - return isinstance(color, str) and len(color) == 7 and color.startswith("#") - - def test_light_theme_all_colors_valid_hex(self) -> None: - for field_name in Md3Theme.__dataclass_fields__: - value = getattr(LIGHT_THEME, field_name) - assert self._is_valid_hex_color(value), f"LIGHT_THEME.{field_name} invalid: {value!r}" - - def test_dark_theme_all_colors_valid_hex(self) -> None: - for field_name in Md3Theme.__dataclass_fields__: - value = getattr(DARK_THEME, field_name) - assert self._is_valid_hex_color(value), f"DARK_THEME.{field_name} invalid: {value!r}" - - def test_themes_are_different(self) -> None: - assert LIGHT_THEME is not DARK_THEME - assert LIGHT_THEME.surface != DARK_THEME.surface - assert LIGHT_THEME.on_surface != DARK_THEME.on_surface - - def test_light_surface_lighter_than_dark(self) -> None: - """Light theme surface should be a light colour (high RGB), - dark theme surface should be dark (low RGB).""" - # Parse hex to int for comparison - light_rgb = int(LIGHT_THEME.surface[1:], 16) - dark_rgb = int(DARK_THEME.surface[1:], 16) - assert light_rgb > dark_rgb, "Light theme surface should be lighter than dark theme surface" - - -# ── THEMES / THEME_LABELS dicts ───────────────────────────────────────────── - - -class TestThemeDicts: - """THEMES and THEME_LABELS lookup dictionaries.""" - - def test_themes_has_both(self) -> None: - assert "light" in THEMES - assert "dark" in THEMES - - def test_themes_values_are_md3_theme(self) -> None: - for theme in THEMES.values(): - assert isinstance(theme, Md3Theme) - - def test_theme_labels_has_both(self) -> None: - assert "light" in THEME_LABELS - assert "dark" in THEME_LABELS - - def test_theme_labels_non_empty(self) -> None: - for label in THEME_LABELS.values(): - assert len(label) > 0 - - -# ── Font constants ────────────────────────────────────────────────────────── - - -class TestFontConstants: - """Font configuration values are sensible.""" - - def test_font_family_is_string(self) -> None: - assert isinstance(FONT_FAMILY, str) - assert len(FONT_FAMILY) > 0 - - def test_font_sizes_positive(self) -> None: - assert FONT_SIZE_SMALL > 0 - assert FONT_SIZE_NORMAL > 0 - assert FONT_SIZE_LARGE > 0 - - def test_font_size_ordering(self) -> None: - assert FONT_SIZE_SMALL < FONT_SIZE_NORMAL < FONT_SIZE_LARGE - - -# ── apply_theme (requires Tk root, no mainloop) ───────────────────────────── - - -@needs_tk -class TestApplyTheme: - """apply_theme function — verifies it runs without error.""" - - @pytest.fixture - def root(self) -> tk.Tk: - """Create a temporary Tk root for style configuration.""" - r = tk.Tk() - yield r - r.destroy() - - def test_apply_light_theme(self, root: tk.Tk) -> None: - style = apply_theme(root, LIGHT_THEME) - assert style is not None - # Verify ttk styles were configured - assert style.lookup("FileList.Treeview", "fieldbackground") == LIGHT_THEME.surface - - def test_apply_dark_theme(self, root: tk.Tk) -> None: - style = apply_theme(root, DARK_THEME) - assert style is not None - # Verify ttk styles were configured with dark colours - assert style.lookup("FileList.Treeview", "fieldbackground") == DARK_THEME.surface - - def test_theme_switch(self, root: tk.Tk) -> None: - """Apply light then dark — styles should change.""" - apply_theme(root, LIGHT_THEME) - style = apply_theme(root, DARK_THEME) - assert style.lookup("FileList.Treeview", "fieldbackground") == DARK_THEME.surface - - def test_reapply_same_theme(self, root: tk.Tk) -> None: - """Re-applying the same theme should not raise and keep styles intact.""" - apply_theme(root, LIGHT_THEME) - style = apply_theme(root, LIGHT_THEME) - assert style.lookup("FileList.Treeview", "fieldbackground") == LIGHT_THEME.surface - - def test_reapply_toggles_back_to_light(self, root: tk.Tk) -> None: - """Light → dark → light must restore the light surface colour.""" - apply_theme(root, LIGHT_THEME) - apply_theme(root, DARK_THEME) - style = apply_theme(root, LIGHT_THEME) - assert style.lookup("FileList.Treeview", "fieldbackground") == LIGHT_THEME.surface - assert style.lookup("FileList.Treeview", "fieldbackground") != DARK_THEME.surface - - def test_apply_theme_twice_is_idempotent(self, root: tk.Tk) -> None: - """Calling apply_theme twice (same theme) must not raise.""" - style = apply_theme(root, LIGHT_THEME) - style2 = apply_theme(root, LIGHT_THEME) - assert style is not None - assert style2 is not None - assert style.lookup("FileList.Treeview", "fieldbackground") == LIGHT_THEME.surface - assert style2.lookup("FileList.Treeview", "fieldbackground") == LIGHT_THEME.surface - - def test_fallback_without_sv_ttk(self, root: tk.Tk, monkeypatch: pytest.MonkeyPatch) -> None: - """apply_theme survives sv_ttk absence (clam fallback path).""" - monkeypatch.setitem(sys.modules, "sv_ttk", None) # import sv_ttk -> ImportError - style = apply_theme(root, DARK_THEME) - assert style is not None - - def test_apply_theme_with_tk_stub(self, monkeypatch: pytest.MonkeyPatch) -> None: - """apply_theme is callable twice with a minimal Tk stub (no real root).""" - - class _FakeToplevel: # noqa: D401 - minimal stand-in - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - class _FakeStyle: - def __init__(self) -> None: - self._configs: dict[str, dict[str, object]] = {} - - def theme_use(self, name: str) -> None: - self._theme = name - - def configure(self, style: str, **kwargs: object) -> None: - self._configs.setdefault(style, {}).update(kwargs) - - def lookup(self, style: str, opt: str) -> str: - return str(self._configs.get(style, {}).get(opt, "")) - - def map(self, style: str, **kwargs: object) -> None: - self._configs.setdefault(style, {}).update(kwargs) - - class _FakeTk: - def __init__(self) -> None: - self.palette: dict[str, str] = {} - - def update(self) -> None: - pass - - def _tk_set_palette(self: _FakeTk, **kwargs: str) -> None: - self.palette.update(kwargs) - - _FakeTk.tk_setPalette = _tk_set_palette - - import tkinter.ttk as ttk - - monkeypatch.setattr(ttk, "Style", _FakeStyle) - monkeypatch.setattr(tk, "Toplevel", _FakeToplevel) - fake = _FakeTk() - s1 = apply_theme(fake, LIGHT_THEME) - s2 = apply_theme(fake, DARK_THEME) - assert isinstance(s1, _FakeStyle) - assert isinstance(s2, _FakeStyle) - assert fake.palette["background"] == DARK_THEME.surface - - -class TestResolveThemeName: - """Tests for resolve_theme_name helper.""" - - def test_resolve_explicit(self) -> None: - assert resolve_theme_name("light") == "light" - assert resolve_theme_name("dark") == "dark" - - def test_resolve_auto_dark(self, monkeypatch: pytest.MonkeyPatch) -> None: - import darkdetect - - monkeypatch.setattr(darkdetect, "theme", lambda: "Dark") - assert resolve_theme_name("auto") == "dark" - - def test_resolve_auto_light(self, monkeypatch: pytest.MonkeyPatch) -> None: - import darkdetect - - monkeypatch.setattr(darkdetect, "theme", lambda: "Light") - assert resolve_theme_name("auto") == "light" - - def test_resolve_auto_none(self, monkeypatch: pytest.MonkeyPatch) -> None: - import darkdetect - - monkeypatch.setattr(darkdetect, "theme", lambda: None) - assert resolve_theme_name("auto") == "light" - - -# ── hex_luminance binding to Md3Theme colours ────────────────────────────── - - -class TestThemeLuminance: - """Luminance helper must yield values in [0, 1] for the theme palette. - - These tests exercise the ``hex_luminance`` module-level helper from - ``profiles.gui.main_window`` against the canonical ``surface`` and - ``outline`` colours of both built-in themes. The helper is a pure - function so importing it does not instantiate Tk. - """ - - def test_light_surface_luminance_in_range(self) -> None: - - value = hex_luminance(LIGHT_THEME.surface) - assert 0.0 <= value <= 1.0 - - def test_dark_surface_luminance_in_range(self) -> None: - - value = hex_luminance(DARK_THEME.surface) - assert 0.0 <= value <= 1.0 - - def test_light_outline_luminance_in_range(self) -> None: - - value = hex_luminance(LIGHT_THEME.outline) - assert 0.0 <= value <= 1.0 - - def test_dark_outline_luminance_in_range(self) -> None: - - value = hex_luminance(DARK_THEME.outline) - assert 0.0 <= value <= 1.0 - - def test_light_surface_lighter_than_dark_surface(self) -> None: - """The luminance helper agrees with the qualitative expectation: - light theme surface > dark theme surface.""" - - assert hex_luminance(LIGHT_THEME.surface) > hex_luminance(DARK_THEME.surface) - - -# ── WCAG contrast helpers ─────────────────────────────────────────────────── - - -class TestContrast: - """Unit tests for ``hex_luminance``, ``contrast_ratio``, ``assert_contrast``.""" - - def test_luminance_black_is_zero(self) -> None: - assert hex_luminance("#000000") == 0.0 - - def test_luminance_white_is_one(self) -> None: - assert hex_luminance("#FFFFFF") == pytest.approx(1.0, abs=1e-6) - - def test_luminance_missing_hash_tolerated(self) -> None: - assert hex_luminance("FFFFFF") == pytest.approx(1.0, abs=1e-6) - # Garbage returns the safe neutral 0.5 - assert hex_luminance("not-a-color") == 0.5 - assert hex_luminance("#abc") == 0.5 # wrong length - - def testcontrast_ratio_black_on_white_is_21(self) -> None: - assert contrast_ratio("#000000", "#FFFFFF") == pytest.approx(21.0, abs=1e-3) - - def testcontrast_ratio_white_on_white_is_1(self) -> None: - assert contrast_ratio("#FFFFFF", "#FFFFFF") == pytest.approx(1.0, abs=1e-6) - - def testassert_contrast_passes_for_known_good_pair(self) -> None: - # Black on white is 21:1 — well above the AA threshold of 4.5. - assert assert_contrast("#000000", "#FFFFFF") is True - - def testassert_contrast_fails_for_known_bad_pair(self) -> None: - # Light grey on white has a ratio of about 1.3:1. - assert assert_contrast("#CCCCCC", "#FFFFFF") is False - - -# ── apply_theme WCAG audit logging ─────────────────────────────────────────── - - -@needs_tk -class TestApplyThemeContrastAudit: - """apply_theme logs a WARNING for each contrast pair below 4.5:1.""" - - @pytest.fixture - def root(self) -> tk.Tk: - r = tk.Tk() - yield r - r.destroy() - - def test_apply_theme_audit_warns_on_bad_contrast( - self, - root: tk.Tk, - caplog: pytest.LogCaptureFixture, - ) -> None: - # Build a theme where on_surface is intentionally very close to surface. - bad_theme = Md3Theme( - primary="#005fb8", - on_primary="#FFFFFF", - primary_container="#D1E4FF", - on_primary_container="#001D36", - secondary="#625B71", - on_secondary="#FFFFFF", - surface="#FFFFFF", - surface_container="#F5F5F5", - surface_dim="#DDDDDD", - on_surface="#FEFEFE", # almost white on white → fails AA - on_surface_variant="#49454F", - outline="#C4C6D0", - outline_variant="#E0E0E4", - background="#FFFFFF", - on_background="#1c1c1c", - error="#B3261E", - on_error="#FFFFFF", - error_container="#F9DEDC", - status_info="#FFFFFF", - status_info_fg="#000000", - status_warning="#FFFFFF", - status_warning_fg="#000000", - status_error="#FFFFFF", - status_error_fg="#000000", - status_success="#FFFFFF", - status_success_fg="#000000", - link="#000000", - link_hover="#111111", - elevation_0="#FFFFFF", - elevation_1="#F5F5F5", - header_bg="#1F1F1F", - header_fg="#FFFFFF", - status_bg="#FFFFFF", - border="#E0E0E4", - prod="#005fb8", - dev="#757575", - green="#16A34A", - green_hover="#15803D", - hover="#eaeaea", - selected="#2f60d8", - tooltip_bg="#212121", - tooltip_fg="#fafafa", - tooltip_border="#212121", - focus_ring="#005fb8", - ) - - caplog.set_level("WARNING", logger="profiles") - apply_theme(root, bad_theme) - - warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"] - labels = {str(rec.getMessage()).split(" ratio=")[0] for rec in warnings} - # on_surface/surface should definitely trigger; audit must not raise. - assert any("on_surface/surface" in msg for msg in labels) - # Valid pairs stay silent. - assert all("on_primary/primary" not in msg for msg in labels) - - -# ── Refactor (Task 1): additional public API edge cases ──────────────────── - - -class TestHexLuminanceEdgeCases: - """Cover the edge cases that the previous duplicated helper didn't test.""" - - def test_empty_string_returns_midpoint(self) -> None: - assert hex_luminance("") == 0.5 - - def test_non_string_input_returns_midpoint(self) -> None: - assert hex_luminance(None) == 0.5 # type: ignore[arg-type] - - def test_uppercase_hex(self) -> None: - assert hex_luminance("#ABCDEF") == pytest.approx(hex_luminance("#abcdef"), abs=1e-9) - - -class TestContrastRatioSymmetry: - """Contrast ratio is symmetric: order of arguments does not matter.""" - - def test_symmetric(self) -> None: - a = contrast_ratio("#1565C0", "#fafafa") - b = contrast_ratio("#fafafa", "#1565C0") - assert a == pytest.approx(b, abs=1e-9) diff --git a/tests/gui/test_ui.py b/tests/gui/test_ui.py deleted file mode 100644 index 3ad8795..0000000 --- a/tests/gui/test_ui.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Tests for profiles.gui.ui — MainWindowUI widget construction, build, progress bar, show/hide.""" - -from __future__ import annotations - -import tkinter as tk -from unittest.mock import MagicMock - -import pytest - -from profiles.gui.ui import MainWindowUI - -# ── MainWindowUI construction ─────────────────────────────────────────────── - - -def _make_window_mock() -> MagicMock: - """Build a MagicMock that quacks enough like MainWindow for the UI builder.""" - window = MagicMock() - window._theme_name = "light" - window._config = MagicMock() - window._config.title = "" - window._config.release = "1.0.0" - window._config.column_names = ["File"] - window._config.column_headers = ["File"] - window._config.column_widths = [100] - window._config.column_stretches = [True] - window._config.close_after_execute = False - return window - - -class TestMainWindowUIInit: - """``__init__`` stores the window reference.""" - - def test_init_stores_window(self) -> None: - window = _make_window_mock() - ui = MainWindowUI(window) - assert ui.window is window - - -class TestMainWindowUIBuild: - """``build()`` invokes every sub-builder exactly once.""" - - def _instrumented_ui(self) -> tuple[MainWindowUI, list[str]]: - window = _make_window_mock() - ui = MainWindowUI(window) - - log: list[str] = [] - - def _record(name: str): - def _fn() -> None: - log.append(name) - - return _fn - - ui._build_header = _record("header") - ui._build_search_bar = _record("search_bar") - ui._build_file_list = _record("file_list") - ui._build_action_bar = _record("action_bar") - ui._build_status_bar = _record("status_bar") - return ui, log - - def test_build_invokes_all_sub_builders_once(self) -> None: - ui, log = self._instrumented_ui() - ui.build() - assert log.count("header") == 1 - assert log.count("search_bar") == 1 - assert log.count("file_list") == 1 - assert log.count("action_bar") == 1 - assert log.count("status_bar") == 1 - - def test_build_invokes_each_sub_builder_exactly_once(self) -> None: - ui, log = self._instrumented_ui() - ui.build() - assert sorted(log) == [ - "action_bar", - "file_list", - "header", - "search_bar", - "status_bar", - ] - assert len(log) == 5 - - def test_build_invokes_sub_builders_in_expected_order(self) -> None: - ui, log = self._instrumented_ui() - ui.build() - assert log == [ - "header", - "search_bar", - "file_list", - "action_bar", - "status_bar", - ] - - -# ── _build_action_bar — indeterminate progressbar ──────────────────────── - - -class TestActionBarProgressbar: - """``_build_action_bar`` creates an indeterminate progressbar and hides it.""" - - def _make_window_with_action_frame(self) -> MagicMock: - """Window mock with the minimum surface ``_build_action_bar`` needs.""" - window = _make_window_mock() - window._action_frame = MagicMock() - window._root = MagicMock() - return window - - def test_build_action_bar_creates_progressbar(self, monkeypatch: pytest.MonkeyPatch) -> None: - """An indeterminate progressbar is created, packed, then immediately hidden.""" - progressbar_mock = MagicMock(name="Progressbar") - monkeypatch.setattr("profiles.gui.ui.ttk.Progressbar", progressbar_mock) - - window = self._make_window_with_action_frame() - # Provide the ttk.Frame / ttk.Button / ttk.Checkbutton stubs so the - # surrounding widgets are no-ops on the mock. - monkeypatch.setattr("profiles.gui.ui.ttk.Frame", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ttk.Button", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ttk.Checkbutton", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ToolTip", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.tk.BooleanVar", MagicMock()) - - ui = MainWindowUI(window) - ui._build_action_bar() - - progressbar_mock.assert_called_once() - kwargs = progressbar_mock.call_args.kwargs - assert kwargs.get("mode") == "indeterminate" - - progress_instance = progressbar_mock.return_value - progress_instance.pack.assert_called() - progress_instance.pack_forget.assert_called_once() - - def test_build_action_bar_stores_progressbar_reference( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The progressbar instance is stored as ``window._progress_bar``.""" - progressbar_mock = MagicMock(name="Progressbar") - monkeypatch.setattr("profiles.gui.ui.ttk.Progressbar", progressbar_mock) - monkeypatch.setattr("profiles.gui.ui.ttk.Frame", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ttk.Button", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ttk.Checkbutton", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.ToolTip", MagicMock()) - monkeypatch.setattr("profiles.gui.ui.tk.BooleanVar", MagicMock()) - - window = self._make_window_with_action_frame() - ui = MainWindowUI(window) - ui._build_action_bar() - - assert window._progress_bar is progressbar_mock.return_value - - -# ── Scan-progress lifecycle ────────────────────────────────────────────── - - -class TestShowHideProgress: - """``_show_progress`` and ``_hide_progress`` are safe without a progressbar.""" - - def _make_main_window_attrs(self) -> MagicMock: - """Build a MagicMock that quacks like the parts of ``MainWindow`` the helpers use.""" - window = MagicMock() - window._progress_bar = MagicMock() - return window - - def test_show_progress_packs_and_starts(self) -> None: - """When a progressbar exists, ``_show_progress`` packs and starts it.""" - from profiles.gui.main_window import MainWindow - - window = self._make_main_window_attrs() - MainWindow._show_progress(window) - - window._progress_bar.pack.assert_called_once_with(side=tk.LEFT, padx=(0, 12)) - window._progress_bar.start.assert_called_once_with(10) - - def test_hide_progress_stops_and_forgets(self) -> None: - """When a progressbar exists, ``_hide_progress`` stops and forgets it.""" - from profiles.gui.main_window import MainWindow - - window = self._make_main_window_attrs() - MainWindow._hide_progress(window) - - window._progress_bar.stop.assert_called_once() - window._progress_bar.pack_forget.assert_called_once() - - def test_show_progress_is_noop_without_progressbar(self) -> None: - """Legacy callers without ``_progress_bar`` do not crash.""" - from profiles.gui.main_window import MainWindow - - window = MagicMock(spec=[]) # no _progress_bar attribute - MainWindow._show_progress(window) - - def test_hide_progress_is_noop_without_progressbar(self) -> None: - """Legacy callers without ``_progress_bar`` do not crash.""" - from profiles.gui.main_window import MainWindow - - window = MagicMock(spec=[]) - MainWindow._hide_progress(window) diff --git a/tests/packaging/test_packaging.py b/tests/packaging/test_packaging.py index 145e93a..a6eecda 100644 --- a/tests/packaging/test_packaging.py +++ b/tests/packaging/test_packaging.py @@ -22,13 +22,14 @@ def test_tk_deps_absent_from_runtime_dependencies() -> None: assert "darkdetect" not in deps -def test_tk_deps_present_in_both_dev_lists() -> None: +def test_tk_deps_absent_from_dev_lists() -> None: + """The Tkinter GUI is removed (issue #50): sv-ttk/darkdetect are gone from dev lists too.""" data = _load() extras_dev = " ".join(data["project"]["optional-dependencies"]["dev"]).lower() groups_dev = " ".join(data["dependency-groups"]["dev"]).lower() for where in (extras_dev, groups_dev): - assert "sv-ttk" in where - assert "darkdetect" in where + assert "sv-ttk" not in where + assert "darkdetect" not in where def test_dev_dependency_lists_stay_in_sync() -> None: diff --git a/tests/test_app.py b/tests/test_app.py index ab7e1bf..e77e0dc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,9 +1,6 @@ """Tests for profiles.app — ProFileApp, main(), init_default_config. Also tests profiles.__init__ package metadata and __main__ entry point. - -Tkinter-backend lifecycle tests live under ``tests/gui`` (deprecated -frontend; skipped there in preparation for removal). """ from __future__ import annotations @@ -16,6 +13,7 @@ import profiles from profiles.app import ProFileApp, init_default_config, main +from profiles.core.config.models import AppConfig from profiles.core.telemetry.diagnostics import default_log_path from profiles.desktop.handoff import HandoffResult from profiles.desktop.lifecycle import ServerStartError @@ -55,7 +53,6 @@ def test_default_construction(self) -> None: assert app._log_path == default_log_path() assert app._headless is False assert app._config is None - assert app._window is None assert app._logger is None def test_custom_config_path(self, tmp_path: Path) -> None: @@ -249,11 +246,7 @@ def test_without_file_empty_configs(self, mocker) -> None: # type: ignore[no-un class TestRun: - """ProFileApp.run — lifecycle orchestration (web/headless branches). - - The deprecated tk-backend branch is covered by - ``tests/gui/test_app_tk_lifecycle.py``. - """ + """ProFileApp.run — lifecycle orchestration (web/headless branches).""" def test_headless_branch_calls_run_headless(self, mocker) -> None: # type: ignore[no-untyped-def] mocker.patch("profiles.app.configure_logger") @@ -267,6 +260,29 @@ def test_headless_branch_calls_run_headless(self, mocker) -> None: # type: igno app.run() mock_run_headless.assert_called_once() + def test_run_routes_to_web_gui(self, mocker) -> None: # type: ignore[no-untyped-def] + """The web GUI is the only interactive backend — run() routes to it.""" + mocker.patch("profiles.app.configure_logger") + mock_config = mocker.MagicMock() + mock_config.verbose = "INFO" + mocker.patch("profiles.app.load_config", return_value=mock_config) + mock_run_web_gui = mocker.patch.object(ProFileApp, "_run_web_gui") + + app = ProFileApp() + app.run() + + mock_run_web_gui.assert_called_once() + + def test_launch_web_backend_calls_desktop(self, mocker) -> None: # type: ignore[no-untyped-def] + """_run_web_gui opens the PyWebView desktop shell on the default port/host.""" + mock_launch = mocker.patch("profiles.desktop.launcher.launch_desktop_window") + + app = ProFileApp(headless=False) + app._config = AppConfig(title="Test Bench") + app._run_web_gui() + + mock_launch.assert_called_once_with(app._config, port=8088, host="127.0.0.1") + def test_web_gui_startup_failure_exits_cleanly(self, mocker, capsys) -> None: # type: ignore[no-untyped-def] """An unresolvable collision exits 1 with an actionable message, no traceback. @@ -556,7 +572,6 @@ def test_headless_flag(self, mocker) -> None: # type: ignore[no-untyped-def] headless=True, server_only=False, no_config_propose=False, - gui_backend="web", host="127.0.0.1", ) mock_app.return_value.run.assert_called_once_with(file=None) @@ -585,7 +600,6 @@ def test_default_no_args(self, mocker) -> None: # type: ignore[no-untyped-def] headless=False, server_only=False, no_config_propose=False, - gui_backend="web", host="127.0.0.1", ) diff --git a/tests/test_app_backend_selection.py b/tests/test_app_backend_selection.py deleted file mode 100644 index 79eb074..0000000 --- a/tests/test_app_backend_selection.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Test backend selection gateway in ProFiles app. - -The deprecated ``--tkinter`` selection test lives under -``tests/gui/test_app_tk_lifecycle.py`` (skipped with the rest of the -Tkinter surface). -""" - -from __future__ import annotations - -import os -import sys -from unittest.mock import patch - -from profiles.app import ProFileApp, main -from profiles.core.config.models import AppConfig - - -def _resolved_backend(argv: list[str]) -> str: - """Run ``main()`` with *argv* and return the backend it constructs.""" - with patch.object(sys, "argv", argv), patch("profiles.app.ProFileApp") as mock_app: - main() - call = mock_app.call_args - assert call is not None, "main() must construct ProFileApp" - return call.kwargs["gui_backend"] - - -def test_backend_selection_default_web() -> None: - with patch.dict(os.environ, {}, clear=True): - assert _resolved_backend(["profiles"]) == "web" - - -def test_launch_web_backend_calls_desktop() -> None: - app = ProFileApp(headless=False) - app._config = AppConfig(title="Test Bench") - with patch("profiles.desktop.launcher.launch_desktop_window") as mock_launch: - app._run_web_gui() - mock_launch.assert_called_once_with(app._config, port=8088, host="127.0.0.1") diff --git a/tests/test_app_no_tkinter.py b/tests/test_app_no_tkinter.py deleted file mode 100644 index 75c2029..0000000 --- a/tests/test_app_no_tkinter.py +++ /dev/null @@ -1,115 +0,0 @@ -"""The web backend must run with tkinter unavailable (Briefcase embeddable Python). - -Related spec: docs/superpowers/specs/2026-10-23-native-installer-design.md §5.2. -""" - -from __future__ import annotations - -import importlib -import logging -import sys - -import pytest - -import profiles.app as app_module - - -class _StubConfig: - """Minimal config surface used by ProFileApp.run().""" - - scan_metrics = False - verbose = "INFO" - - -@pytest.fixture(autouse=True) -def _restore_app_module(): - """Reload profiles.app normally after each test so other suites are unaffected.""" - yield - importlib.reload(app_module) - - -def _reload_with_tkinter_blocked(monkeypatch: pytest.MonkeyPatch) -> object: - """Put a None sentinel for tkinter, then reload profiles.app. - - ``sys.modules["tkinter"] = None`` makes ``import tkinter`` raise ImportError, - simulating the Briefcase Windows embeddable distribution. - """ - monkeypatch.setitem(sys.modules, "tkinter", None) - monkeypatch.setitem(sys.modules, "profiles.gui.main_window", None) - return importlib.reload(app_module) - - -def _stub_app_collaborators(monkeypatch: pytest.MonkeyPatch, app: object) -> None: - """Replace logging and config loading so run() reaches the backend branch.""" - monkeypatch.setattr( - type(app), "_setup_logging", lambda self, level=logging.INFO: logging.getLogger("test") - ) - monkeypatch.setattr(type(app), "_load_configuration", lambda self, logger: _StubConfig()) - - -def test_web_backend_runs_without_tkinter(monkeypatch: pytest.MonkeyPatch) -> None: - """Import + web run path succeed while tkinter is blocked.""" - mod = _reload_with_tkinter_blocked(monkeypatch) - called: list[str] = [] - monkeypatch.setattr(mod.ProFileApp, "_run_web_gui", lambda self, **kw: called.append("web")) - - app = mod.ProFileApp(gui_backend="web") - _stub_app_collaborators(monkeypatch, app) - app.run() # must not raise ImportError - - assert called == ["web"] - - -def test_headless_runs_without_tkinter(monkeypatch: pytest.MonkeyPatch) -> None: - """Headless mode needs no tkinter either.""" - mod = _reload_with_tkinter_blocked(monkeypatch) - app = mod.ProFileApp(gui_backend="web", headless=True) - _stub_app_collaborators(monkeypatch, app) - monkeypatch.setattr(mod.ProFileApp, "run_headless", lambda self, file_path=None: 0) - - with pytest.raises(SystemExit) as excinfo: - app.run() - - assert excinfo.value.code == 0 - - -def test_tkinter_banner_printed_when_requested( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """With tkinter available, --tkinter routes through main() and prints the banner before GUI launch.""" - calls: list[str] = [] - monkeypatch.setattr(sys, "argv", ["profiles", "--tkinter"]) - monkeypatch.setattr(app_module.ProFileApp, "_run_gui", lambda self: calls.append("tk")) - - app = app_module.ProFileApp(gui_backend="tk") - _stub_app_collaborators(monkeypatch, app) - app_module.main() # banner comes from main(), before ProFileApp is used - - assert calls == ["tk"] - out = capsys.readouterr().out - assert "deprecated" in out.lower() - assert "will be removed" in out.lower() - - -def test_tkinter_clean_error_when_unavailable( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Without tkinter, --tkinter prints banner + actionable error and exits 1 (no traceback). - - main() is exercised so stdout contains the deprecation banner exactly as a - real ``profiles --tkinter`` invocation would. - """ - mod = _reload_with_tkinter_blocked(monkeypatch) - monkeypatch.setattr(sys, "argv", ["profiles", "--tkinter"]) - - app = mod.ProFileApp(gui_backend="tk") - _stub_app_collaborators(monkeypatch, app) - - with pytest.raises(SystemExit) as excinfo: - mod.main() - - assert excinfo.value.code == 1 - captured = capsys.readouterr() - assert "deprecated" in captured.out.lower() - assert "full Python installation" in captured.err - assert "Traceback" not in captured.err diff --git a/tests/test_app_tk_lifecycle.py b/tests/test_app_tk_lifecycle.py deleted file mode 100644 index 60a83a9..0000000 --- a/tests/test_app_tk_lifecycle.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tkinter-backend lifecycle tests for ``profiles.app`` — deprecated. - -These tests pin the ``gui_backend="tk"`` surface of the app (window -creation, Tcl/Tk error handling, ``--tkinter`` CLI routing). They sit at -the mirrored path of ``src/profiles/app.py`` and carry their own module -skip; delete them together with the Tkinter frontend in phase 2. -""" - -from __future__ import annotations - -import sys -from unittest.mock import patch - -import pytest - -from profiles.app import ProFileApp, main - -pytestmark = pytest.mark.skip( - reason=( - "Tkinter GUI is deprecated — web frontend is the default. " - "Skipped in preparation for removal (phase 2 deletes src/profiles/gui)." - ) -) - -# ── ProFileApp.run (tk GUI mode) ───────────────────────────────────────── - - -class TestRunTkMode: - """ProFileApp.run — tk-backend branch (deprecated).""" - - def test_gui_mode_creates_window(self, mocker) -> None: # type: ignore[no-untyped-def] - mocker.patch("profiles.app.configure_logger") - mock_config = mocker.MagicMock() - mock_config.verbose = "INFO" - mocker.patch("profiles.app.load_config", return_value=mock_config) - mock_main_window = mocker.patch("profiles.gui.main_window.MainWindow") - mock_window_instance = mocker.MagicMock() - mock_main_window.return_value = mock_window_instance - - app = ProFileApp(gui_backend="tk") - app.run() - mock_main_window.assert_called_once() - mock_window_instance.run.assert_called_once() - - def test_tk_runtime_error_handling(self, mocker) -> None: # type: ignore[no-untyped-def] - mocker.patch("profiles.app.configure_logger") - mock_config = mocker.MagicMock() - mock_config.verbose = "INFO" - mocker.patch("profiles.app.load_config", return_value=mock_config) - mocker.patch( - "profiles.gui.main_window.MainWindow", - side_effect=RuntimeError("no screen available"), - ) - - app = ProFileApp(gui_backend="tk") - with pytest.raises(SystemExit): - app.run() - - def test_tk_tcl_error_handling(self, mocker) -> None: # type: ignore[no-untyped-def] - mocker.patch("profiles.app.configure_logger") - mock_config = mocker.MagicMock() - mock_config.verbose = "INFO" - mocker.patch("profiles.app.load_config", return_value=mock_config) - import tkinter - - mocker.patch( - "profiles.gui.main_window.MainWindow", - side_effect=tkinter.TclError("display error"), - ) - - app = ProFileApp(gui_backend="tk") - with pytest.raises(SystemExit): - app.run() - - def test_gui_non_display_error(self, mocker) -> None: # type: ignore[no-untyped-def] - """RuntimeError without 'screen'/'display' keyword uses generic message.""" - mocker.patch("profiles.app.configure_logger") - mock_config = mocker.MagicMock() - mock_config.verbose = "INFO" - mocker.patch("profiles.app.load_config", return_value=mock_config) - mocker.patch( - "profiles.gui.main_window.MainWindow", - side_effect=RuntimeError("Tcl error"), - ) - - app = ProFileApp(gui_backend="tk") - with pytest.raises(SystemExit): - app.run() - - -# ── CLI backend selection (--tkinter, deprecated) ──────────────────────── - - -def test_backend_selection_cli_tk() -> None: - """``--tkinter`` routes to the deprecated tk backend.""" - with ( - patch.object(sys, "argv", ["profiles", "--tkinter"]), - patch("profiles.app.ProFileApp") as mock_app, - ): - main() - call = mock_app.call_args - assert call is not None, "main() must construct ProFileApp" - assert call.kwargs["gui_backend"] == "tk" diff --git a/uv.lock b/uv.lock index 0bd622f..70390a1 100644 --- a/uv.lock +++ b/uv.lock @@ -537,15 +537,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[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 = "dependency-groups" version = "1.3.2" @@ -1290,7 +1281,6 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "briefcase" }, - { name = "darkdetect" }, { name = "deptry" }, { name = "httpx" }, { name = "pre-commit" }, @@ -1300,13 +1290,11 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "ruff" }, - { name = "sv-ttk" }, ] [package.dev-dependencies] dev = [ { name = "briefcase" }, - { name = "darkdetect" }, { name = "deptry" }, { name = "httpx" }, { name = "pre-commit" }, @@ -1316,13 +1304,11 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "ruff" }, - { name = "sv-ttk" }, ] [package.metadata] requires-dist = [ { name = "briefcase", marker = "extra == 'dev'", specifier = ">=0.4.4" }, - { name = "darkdetect", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "deptry", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, @@ -1338,7 +1324,6 @@ requires-dist = [ { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, - { name = "sv-ttk", marker = "extra == 'dev'", specifier = ">=2.5.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "zxing-cpp", specifier = ">=2.2.0" }, ] @@ -1347,7 +1332,6 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ { name = "briefcase", specifier = ">=0.4.4" }, - { name = "darkdetect", specifier = ">=0.8.0" }, { name = "deptry", specifier = ">=0.22.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pre-commit", specifier = ">=3.0.0" }, @@ -1357,7 +1341,6 @@ dev = [ { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "pytest-mock", specifier = ">=3.0.0" }, { name = "ruff", specifier = ">=0.1.0" }, - { name = "sv-ttk", specifier = ">=2.5.0" }, ] [[package]] @@ -2010,15 +1993,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" }, ] -[[package]] -name = "sv-ttk" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/ca/9625914e7a7e58ce9f0d8d6a5eb29975bab077ce58a36cf86f863d188cd3/sv_ttk-2.6.1.tar.gz", hash = "sha256:47589d5e20398cf404e8362f24f3ed48f3830cd0ace056d83354fa25d8e4fe48", size = 49640, upload-time = "2025-06-08T16:14:21.333Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/36/32629d8bab9bccd54a36716a960ba1148fca4c1fd453370dba0b42d712ea/sv_ttk-2.6.1-py3-none-any.whl", hash = "sha256:8ca5944f95431a911986b00c54e7f44608f3f811382cd985fd14bb3e65f95d97", size = 50076, upload-time = "2025-06-08T16:14:20.402Z" }, -] - [[package]] name = "tenacity" version = "9.2.1" From 78e1f0f80a2b44c6c81f85e112639c6be3d622f1 Mon Sep 17 00:00:00 2001 From: falbany Date: Wed, 4 Nov 2026 21:53:45 +0100 Subject: [PATCH 2/2] docs: sync architecture trees and user docs after the Tkinter removal - AGENTS.md + src/profiles/core/AGENTS.md + tests/AGENTS.md: gui/ layer gone from the trees, presentation layer is web/server/desktop only - tests/README.md: drop the gui rows/examples and coverage omit note - README.en/fr: remove the Legacy Tkinter Mode sections, tree entries and TOC anchors - docs/README.md, installation-guide.en/fr: drop --tkinter/--web from the command tables, --help blocks and dependency lists; Linux troubleshooting now points at the PyWebView/WebKit deps only - hooks-guide.en/fr: drop the legacy-Tkinter render behavior bullets - configuration-profile.en: theme auto no longer claims darkdetect system detection (the web UI resolves auto to dark) --- AGENTS.md | 26 +++++----------- README.fr.md | 12 +------- README.md | 12 +------- docs/README.md | 8 ++--- docs/configuration-profile.en.md | 2 +- docs/hooks-guide.en.md | 7 ----- docs/hooks-guide.fr.md | 7 ----- docs/installation-guide.en.md | 52 +++++--------------------------- docs/installation-guide.fr.md | 42 ++++++-------------------- src/profiles/core/AGENTS.md | 8 ++--- tests/AGENTS.md | 28 ++--------------- tests/README.md | 13 ++++---- 12 files changed, 43 insertions(+), 174 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d0f927..14fbb60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Ce document définit les standards d'architecture, les principes de conception e ## 📁 Structure de l'Application > **⚠️ Keep in sync with the code** : cet arbre reflète `src/profiles/` au -> 31/10/2026. Toute création, suppression ou déplacement de module doit mettre +> 04/11/2026. Toute création, suppression ou déplacement de module doit mettre > à jour cette section dans le même commit — ainsi que `tests/AGENTS.md`, > qui reflète l'arbre de tests 1:1. Avant d'utiliser un chemin d'import des > extraits ci-dessous, vérifiez-le contre `src/profiles/core/__init__.py` @@ -96,12 +96,6 @@ src/profiles/ │ ├── lifecycle.py # Cycle de vie de l'application │ └── instance.py, handoff.py # Instances multiples, handoff de port │ -├── gui/ # ⚠️ DÉPRÉCIÉ (Tkinter) — suppression prévue (phase 2) -│ │ # Ne pas y ajouter de fonctionnalités ; le -│ │ # front-end par défaut est web/. Tests skippés. -│ ├── main_window.py # Orchestration des widgets -│ └── ui.py, styles.py, theme.py, i18n.py, controllers/, presentation/ -│ ├── utils/ # Fonctions utilitaires pures & stateless │ ├── file_utils.py # scan_directory, launch_file, open_with_default_app │ ├── network.py # hostname, IP, username @@ -125,7 +119,7 @@ src/profiles/ **Responsabilité** : Logique métier pure, indépendante de tout framework **Règles** : -- ✅ Zéro dépendance vers les couches UI (React/web, desktop, Tkinter, CLI) +- ✅ Zéro dépendance vers les couches UI (React/web, desktop, CLI) - ✅ Fonctions pures où c'est possible - ✅ Retourner des structures de données typées (jamais `None` pour les collections) - ✅ Documenter toutes les fonctions publiques avec des docstrings @@ -159,10 +153,7 @@ def merge(base, override): ### Couche Présentation **Couche par défaut** : `profiles.web` (React PWA) + `profiles.server` -(FastAPI) + `profiles.desktop` (shell PyWebView). Le Tkinter -`profiles.gui` est **déprécié** (suppression prévue, phase 2) — ne pas y -ajouter de fonctionnalités ; les extraits ci-dessous ne valent que pour la -maintenance de la couche legacy. +(FastAPI) + `profiles.desktop` (shell PyWebView). **Responsabilité** : Gestion exclusive des objets d'interface et de l'expérience utilisateur @@ -170,7 +161,6 @@ maintenance de la couche legacy. - ✅ Tous les objets d'UI sont créés et gérés dans la couche présentation - ✅ Déléguer la logique métier à `profiles.core.*` - ✅ Gérer uniquement le code de liaison spécifique aux widgets/composants -- ✅ Utiliser les widgets `ttk` pour un theming cohérent (legacy Tkinter) - ✅ Implémenter une gestion d'erreur robuste pour les actions utilisateur - ✅ Ne jamais contenir de logique métier directe @@ -212,7 +202,7 @@ def on_scan_button_click(self) -> None: - ✅ Fonctions helpers stateless uniquement - ✅ Aucune connaissance du domaine applicatif - ✅ Aucun effet de bord (fonctions pures) -- ✅ Agnostiques au framework (pas de Tkinter, pas de CLI libs) +- ✅ Agnostiques au framework (aucune dépendance UI ou CLI) - ✅ Documenter les formats d'entrée/sortie attendus **Exemple** : @@ -367,7 +357,7 @@ class LoggerFactory: ... -# gui/main_window.py - UI only +# couche présentation - UI only class MainWindow: """Main application window.""" @@ -686,8 +676,8 @@ def test_file_processor(): ### Conformité Architecture -- [ ] **Core layer** n'a pas d'imports UI (Tkinter, FastAPI, React) -- [ ] **Couche présentation** (web/server/desktop, ou gui legacy) délègue la logique métier à `profiles.core.*` +- [ ] **Core layer** n'a pas d'imports UI (FastAPI, React) +- [ ] **Couche présentation** (web/server/desktop) délègue la logique métier à `profiles.core.*` - [ ] **Utils layer** est stateless et agnostique au framework - [ ] **Imports** suivent les boundaries des couches - [ ] **Aucune dépendance circulaire** entre modules @@ -818,7 +808,7 @@ jobs: 5. **Présenter les résultats** dans le format du front-end (table terminal, panel curses, etc.) -6. **Zéro import d'UI** nécessaire (pas de Tkinter, pas de React) +6. **Zéro import d'UI** nécessaire (aucun framework d'interface importé) --- diff --git a/README.fr.md b/README.fr.md index e39cf47..b7b9564 100644 --- a/README.fr.md +++ b/README.fr.md @@ -37,7 +37,6 @@ - [💻 Utilisation](#-utilisation) - [Mode Web (par défaut)](#mode-web-par-défaut) - [Mode Navigateur / PWA (serveurs partagés)](#mode-navigateur--pwa-serveurs-partagés) - - [Mode Tkinter historique (déprécié)](#mode-tkinter-historique-déprécié) - [Mode CLI / Headless (en cours de développement)](#mode-cli--headless-en-cours-de-développement) - [🛠 Configuration](#-configuration) - [🏗 Architecture](#-architecture) @@ -202,14 +201,6 @@ ProFiles --server-only Lance le serveur FastAPI **sans** la fenêtre de bureau. Pointez n'importe quel navigateur du réseau vers `http://:8088` (port par défaut). Voir le [Guide Serveur Réseau](./docs/network-server-guide.fr.md) pour la configuration firewall, hostname et multi-clients. -### Mode Tkinter historique (déprécié) - -```bash -ProFiles --tkinter -``` - -L'interface de bureau Tkinter d'origine est conservée pour compatibilité uniquement et est **dépréciée** — les nouvelles fonctionnalités arrivent exclusivement sur le frontend web. - ### Mode CLI / Headless (en cours de développement) ```bash @@ -334,9 +325,8 @@ ProFiles/ │ ├── web/ # Frontend PWA React + TypeScript + Vite (src/, dist/, build.py) │ ├── desktop/ # Shell de bureau PyWebView & cycle de vie du serveur en fond │ ├── launch/ # Résolution au lancement : handoff *.profiles & sélection de config -│ ├── gui/ # Couche de présentation Tkinter historique (dépréciée) │ ├── utils/ # Helpers stateless (file ops, network, search parser, shortcut) -│ ├── app.py # Cycle de vie applicatif (web desktop, server-only, Tk, headless) +│ ├── app.py # Cycle de vie applicatif (web desktop, server-only, headless) │ └── config.py # Ré-exports pratiques pour l'API de configuration ├── docs/ # Documentation utilisateur (EN/FR) └── tests/ # Suite Pytest diff --git a/README.md b/README.md index 97c4859..a946da8 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ - [💻 Usage](#-usage) - [Web Mode (default)](#web-mode-default) - [Browser / PWA Mode (shared servers)](#browser--pwa-mode-shared-servers) - - [Legacy Tkinter Mode (deprecated)](#legacy-tkinter-mode-deprecated) - [CLI / Headless Mode (work in progress)](#cli--headless-mode-work-in-progress) - [🛠 Configuration](#-configuration) - [🏗 Architecture](#-architecture) @@ -201,14 +200,6 @@ ProFiles --server-only Runs the FastAPI server **without** the desktop window. Point any browser on the network at `http://:8088` (default port). See the [Network Server Guide](./docs/network-server-guide.en.md) for firewall, hostname and multi-client setup. -### Legacy Tkinter Mode (deprecated) - -```bash -ProFiles --tkinter -``` - -The original Tkinter desktop interface is kept for compatibility only and is **deprecated** — new features land exclusively on the web frontend. - ### CLI / Headless Mode (work in progress) ```bash @@ -333,9 +324,8 @@ ProFiles/ │ ├── web/ # React + TypeScript + Vite PWA frontend (src/, dist/, build.py) │ ├── desktop/ # PyWebView desktop shell & background server lifecycle │ ├── launch/ # Launch-time resolution: *.profiles handoff & config selection -│ ├── gui/ # Legacy Tkinter presentation layer (deprecated) │ ├── utils/ # Stateless helpers (file ops, network, search parser, shortcut) -│ ├── app.py # Application lifecycle (web desktop, server-only, Tk, headless) +│ ├── app.py # Application lifecycle (web desktop, server-only, headless) │ └── config.py # Convenience re-exports for config API ├── docs/ # User documentation (EN/FR) └── tests/ # Pytest suite diff --git a/docs/README.md b/docs/README.md index 84bc94c..f211064 100644 --- a/docs/README.md +++ b/docs/README.md @@ -137,9 +137,7 @@ python -m profiles --init | Command | Description | | -------------------------------------- | ---------------------------------------------- | | `python -m profiles` | Launch the web UI in a PyWebView desktop shell (default) | -| `python -m profiles --web` | Same as above (explicit) | | `python -m profiles --server-only` | Run the FastAPI server only (browser/PWA use) | -| `python -m profiles --tkinter` | Legacy Tkinter interface (deprecated) | | `python -m profiles --headless` | Run in CLI mode | | `python -m profiles --headless FILE` | Launch specific file (no GUI) | | `python -m profiles --init` | Generate configuration file | @@ -158,9 +156,8 @@ ProFiles/ │ ├── web/ # React + TypeScript + Vite PWA frontend (src/, dist/, build.py) │ ├── desktop/ # PyWebView desktop shell & background server lifecycle │ ├── launch/ # Launch-time resolution: *.profiles handoff & config selection -│ ├── gui/ # Legacy Tkinter presentation layer (deprecated) │ ├── utils/ # Stateless utility functions -│ ├── app.py # Application lifecycle (web desktop, server-only, Tk, headless) +│ ├── app.py # Application lifecycle (web desktop, server-only, headless) │ └── config.py # Convenience re-exports for config API ├── tests/ # Unit tests ├── docs/ # This documentation @@ -200,9 +197,8 @@ For additional support: - **Python**: 3.11+ - **Interface**: Web UI (React + TypeScript + Vite PWA) served by FastAPI, desktop shell via PyWebView -- **Legacy GUI (deprecated)**: Tkinter + [Sun Valley ttk theme](https://github.com/rbdende/Sun-Valley-ttk-theme) (`sv-ttk`) + `darkdetect` — use `--tkinter`, kept for compatibility only - **License**: See `LICENCE` file --- -*Last updated: 2026-10-23* +*Last updated: 2026-11-04* diff --git a/docs/configuration-profile.en.md b/docs/configuration-profile.en.md index d89dd30..6975f9e 100644 --- a/docs/configuration-profile.en.md +++ b/docs/configuration-profile.en.md @@ -62,7 +62,7 @@ This section defines default parameters applicable to all machines. | `title` | string | `""` | Custom title appended to main window | | `gui_auto_launch` | bool | `true` | Show GUI on `python -m profiles` | | `close_after_execute` | bool | `false` | Close window after successful launch | -| `theme` | enum | `"auto"` | UI theme: `"light"`, `"dark"`, or `"auto"` (Material Design 3; `"auto"` detects system theme via `darkdetect`) | +| `theme` | enum | `"auto"` | UI theme: `"light"`, `"dark"`, or `"auto"` (Material Design 3; the web UI resolves `"auto"` and unknown values to dark) | | `language` | enum | `"en"` | GUI language: `"en"` (English) or `"fr"` (French); toggled from the status-bar language button (cycles en → fr → en) | | `search_dir` | string | `""` | Default search directory for Directory field | | `recursive_search` | bool | `false` | Initial state of Recursive checkbox | diff --git a/docs/hooks-guide.en.md b/docs/hooks-guide.en.md index 46fe8b5..de84357 100644 --- a/docs/hooks-guide.en.md +++ b/docs/hooks-guide.en.md @@ -108,7 +108,6 @@ Displays a message to the user with Markdown support. - **Web mode (default)**: The notification appears as a workflow modal in the web frontend (the dialog opens in the desktop shell / browser; blocking steps wait for user confirmation in the UI) - **Headless mode**: Prints formatted message to standard output -- **Legacy Tkinter backend**: Displays a Tkinter window with formatted text - **Always succeeds**: Cannot fail, so `on_failure` is ignored - **Markdown support**: GitHub-flavored Markdown (see [Rich Notifications](#rich-notifications-markdown)) @@ -422,10 +421,6 @@ and multi-line YAML block scalars behave the same. quotes) stay as literal text lines - See `render_text()` in `src/profiles/core/environment/render.py` -**Legacy Tkinter backend**: renders the historical subset only (`#`/`##`/`###` -headings, bold, italic, inline code, `\\n`); richer constructs degrade to -plain text. - --- ## Error Handling and Failmode @@ -1008,13 +1003,11 @@ outcome = run_workflow( - In web mode: The notification opens as a workflow modal in the frontend (check the desktop window / browser tab) - In headless mode: Message printed to standard output -- Legacy Tkinter backend missing Tkinter: automatic fallback to headless **Solutions**: 1. In web mode, look at the workflow modal in the frontend (it may be awaiting your confirmation) 2. In headless mode, check console for messages -3. With `--tkinter`, verify Tkinter is installed (`python -m tkinter`) ### Problem: Frequent Timeout diff --git a/docs/hooks-guide.fr.md b/docs/hooks-guide.fr.md index 9d9cad1..f3f2aad 100644 --- a/docs/hooks-guide.fr.md +++ b/docs/hooks-guide.fr.md @@ -107,7 +107,6 @@ Affiche un message à l'utilisateur avec support du Markdown. - **Mode web (par défaut)** : La notification s'affiche dans une modale de workflow du frontend web (la fenêtre de bureau / le navigateur attend la confirmation utilisateur pour les étapes bloquantes) - **Mode headless** : Imprime le message formaté sur la sortie standard -- **Backend Tkinter historique** : Affiche une fenêtre Tkinter avec le texte formaté - **Toujours réussi** : Ne peut pas échouer, donc `on_failure` est ignoré - **Support Markdown** : Markdown GitHub-flavored (voir [Notifications Riches](#notifications-riches-markdown)) @@ -425,10 +424,6 @@ se comportent identiquement. bloc (listes, tableaux, citations) restent en lignes littérales - Voir `render_text()` dans `src/profiles/core/environment/render.py` -**Backend Tkinter historique** : rend le sous-ensemble historique uniquement -(titres `#`/`##`/`###`, gras, italique, code en ligne, `\\n`) ; les -constructions plus riches dégradent en texte plat. - --- ## Gestion des Erreurs et Failmode @@ -946,13 +941,11 @@ outcome = run_workflow( - En mode web : La notification s'ouvre dans une modale de workflow du frontend (vérifiez la fenêtre de bureau / l'onglet navigateur) - En mode headless : Le message est imprimé sur la sortie standard -- Backend Tkinter sans Tkinter : fallback automatique vers headless **Solutions** : 1. En mode web, regardez la modale de workflow dans le frontend (elle attend peut-être votre confirmation) 2. En mode headless, vérifiez la console pour les messages -3. Avec `--tkinter`, vérifiez que Tkinter est installé (`python -m tkinter`) ### Problème : Timeout Fréquent diff --git a/docs/installation-guide.en.md b/docs/installation-guide.en.md index 3d155ed..ca3a49d 100644 --- a/docs/installation-guide.en.md +++ b/docs/installation-guide.en.md @@ -162,7 +162,6 @@ python -m profiles --help ### Main Dependencies - `pywebview` + `fastapi`/`uvicorn` — web GUI backend (default) -- `sv-ttk`, `darkdetect` — **dev-only** since 2026.8, for the deprecated Tkinter backend These are declared in `pyproject.toml` and installed automatically with `pip install .`. @@ -266,12 +265,6 @@ python -m profiles --server-only Runs the FastAPI server without the desktop window — open `http://:8088` from any browser. -### Legacy Tkinter Mode (deprecated) - -```bash -python -m profiles --tkinter -``` - ### Headless Mode (CLI) ```bash @@ -292,22 +285,6 @@ python -m profiles --init --- -## Tkinter Backend (Deprecated) - -The original Tkinter frontend is **deprecated** (developer/advanced use -only) and will be removed in a future release. The web backend -(pywebview + FastAPI, the default) replaces it. - -```bash -profiles --tkinter # prints a deprecation banner, then launches Tk GUI -``` - -Tk-mode dependencies (`sv-ttk`, `darkdetect`) are dev-only: install with -`pip install -e ".[dev]"` or `uv sync`. Inside Briefcase bundles the -Tkinter backend is not available — use the default web GUI. - ---- - ## Best Practices ### Development Workflow @@ -406,7 +383,7 @@ See [AGENTS.md](../AGENTS.md) for architecture details. ### Issue: Wizard fails to install dependencies -**Symptoms**: Installation stops with an error about `sv-ttk` or `darkdetect` (legacy Tkinter backend dependencies). +**Symptoms**: Installation stops with a dependency installation error. **Diagnosis**: - Network connectivity issues @@ -419,13 +396,13 @@ See [AGENTS.md](../AGENTS.md) for architecture details. pip cache purge # Install dependencies directly -pip install sv-ttk darkdetect +pip install -e . # Run installer again python install.py # Or install with verbose output -pip install -v sv-ttk darkdetect +pip install -v -e . ``` ### Issue: `profiles` command not found @@ -488,11 +465,10 @@ sudo pip install -e . ### Issue: GUI not displaying -**Symptoms**: Application starts but no window appears, or a Tkinter/PyWebView error. +**Symptoms**: Application starts but no window appears, or a PyWebView error. **Diagnosis**: -- The default **web** backend needs PyWebView plus an installed web runtime (Edge WebView2 on Windows) — not Tkinter -- Tkinter errors only affect the deprecated `--tkinter` backend (common on minimal Linux installs) +- The **web** backend needs PyWebView plus an installed web runtime (Edge WebView2 on Windows) - Display environment not set (Linux remote sessions) - X11 forwarding not enabled @@ -500,27 +476,20 @@ sudo pip install -e . **Windows**: PyWebView uses the Edge WebView2 runtime, bundled with Windows 10/11. Reinstall Python/WebView2 if needed. -**Linux** (only when using `--tkinter`, or headless WebView dependencies for PyWebView): +**Linux** (PyWebView/WebKit dependencies): ```bash # Ubuntu/Debian -sudo apt-get install python3-tk # legacy Tkinter backend sudo apt-get install libwebkit2gtk-4.1-dev # PyWebView shell - -# RHEL/CentOS -sudo dnf install python3-tkinter - -# Arch Linux -sudo pacman -S python-tkinter ``` +See the [pywebview installation guide](https://pywebview.readthedocs.io/guide/installation.html) for other distributions. + **Remote SSH**: Prefer `ProFiles --server-only` and a browser on your machine instead of X11 forwarding: ```bash ssh user@host ProFiles --server-only # then open http://:8088 locally ``` -**Legacy Tkinter backend**: install python3-tk as shown above, or run `python -m tkinter` to verify Tkinter works. - ### Issue: Configuration file not found **Symptoms**: ProFiles uses defaults instead of custom configuration. @@ -692,14 +661,11 @@ options: --config CONFIG Path to .profiles configuration file (default: auto-search folder tree) --headless Run in headless mode (no GUI) --server-only Run local FastAPI server only (no desktop window, for browser/PWA use) - --tkinter Use Tkinter GUI backend (legacy, deprecated; default is web) - --web Use web GUI backend (default) --init Generate a default .profiles configuration file in the current directory Examples: profiles Launch web UI (default; auto-search config) profiles --server-only Run FastAPI server only (browser/PWA access) - profiles --tkinter Legacy Tkinter interface (deprecated) profiles --config /path/to/.profiles Use explicit config file profiles --headless Headless: scan & launch all profiles --headless file.mttl Headless: launch specific file @@ -736,7 +702,6 @@ Examples: - **Python**: 3.11+ - **Web UI (default)**: React + TypeScript + Vite PWA, served by FastAPI (port 8088), desktop shell via PyWebView - **Node.js/npm**: Optional — only needed to rebuild the frontend (`python -m profiles.web.build --force`) -- **Tkinter (deprecated)**: Only required for the legacy `--tkinter` backend (`sv-ttk`, `darkdetect` auto-install with it) - **License**: See `LICENCE` file --- @@ -775,7 +740,6 @@ profiles/ │ ├── server/ # FastAPI backend (REST API) │ ├── web/ # React + TypeScript + Vite PWA frontend │ ├── desktop/ # PyWebView desktop shell & server lifecycle -│ ├── gui/ # Legacy Tkinter interface (deprecated) │ ├── utils/ # Utility functions │ ├── app.py # Application lifecycle │ └── config.py # Configuration diff --git a/docs/installation-guide.fr.md b/docs/installation-guide.fr.md index f83b2f0..f2fb7ae 100644 --- a/docs/installation-guide.fr.md +++ b/docs/installation-guide.fr.md @@ -193,7 +193,6 @@ python -m profiles.web.build --force ### Dépendances principales - `pywebview` + `fastapi`/`uvicorn` — backend web de l'interface graphique (par défaut) -- `sv-ttk`, `darkdetect` — **réservés au dev** depuis 2026.8, pour le backend Tkinter déprécié Ces dépendances sont déclarées dans `pyproject.toml` et installées automatiquement avec `pip install .`. @@ -297,17 +296,6 @@ python -m profiles --server-only Exécute le serveur FastAPI sans fenêtre de bureau — ouvrez `http://:8088` depuis n'importe quel navigateur. -### Mode Tkinter Historique (obsolète) - -```bash -python -m profiles --tkinter -``` - -> ⚠ **Déprécié** : usage développeur/avancé uniquement ; sera supprimé -> dans une version future. Dépendances (`sv-ttk`, `darkdetect`) -> désormais en extras dev : `pip install -e ".[dev]"`. Indisponible -> dans les bundles Briefcase — utilisez le mode web par défaut. - ### Mode Sans Interface (CLI) ```bash @@ -426,7 +414,7 @@ Voir [AGENTS.md](../AGENTS.md) pour les détails de l'architecture. ### Problème : L'assistant échoue à installer les dépendances -**Symptômes** : L'installation s'arrête avec une erreur concernant `sv-ttk` ou `darkdetect` (dépendances du backend Tkinter historique). +**Symptômes** : L'installation s'arrête avec une erreur d'installation de dépendances. **Diagnostic** : - Problèmes de connectivité réseau @@ -439,13 +427,13 @@ Voir [AGENTS.md](../AGENTS.md) pour les détails de l'architecture. pip cache purge # Installer les dépendances directement -pip install sv-ttk darkdetect +pip install -e . # Exécuter l'installateur à nouveau python install.py # Ou installer avec sortie détaillée -pip install -v sv-ttk darkdetect +pip install -v -e . ``` ### Problème : Commande `profiles` non trouvée @@ -508,11 +496,10 @@ sudo pip install -e . ### Problème : Interface graphique ne s'affiche pas -**Symptômes** : L'application démarre mais aucune fenêtre n'apparaît, ou erreur Tkinter/PyWebView. +**Symptômes** : L'application démarre mais aucune fenêtre n'apparaît, ou erreur PyWebView. **Diagnostic** : -- Le backend **web** (par défaut) requiert PyWebView et un moteur web (Edge WebView2 sous Windows) — pas Tkinter -- Les erreurs Tkinter ne concernent que le backend `--tkinter` (désormais obsolète, courant sur les installations Linux minimales) +- Le backend **web** (par défaut) requiert PyWebView et un moteur web (Edge WebView2 sous Windows) - Environnement d'affichage non défini (sessions distantes Linux) - Transfert X11 non activé @@ -520,27 +507,20 @@ sudo pip install -e . **Windows** : PyWebView utilise le moteur Edge WebView2, inclus avec Windows 10/11. Réinstallez Python/WebView2 si nécessaire. -**Linux** (uniquement pour `--tkinter`, ou les dépendances WebView de PyWebView) : +**Linux** (dépendances PyWebView/WebKit) : ```bash # Ubuntu/Debian -sudo apt-get install python3-tk # backend Tkinter historique sudo apt-get install libwebkit2gtk-4.1-dev # fenêtre de bureau PyWebView - -# RHEL/CentOS -sudo dnf install python3-tkinter - -# Arch Linux -sudo pacman -S python-tkinter ``` +Consultez le [guide d'installation pywebview](https://pywebview.readthedocs.io/guide/installation.html) pour les autres distributions. + **SSH à distance** : Préférez `ProFiles --server-only` et un navigateur sur votre machine au lieu du transfert X11 : ```bash ssh user@host ProFiles --server-only # puis ouvrez http://:8088 en local ``` -**Backend Tkinter historique** : installez python3-tk comme indiqué ci-dessus, ou exécutez `python -m tkinter` pour vérifier que Tkinter fonctionne. - ### Problème : Fichier de configuration non trouvé **Symptômes** : ProFiles utilise les valeurs par défaut au lieu de la configuration personnalisée. @@ -701,7 +681,7 @@ Editable: true **`python -m profiles --help`** : ``` usage: profiles [-h] [--config CONFIG] [--headless] [--init] [--server-only] - [--no-config-propose] [--tkinter] [--web] [file] + [--no-config-propose] [file] ProFiles - Python MuTool Project Launcher @@ -710,8 +690,6 @@ options: --config CONFIG Path to configuration file --headless Run without GUI --server-only Run local FastAPI server only (no desktop window, browser/PWA) - --tkinter Tkinter GUI backend (legacy, deprecated; default is web) - --web Web GUI backend (default) --init Generate starter configuration file ``` @@ -745,7 +723,6 @@ options: - **Python** : 3.11+ - **Interface Web (par défaut)** : React + TypeScript + Vite PWA, servie par FastAPI (port 8088), fenêtre de bureau via PyWebView - **Node.js/npm** : Optionnel — nécessaire uniquement pour reconstruire le frontend (`python -m profiles.web.build --force`) -- **Tkinter (obsolète)** : Requis uniquement pour le backend historique `--tkinter` (`sv-ttk`, `darkdetect` installés avec lui) - **Rich** : Requis pour l'assistant CLI - **Licence** : Voir le fichier `LICENCE` @@ -785,7 +762,6 @@ profiles/ │ ├── server/ # Backend FastAPI (API REST) │ ├── web/ # Frontend React + TypeScript + Vite PWA │ ├── desktop/ # Fenêtre de bureau PyWebView & cycle de vie serveur -│ ├── gui/ # Interface Tkinter historique (obsolète) │ ├── utils/ # Fonctions utilitaires │ ├── app.py # Cycle de vie application │ └── config.py # Configuration diff --git a/src/profiles/core/AGENTS.md b/src/profiles/core/AGENTS.md index 6333ebc..ebf8076 100644 --- a/src/profiles/core/AGENTS.md +++ b/src/profiles/core/AGENTS.md @@ -7,8 +7,8 @@ Read this before editing any file in `src/profiles/core/`. ## 🎯 What lives here -The `core/` package contains **all domain logic** — zero GUI / Tkinter -dependencies, pure stdlib, reusable by GUI, CLI, and any future TUI. +The `core/` package contains **all domain logic** — zero UI dependencies, +pure stdlib, reusable by the web frontend, CLI, and any future TUI. The package is split into SOLID-aligned sub-packages so that each one owns a single responsibility: @@ -54,8 +54,8 @@ owns a single responsibility: | Rule | Reason | | --- | --- | -| **NO Tkinter imports** | Core must stay reusable from CLI / TUI | -| **NO imports from `profiles.gui.*`** | Breaks the layered architecture | +| **NO UI imports** (Tkinter, web, desktop) at module level | Core must stay reusable from CLI / TUI | +| **NO imports from the presentation layers** (`profiles.web/server/desktop`) | Breaks the layered architecture | | **Pure functions where possible** | Testability, referential transparency | | **Return typed structures** — never `None` for collections | Callers can iterate without `if x is not None` | | **Type hints on every public signature** | Project standard (`py.typed` shipped) | diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 49b14c7..44e5abe 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -4,7 +4,7 @@ This file provides context and instructions for AI agents working on the ProFile ## Project Overview -ProFiles is a desktop + web application: the default frontend is web (React in `src/profiles/web`, FastAPI server in `src/profiles/server`, PyWebView shell in `src/profiles/desktop`). The Tkinter GUI (`src/profiles/gui/`) is **deprecated** — its tests are skipped (see *Test File Organization*) pending removal in phase 2 of the deprecation plan. +ProFiles is a desktop + web application: the default frontend is web (React in `src/profiles/web`, FastAPI server in `src/profiles/server`, PyWebView shell in `src/profiles/desktop`). **Key technical constraints:** - **Python 3.11+** with `from __future__ import annotations` @@ -18,8 +18,7 @@ ProFiles is a desktop + web application: the default frontend is web (React in ` 1. **Comprehensive coverage** — the suite gate is 85% (`--cov-fail-under`); aim for 90%+ on every module 2. **Mock platform‑specific calls** — always mock `os.startfile`, `subprocess.run`, `socket.socket`, etc. 3. **Test-only dependencies** — pytest, pytest-cov, pytest-mock, plus `httpx` for server-route tests -4. **No Tkinter mainloop** — GUI tests use widget creation but never call `mainloop()` -5. **Reset global state** — the `profiles` logger is module‑level; tests must clean up between runs +4. **Reset global state** — the `profiles` logger is module‑level; tests must clean up between runs ## Test File Organization @@ -31,12 +30,9 @@ Test files mirror the `src/profiles/` package tree 1:1 — each source module `X tests/ ├── conftest.py # os.startfile polyfill (root) ├── test_app.py # ← app.py -├── test_app_backend_selection.py # ← app.py (supplement: --tkinter/web backend routing) ├── test_app_host.py # ← app.py (supplement: --host CLI option) ├── test_app_launch_resolution.py # ← app.py (supplement: launch config resolution) ├── test_app_log_path.py # ← app.py (supplement: CWD-independent log path) -├── test_app_no_tkinter.py # ← app.py (supplement: web/headless run with tkinter blocked) -├── test_app_tk_lifecycle.py # ← app.py (supplement: deprecated tk branch — module-skipped) ├── test_config.py # ← config.py (shim re-exports) ├── characterization/ # cross-cutting golden baseline (see exceptions) ├── core/ @@ -82,19 +78,6 @@ tests/ │ ├── test_lifecycle.py # ← desktop/lifecycle.py │ ├── test_launcher.py # ← desktop/launcher.py │ └── test_instance.py # ← desktop/instance.py -├── gui/ # DEPRECATED — conftest.py skip-marks everything here -│ ├── conftest.py # skip mark: tkinter deprecation -│ ├── test_context_menu.py # ← gui/context_menu.py -│ ├── test_i18n.py # ← gui/i18n.py -│ ├── test_main_window.py # ← gui/main_window.py -│ ├── test_main_window_shortcuts.py # ← gui/main_window.py (supplement: shortcuts dialog) -│ ├── test_search_bar.py # ← gui/search_bar.py -│ ├── test_status_bar.py # ← gui/status_bar.py -│ ├── test_styles.py # ← gui/styles.py -│ ├── test_theme.py # ← gui/theme.py -│ ├── test_ui.py # ← gui/ui.py -│ ├── controllers/ # ← gui/controllers/{directory_manager,scan_controller,window_actions}.py -│ └── presentation/test_row_colors.py # ← gui/presentation/row_colors.py ├── launch/ │ ├── test_context.py # ← launch/context.py │ ├── test_creation.py # ← launch/creation.py @@ -139,8 +122,6 @@ tests/ **Known coverage gaps (mirrored test file does not exist yet):** `core/telemetry/log_viewer.py`, `server/state.py` — `server/state.py` is partially exercised through `tests/server/test_app.py`. -**Deprecated Tkinter isolation:** everything under `tests/gui/` is skip-marked by `tests/gui/conftest.py` with a deprecation reason, and `tests/test_app_tk_lifecycle.py` carries the same module-level skip. These files are deleted together with `src/profiles/gui` in phase 2. - ## Writing Tests ### Style Guide @@ -173,12 +154,10 @@ class TestFeature: | `platform.node()` | `mocker.patch("platform.node", return_value="...")` | | `getpass.getuser()` | `mocker.patch("getpass.getuser", return_value="...")` | | `logging.handlers` | Use `tmp_path` for log file, verify file contents | -| `tkinter.Tk()` | Create in fixture, destroy after (only in `tests/gui`) | ### Common Pitfalls - **Logger state leakage**: The `profiles` logger is global. Always reset handlers between tests using `logging.getLogger("profiles").handlers.clear()`. -- **Tkinter tests are skipped**: `tests/gui/**` and `tests/test_app_tk_lifecycle.py` are skip-marked (deprecated frontend). Do not un-skip them; they are removed in phase 2. - **Python 3.13 logger reentrancy**: records logged through `Logger.handle` from inside a handler callback are silently dropped (thread-local guard). In-handler telemetry must go through `profiles.core.telemetry.diagnostics.emit_event_through_handlers`. - **pytest-qt excepthook capture**: pytest-qt (if installed) fails any test routing an exception through `sys.excepthook`. Tests that deliberately exercise the crash hook carry the `qt_no_exception_capture` marker (registered in `pyproject.toml`). - **Platform‑specific paths**: Use `os.sep` or `pathlib.Path` for cross-platform path handling. @@ -204,7 +183,7 @@ markers = [ ] ``` -`.coveragerc` sets `branch = True`, omits `tests/`, `__main__.py` and the deprecated `profiles/gui/` package, and enforces `fail_under = 85`. +`.coveragerc` sets `branch = True`, omits `tests/` and `__main__.py`, and enforces `fail_under = 85`. This makes `pytest` discover tests in the `tests/` directory and add both the repo root and `src/` to `sys.path` so imports like `from profiles.config import ...` work without installation. @@ -220,5 +199,4 @@ This makes `pytest` discover tests in the `tests/` directory and add both the re ## Conftest Notes - Root `tests/conftest.py` polyfills `os.startfile` on non-Windows (needed by launch tests across all subdirs) and provides the `sample_config_path` / `empty_config_path` fixtures. -- `tests/gui/conftest.py` applies the Tkinter-deprecation skip to everything collected under `tests/gui/`. - `tests/characterization/` manages its own `fixtures.py` module and golden JSON; it manipulates `sys.path` locally, so avoid importing from it elsewhere. diff --git a/tests/README.md b/tests/README.md index cfb3634..9f932cb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,7 +24,7 @@ pytest -v # Run a specific test file (mirrored paths) pytest tests/core/config/test_loader.py -v -pytest tests/gui/test_main_window.py -v +pytest tests/desktop/test_launcher.py -v pytest tests/utils/test_search_parser.py -v # Run a specific test class @@ -32,7 +32,7 @@ pytest tests/core/config/test_loader.py::TestLoadConfig -v # Run all tests for a source sub-package pytest tests/core/config/ -v -pytest tests/gui/ -v +pytest tests/desktop/ -v pytest tests/utils/ -v ``` @@ -49,24 +49,23 @@ pytest --cov=profiles --cov-report=html pytest --cov=profiles.config -v ``` -Coverage target: **85%** enforced by `--cov-fail-under` (aim for 90%+ per module); the deprecated `profiles/gui/` package is omitted (see `.coveragerc`). +Coverage target: **85%** enforced by `--cov-fail-under` (aim for 90%+ per module). ## Test File Structure Test files mirror the `src/profiles/` tree 1:1 — each source module has a matching `test_.py` at the mirrored path, and supplementary files for the same module are named `test__.py`. Every test directory is a package (`__init__.py`), mirroring `src/profiles`. -The authoritative layout — full tree, supplements, documented cross-cutting exceptions (`tests/characterization/`, `tests/packaging/`, `tests/server/test_barcode_integration.py`, `test_public_api.py`), known coverage gaps, and the deprecated-Tkinter skip — is maintained in **[AGENTS.md](AGENTS.md) → Test File Organization**. +The authoritative layout — full tree, supplements, documented cross-cutting exceptions (`tests/characterization/`, `tests/packaging/`, `tests/server/test_barcode_integration.py`, `test_public_api.py`) and known coverage gaps — is maintained in **[AGENTS.md](AGENTS.md) → Test File Organization**. Highlights: | File | Source Module | Focus Areas | |---|---|---| | tests/test_app.py | profiles.app | ProFileApp, main(), init_default_config | -| tests/test_app_*.py | profiles.app | supplements: backend selection, --host option, launch resolution, log path, no-tkinter run, deprecated tk lifecycle | +| tests/test_app_*.py | profiles.app | supplements: --host option, launch resolution, log path | | tests/test_config.py | profiles.config | shim re-exports (ColumnConfiguration, …) | | tests/core/… | profiles.core.* | actions, config (models/reader/schema/service/loader/matcher/validator/template/inheritance/yaml_io), environment (system/execution/matcher/render/interactions/message_dialog/workflow), processing (scanner/file_classifier/file_metadata/column_extractor), telemetry (diagnostics/events/metrics) | | tests/desktop/test_lifecycle.py | profiles.desktop.lifecycle | port discovery, background server lifecycle | | tests/desktop/test_launcher.py | profiles.desktop.launcher | connectable window URL rule, URL banner, window-bound lifecycle | -| tests/gui/** | profiles.gui.* | **skipped** — deprecated Tkinter frontend (see `tests/gui/conftest.py`) | | tests/launch/test_{context,creation,resolution}.py | profiles.launch.* | launch context detection, shortcut creation, resolution | | tests/packaging/ | (cross-cutting) | entry-point stub, packaging dependency rules, ProFiles.pyw stub | | tests/server/test_app.py | profiles.server.app | FastAPI factory, client connection telemetry | @@ -125,7 +124,7 @@ def test_launch_macos(mocker): ``` ### Fixtures Reference -tests/conftest.py provides shared infrastructure auto-inherited by all subdirs: it polyfills `os.startfile` on non-Windows so launch tests can mock it, and provides the `sample_config_path` / `empty_config_path` fixtures. `tests/gui/conftest.py` applies the Tkinter-deprecation skip to everything under `tests/gui/`. Use the built-in `tmp_path` fixture for temporary file setups. +tests/conftest.py provides shared infrastructure auto-inherited by all subdirs: it polyfills `os.startfile` on non-Windows so launch tests can mock it, and provides the `sample_config_path` / `empty_config_path` fixtures. Use the built-in `tmp_path` fixture for temporary file setups. ### CI/CD Integration ```yaml