diff --git a/.pi-tasks/.ignore b/.pi-tasks/.ignore new file mode 100644 index 0000000..c64e0f4 --- /dev/null +++ b/.pi-tasks/.ignore @@ -0,0 +1,4 @@ +# Keep committed task files out of pi find/grep discovery (host model + research +# workers) while git still tracks them. .ignore is read by fd/ripgrep, not by +# git, so tasks stay committable. Managed by pi-task. +* diff --git a/.pi-tasks/TASK_0001.md b/.pi-tasks/TASK_0001.md new file mode 100644 index 0000000..ba9b0ae --- /dev/null +++ b/.pi-tasks/TASK_0001.md @@ -0,0 +1,229 @@ +--- +id: TASK_0001 +state: completed +phase: done +created_at: 2026-07-01T14:06:46.185Z +updated_at: 2026-07-01T14:20:49.351Z +title: Add a small shared API inside `mkpfs` that exposes command metadata, default output naming, and progress events, so command and option discovery can happen dynamically. The primary discovery path should be importing the `mkpfs` package and reading its metadata; if that metadata is not available, the implementation should fall back to parsing `mkpfs --help`. Keep the current CLI behavior intact while making the package provide a stable source of truth for these shared details. +label: mkpfs shared metadata, naming, and progress events API +--- + +## raw prompt + +Add shared mkpfs metadata and progress hooks | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events; discover commands/options dynamically by first importing the mkpfs package and fall back to parsing mkpfs --help if import metadata isn't available + +## refined prompt + +GOAL +Add a small shared API inside `mkpfs` that exposes command metadata, default output naming, and progress events, so command and option discovery can happen dynamically. The primary discovery path should be importing the `mkpfs` package and reading its metadata; if that metadata is not available, the implementation should fall back to parsing `mkpfs --help`. Keep the current CLI behavior intact while making the package provide a stable source of truth for these shared details. + +CONSTRAINTS +- Do not change existing command semantics, arguments, or help text beyond exposing metadata for discovery. +- Do not implement any work that belongs to later steps: package splitting, focused modules, dynamic navigation UI, option forms, footer versioning, progress mirroring, or window responsiveness. +- Keep the shared API small and localized to `mkpfs`; avoid introducing unrelated abstractions or broad refactors outside this slice. +- The import-based discovery path must be preferred, and `mkpfs --help` must remain a fallback when import metadata is unavailable. +- Any progress hook support added here must be additive and non-breaking for existing callers. +- Preserve all existing concrete identifiers exactly as they are, including `mkpfs` and `mkpfs --help`. + +KNOWN-UNKNOWNS +- What exact public symbols, module, or object in `mkpfs` should expose the shared metadata and progress hook API? +- What shape should the command metadata take for command names, options, defaults, and help text? +- What exact progress event fields and lifecycle should be emitted? +- What conditions should count as “import metadata isn’t available” and trigger the `mkpfs --help` fallback? +- Should fallback parsing cover only top-level help, or also subcommand help output? + +EXTERNAL-DEPENDENCIES + +## verified tooling + +uv run ruff format . +uv run ruff check . --fix +uvx ruff format . --check --diff +uvx ruff check --output-format=github . +uv run --frozen pytest +SKIP_VENV=1 ./run-tests.sh +uv build +uv run twine check dist/* + +## research + +FILES +mkpfs/__init__.py This is the package root for MkPFS, providing version info. +mkpfs/cli.py Command-line interface for the mkpfs package, handling user input. +mkpfs/consts.py Contains constant definitions used across the package. +mkpfs/logging.py Provides logging utilities for the CLI. +mkpfs/pbar.py Implements a progress bar for CLI operations. +mkpfs/pfs.py Manages PFS file images that could be mounted on PS4 and PS5. +mkpfs/utils.py Contains various utility functions and helpers for the package. +pyproject.toml Project metadata and configuration, including dependencies and build info. +tests/mkpfs/test_ampr.py Unit tests for the ampr module within mkpfs. +tests/mkpfs/test_cli.py Unit tests for the CLI interface of mkpfs. +tests/mkpfs/test_consts.py Unit tests for constant definitions in consts.py. +tests/mkpfs/test_exfat.py Unit tests for exfat-handling functionality in mkpfs. +tests/mkpfs/test_exfat_writer.py Unit tests for exfat writer logic. +tests/mkpfs/test_init.py Unit tests for the package initialization. +tests/mkpfs/test_logging.py Unit tests for logging mechanisms. +tests/mkpfs/test_main.py Unit tests for the main execution flow. +tests/mkpfs/test_pbar.py Unit tests for progress bar utility. +tests/mkpfs/test_pfs.py Unit tests for PFS operations. +tests/mkpfs/test_utils.py Unit tests for utility functions in utils.py. +tests/integration.sh Script for running integration tests. +README.md Documentation on how to use the mkpfs package. +LICENSE Project license file. + +APIS +mkpfs.__version__ str — package version string + +mkpfs.get_cli_metadata(prefer_import: bool = True) -> dict[str, object] — Return structured command/option metadata; prefer import-time metadata, fall back to running `mkpfs --help` and parsing when import metadata unavailable + +mkpfs.cli_metadata dict[str, object] | None — Optional import-time metadata mapping command name -> metadata (None when not present) + +mkpfs.default_image_basename(source_root: pathlib.Path) -> str — Return default output basename for a given source (wrapper over utils.default_image_basename) + +mkpfs.normalize_output_path(path_arg: str, desired_suffix: str, adjust: bool = True) -> tuple[pathlib.Path, bool] — Normalize output path and report whether suffix was changed (re-export of utils.normalize_output_path) + +mkpfs.cli.cli_mkpfs_main_parsers() -> argparse.ArgumentParser — Build the top-level argparse parser and subparsers (used to introspect commands/options) + +mkpfs.get_help_title() -> str — Return the short CLI help title (MkPFS ) + +mkpfs.cli.cli_mkpfs_main(argv: list[str] | None = None) -> int — CLI entrypoint that dispatches subcommands (kept for fallback usage and behavior preservation) + +mkpfs.pbar.Progress class — Progress helper used by CLI flows; methods: __init__(enabled: bool = True, width: int = 32), step(phase: str, done: int, total: int, bytes_processed: int = 0) -> None, status(message: str) -> None + +mkpfs.ProgressEvent dataclass(phase: str, done: int, total: int, bytes_processed: int | None, timestamp: float) — New lightweight event shape emitted for progress handlers + +mkpfs.register_progress_handler(handler: Callable[[mkpfs.ProgressEvent], None]) -> Callable[[], None] — Register a global/additive progress event handler; returns an unregister function + +mkpfs.pfs.encode_pfsc_payload(raw: bytes, threshold_gain: int, zlib_level: int, logical_block_size: int = ..., progress_callback: Callable[[int], None] | None = None) -> tuple[bytes, float, int] — PFSC encoder that accepts an additive progress_callback reporting bytes processed (used to bridge to progress events) + +mkpfs.pfs.resolve_single_file_inner_name(source_name: str, rename_inner_image: bool = True) -> str — Compute safe inner filename for single-file pack flows (used for default naming) + +CONTEXT +- Importing the package root currently executes only mkpfs/__init__.py which defines __version__ and nothing else — therefore a discovery API added to the package root (or a new lightweight submodule imported from it) can be read with a cheap import without pulling cli.py or heavy build/inspect code. + +- The canonical CLI command/option text is constructed inside mkpfs/cli.py by cli_mkpfs_main_parsers() (and MkPFSArgumentParser.format_help()), so any import-based metadata that reproduces authoritative help must either (a) expose data separately or (b) import/construct that parser; importing cli.py is heavy because cli.py imports many other modules (pfs, exfat, ampr, exfat_writer, etc.). + +- The installed console script entrypoint (pyproject.toml [project.scripts]) maps mkpfs -> mkpfs.cli:main, so the required fallback path "mkpfs --help" must invoke that console entry (or run equivalent subprocess) — the fallback help text format is produced by cli_mkpfs_main_parsers() and MkPFSArgumentParser.format_help(), which injects a custom header and "Usage:" section. + +- The CLI supports legacy flattened pack invocation that is normalized by normalize_cli_argv_for_pack_compat() (it rewrites "mkpfs pack " into "mkpfs pack folder|file ..."), and that normalization inspects the filesystem (Path.is_file()) to infer mode; discovery metadata must reflect the canonical subcommands (pack folder/file/exfat) while being aware that user-facing invocations historically accepted a flat form. + +- Many CLI behaviors that affect default output naming are data-driven: utils.default_image_basename(source_root: Path) reads sce_sys/param.json (via title_id_from_source/read_param_json) to prefer a title ID; normalize_output_path(path_arg, desired_suffix, adjust=True) encapsulates suffix-adjustment logic — an import-based metadata API should reuse or expose these utility functions rather than duplicating file-inspection logic. + +- The CLI chooses output suffixes conditionally: pack.folder with raw==False sets inner exFAT name f"{default_image_basename(source)}.exfat" and final suffix ".ffpfsc" (or ".ffpfs" when titleId detected for raw mode); single-file streaming prefers ".ffpfsc"; these suffix choices depend on source content (titleId) and on options like --raw, so metadata for default naming must either provide a deterministic function to compute suffix given a Path + options or clearly document that suffixes may change based on source inspection. + +- Progress reporting is implemented by the Progress class in mkpfs/pbar.py (methods: step(phase, done, total, bytes_processed=0) and status(message)); many library functions accept an optional Progress or progress-like object (verify_file_payload_hashes, validate_source_match, extract_exfat_image, extract_pfs_image, pfs_extract wrapper), but several core builders instantiate Progress internally (build_pfs, build_pfs_stream_single_file, build_pfs_stream_from_exfat) and do not accept a progress parameter — so any additive progress-hook API will need to either expose the Progress class for callers to pass where already supported, or add new optional params/hooks to builders (must be additive and default to current behavior). + +- The Progress implementation writes to stderr directly and uses the step signature (phase, done, total, bytes_processed) and a status() method; tests and callers expect that specific shape — the shared progress hook must preserve this interface or be adapted via an adapter that calls these methods. + +- Many places drive progress by logical bytes and use an internal update throttle (update_interval 8 MiB) and distinct phase names ("compress","write","verify","compare","extract","read") — consumers of a shared progress event stream will likely need those phase names and units (bytes vs items) to present meaningful progress. + +- The CLI help output is customized by MkPFSArgumentParser.format_help() which (a) prepends get_output_title() including __version__ and PROJECT_URL, (b) replaces argparse usage header with "Usage:\n \n\n", and (c) appends parser.epilog examples; a fallback parser that consumes mkpfs --help must parse this non-standard header/usage layout (and optionally ignore the project URL/version line). + +- Some CLI option defaulting and semantics are encoded across multiple helper functions (cli_mkpfs_add_create_args, _resolve_pack_build_config, _resolve_pack_temp_folder, _stream_fallback_reason, PackVerificationMode/_resolve_pack_verification_mode), so extracting accurate option metadata (types, default values, mutually-exclusive groups, and inter-option constraints) by static parsing of help text can miss semantic constraints implemented in those helper functions. + +- CLI handlers are bound by parser.set_defaults(func=...) to concrete functions in cli.py (e.g., folder.set_defaults(func=cli_mkpfs_create_run)); these handlers execute heavy work and perform filesystem operations — metadata discovery must not invoke those functions when imported; exposing handler callables in metadata is fine but import-based discovery should avoid executing them. + +- The package already exposes short, stable library wrappers with pfs_ prefixes at the bottom of mkpfs/pfs.py (pfs_build, pfs_inspect, pfs_read_info, pfs_extract, pfs_verify) — a discovery API may prefer to reference these thin, stable wrappers for programmatic usage rather than CLI handlers. + +- The codebase contains logic that reads/writes temp files and probes disk space (resolve_temp_root, resolve_disk_usage_probe_path, shutil.disk_usage) and may prompt the user (prompt_overwrite) — the import-based discovery path must avoid triggering any filesystem probing or interactive prompts. + +- The help/version banner has a special early interception for -V in cli_mkpfs_main (it checks effective_argv[0] in ("-V") before building parser) — parsing fallback that simply runs mkpfs --help must consider that -V is handled specially by main() and printing version only. + +- Tests reference Progress and expect deterministic public names: tests use Progress(enabled=True/False, width=...) and some tests record events by subclassing; any shared progress API must maintain method names and semantics used in tests to remain compatible. + +- The package already provides default naming utilities and path-normalization helpers in mkpfs/utils.py; reusing those functions will ensure metadata-driven default output naming matches existing CLI behavior exactly. + +VERIFIED-TOOLING + uv run ruff format . + uv run ruff check . --fix + uvx ruff format . --check --diff + uvx ruff check --output-format=github . + uv run --frozen pytest + SKIP_VENV=1 ./run-tests.sh + uv build + uv run twine check dist/* + +## grill Q&A + +Q1: For the fallback path, do you want me to parse only the top-level "mkpfs --help" output, or must the fallback also run and parse each subcommand's help (e.g., "mkpfs pack --help") to provide full per-subcommand option metadata? +A1: Run and parse mkpfs --help, then for each top-level subcommand discovered there run mkpfs --help and parse those outputs to produce full per-command option metadata (only probe canonical subcommands listed in top-level help; timebox/subprocess-safeguard the calls). +Q2: Do you want the shared API exposed under these exact public symbols — mkpfs.cli_metadata (optional dict), mkpfs.get_cli_metadata(prefer_import: bool = True) -> dict, mkpfs.default_image_basename(Path) -> str, mkpfs.normalize_output_path(path_arg, desired_suffix, adjust=True) -> (Path, bool), mkpfs.ProgressEvent dataclass, mkpfs.register_progress_handler(handler) -> unregister, and re-export mkpfs.pbar.Progress — or would you prefer a different single module/name for any of these (if so, which one)? +A2: Whatever makes the project simpler, easy to maintain and well organized/standardized. +Q3: For the CLI metadata shape, which do you want me to implement: A) full argparse-accurate structured metadata (per-command usage, subcommands, options with dest, flags, type, nargs, default, choices, required, action, mutually-exclusive groups), B) lightweight discovery metadata (per-command name, short help, usage string, options list with flags, help text, and default-as-string), or C) a hybrid where import-based discovery returns the full structure but the help-fallback returns a reduced/nullable form normalized to the same schema (i.e., prefer full when available but accept missing fields from help parsing)? +A3: Implement the hybrid (C): prefer and expose a full argparse-accurate structure when available from import, but normalize the help-parsed fallback to the same schema with optional/nullable fields (omitted or None when unavailable) and include a small "source" tag ("import" | "help") so callers can detect missing details. (auto) + +## spec + +GOAL + Add a small, stable, importable discovery API inside the mkpfs package that exposes per-command metadata, deterministic default output naming helpers, and an additive progress-event hook system so callers can discover commands/options programmatically (prefer import-time metadata; fall back to parsing mkpfs --help and each listed subcommand's --help). Preserve existing CLI behavior and identifiers exactly while keeping the API small and localized to mkpfs. + +CONSTRAINTS + - Do not change any existing command semantics, arguments, or help text beyond exposing metadata for discovery; the CLI must behave identically for end users. + - Keep the feature localized to the mkpfs package root or a tiny submodule imported from it; avoid broad refactors or moving code between modules. + - The import path must be cheap and must not perform heavy imports that trigger filesystem probes, disk-usage checks, or interactive prompts on plain `import mkpfs`. Heavy modules (cli.py, pfs builders that perform work) must not be imported automatically on package import. + - Preferred discovery: when get_cli_metadata(prefer_import=True) is called, return authoritative, argparse-accurate metadata sourced from an import-time structure (mkpfs.cli_metadata) if present; otherwise run `mkpfs --help` and for each canonical subcommand listed there run `mkpfs --help` and parse outputs to produce metadata. The returned metadata object must include a "source" tag with value "import" or "help". + - The fallback help-parsing flow must be timeboxed and subprocess-safe: spawn at most one help subprocess per canonical top-level subcommand discovered, with a per-subprocess timeout (e.g., 5s) and a global wall-clock cap. Failures/timeouts must be reported in the metadata as partial/null fields, not raise. + - Implement the hybrid metadata schema: import-sourced metadata should be full and argparse-accurate (dest, flags, type, nargs, default, choices, required, action, mutually-exclusive groups, usage, short help, parser-level epilog); help-sourced metadata should be normalized to the same schema but may contain None / omitted fields where help text lacks structure. + - Preserve concrete identifiers exactly: expose as public symbols under the mkpfs package the following names and signatures (matching the project's conventions and tests): mkpfs.cli_metadata (optional dict or None), mkpfs.get_cli_metadata(prefer_import: bool = True) -> dict, mkpfs.default_image_basename(source_root: pathlib.Path) -> str, mkpfs.normalize_output_path(path_arg: str, desired_suffix: str, adjust: bool = True) -> tuple[pathlib.Path, bool], mkpfs.ProgressEvent dataclass, mkpfs.register_progress_handler(handler: Callable[[mkpfs.ProgressEvent], None]) -> Callable[[], None], and re-export mkpfs.pbar.Progress as mkpfs.Progress. + - Any progress hook support must be strictly additive and non-breaking: existing callers that construct mkpfs.pbar.Progress and existing functions must continue to work unchanged. New global handlers must be optional, additive, and return an unregister callable. + - Bridge existing internal progress producers (notably pfs.encode_pfsc_payload which already accepts a progress_callback(bytes_processed:int)) into the new global ProgressEvent stream without changing the existing function signatures; add optional new adapter glue where necessary but keep original parameters unchanged. + - Preserve Progress class API and semantics: Progress(enabled: bool = True, width: int = 32), step(phase: str, done: int, total: int, bytes_processed: int = 0), status(message: str). The ProgressEvent shape must include at least fields: phase: str, done: int, total: int, bytes_processed: int | None, timestamp: float. + - Reuse existing utilities for default naming and path normalization (mkpfs.utils.default_image_basename, mkpfs.utils.normalize_output_path) rather than duplicating file-probing logic; exposed wrappers must return identical results to current CLI helpers for the same inputs. + - Do not add behavior that belongs to later steps: no package splitting, no dynamic UI or option forms, no footer versioning changes, no progress mirroring into UI windows, and no layout/responsiveness work. + - Unit tests and existing integration scripts must remain runnable; do not add tests that require network or interactive input. Additions should be covered by new unit tests where feasible, but this task's deliverable is the implementation spec and verification commands only. + +ACCEPTANCE + - Importing mkpfs (import mkpfs) remains cheap and does not perform heavy work, side-effects, filesystem probes, or interactive prompts. + - mkpfs exposes the following public API that callers can import and use: + - mkpfs.cli_metadata: dict | None — optional import-time authoritative metadata (None if not present) + - mkpfs.get_cli_metadata(prefer_import: bool = True) -> dict — returns per-command metadata with a top-level "source" tag ("import" | "help") + - mkpfs.default_image_basename(Path) -> str — deterministic default output basename that matches current CLI behavior + - mkpfs.normalize_output_path(path_arg: str, desired_suffix: str, adjust: bool = True) -> (Path, bool) — re-export/wrapper around utils.normalize_output_path + - mkpfs.ProgressEvent dataclass(phase: str, done: int, total: int, bytes_processed: int | None, timestamp: float) + - mkpfs.register_progress_handler(handler: Callable[[mkpfs.ProgressEvent], None]) -> Callable[[], None] — registers a global handler, returns an unregister function + - mkpfs.Progress (re-export of mkpfs.pbar.Progress) + - get_cli_metadata with prefer_import=True returns the import-sourced, full argparse-accurate structure when mkpfs.cli_metadata is present; when cli_metadata is missing or prefer_import=False, get_cli_metadata runs the safe, timeboxed help-parsing fallback that executes `mkpfs --help` and then `mkpfs --help` for each top-level subcommand discovered, merging results into the normalized schema and marking "source":"help". + - The help fallback parsing must ignore non-standard help header lines (version/project URL) and tolerate MkPFSArgumentParser's custom formatting; missing fields from help-parsed outputs must be represented as None or omitted but must preserve canonical names for options and subcommands. + - The progress-event API must be usable both by callers that pass a Progress into existing functions and by callers who register global handlers; pfs.encode_pfsc_payload must (without signature change) be able to drive the global handlers when a progress_callback is provided internally or adapted. + - All existing unit tests and integration scripts pass locally (no regressions). The repository lint (ruff) and test suite (pytest and ./run-tests.sh) run cleanly after the change. + - The implementation must include reasonable error-handling: help-subprocess failures/timeouts are recorded (partial metadata) but do not raise; register_progress_handler returns a stable unregister function even if handler registration fails. + +VERIFY: +```sh +uvx ruff check --output-format=github . +uv run --frozen pytest +SKIP_VENV=1 ./run-tests.sh +``` + +## phase timings + +refine 25.1s +research 386.7s + enrichment 954ms + worker:files wait 476ms + worker:files work 24.7s + worker:apis wait 594ms + worker:apis work 68.5s + worker:context wait 482ms + worker:context work 90.4s + worker:tooling wait 503ms + worker:tooling work 48.1s + workers 322.1s + verify-tooling 64.6s +grill 343.8s + gen 10.9s + auto-answer 9.5s + gen 18.6s + auto-answer 124.4s + gen 14.0s + auto-answer 7.3s + gen 13.5s + gen 16.0s +compose 43.5s +critique 39.8s + triage 39.8s +total 839.0s + +## handoff + +handoff_at: 2026-07-01T14:20:49.350Z diff --git a/.pi-tasks/TASK_0002.md b/.pi-tasks/TASK_0002.md new file mode 100644 index 0000000..75272ae --- /dev/null +++ b/.pi-tasks/TASK_0002.md @@ -0,0 +1,475 @@ +--- +id: TASK_0002 +state: completed +phase: done +created_at: 2026-07-01T14:31:02.673Z +updated_at: 2026-07-02T01:37:44.182Z +title: Refactor the single-file GUI implementation at mkpfs/gui.py into a proper package mkpfs/gui/ composed of focused modules so the runtime API and user experience are unchanged. After the change: +label: mkpfs GUI: migrate gui.py into mkpfs/gui package +--- + +## raw prompt + +Split the GUI into a package with focused modules | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): keep CustomTkinter/tkinter to minimize scope, preserve current behavior and packaging, and focus on modularization + +## refined prompt + +GOAL + Refactor the single-file GUI implementation at mkpfs/gui.py into a proper package mkpfs/gui/ composed of focused modules so the runtime API and user experience are unchanged. After the change: + - mkpfs.gui remains importable and python -m mkpfs.gui still launches the GUI. + - Public classes and functions keep their exact names and signatures: MkPFSApp, BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton, tr, main, and BasePanel._run_mkpfs (and its behavior). + - The translations data structures remain identical: _TRANSLATIONS, _LANG_NAMES, and _current_locale. + - The same assets-resolution logic and paths in MkPFSApp._set_window_icon (candidates list) and all UI behaviours (dialogs, run flow, logging, language switching, progress handling) are preserved. + +CONSTRAINTS + - Do not change any public class or function names, signatures, or their semantics (preserve MkPFSApp, BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton, tr, main, BasePanel._run_mkpfs, _TRANSLATIONS, _LANG_NAMES, _current_locale, MkPFSApp._PAGES exactly). + - Preserve current runtime behavior: running python -m mkpfs.gui must still start the same UI and all commands invoked via _run_mkpfs must continue to call mkpfs.cli.cli_mkpfs_main and stream output unchanged. + - Keep CustomTkinter/tkinter usage; do not migrate to another GUI toolkit or change widget APIs. + - Preserve packaging surface: the package name mkpfs.gui must exist and any previously documented invocation (python -m mkpfs.gui and the mkpfs-gui console/entrypoint) must continue to work without changing end-user commands. + - Do not modify any code outside the GUI slice (no changes to CLI, core mkpfs modules, packaging metadata, tests for other areas, or files owned by other plan steps). + - Do not add new runtime dependencies beyond those already used by the GUI (CustomTkinter, Pillow, tkinter). Do not change imports in non-GUI code. + - Keep all translation keys and literal strings verbatim (no reorganising or renaming keys in _TRANSLATIONS). + - Avoid functional refactors that alter threading, subprocess/import behavior, builtins.input patching, or progress/log semantics. + +KNOWN-UNKNOWNS + - Do you want these module names and layout? Proposal (exact names to be used): + - mkpfs/gui/__init__.py (re-export main and key types; preserve import surface) + - mkpfs/gui/__main__.py (calls main() to preserve python -m mkpfs.gui) + - mkpfs/gui/app.py (MkPFSApp and top-level window construction) + - mkpfs/gui/panels.py (BasePanel and all Pack/Verify/Inspect/Tree/Unpack panel classes) + - mkpfs/gui/widgets.py (GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton) + - mkpfs/gui/i18n.py (tr, _TRANSLATIONS, _LANG_NAMES, _current_locale) + - mkpfs/gui/theme.py (theme constants: colours, fonts, _PANEL_ACCENT, UI helper functions) + Is this module breakdown acceptable, or do you prefer different filenames or a different split (for example putting Panel subclasses each in their own module)? + - Backwards compatibility: do you want to preserve the original single file mkpfs/gui.py as a thin shim that imports/forwards into the new package (i.e., keep file mkpfs/gui.py that from . import main)? Or is it acceptable to replace mkpfs/gui.py with the package directory mkpfs/gui/ so importers will get the package instead? (Replacing the file with a package is normal but may affect tools that imported the file path directly.) + - Entry points and packaging: should I update any packaging metadata or console script references now (setup.cfg/pyproject entry_points) to point to the new main location if necessary, or leave packaging files untouched and only ensure mkpfs.gui exposes main()? (You requested preserving packaging; I will not edit setup/pyproject unless you explicitly ask.) + - Tests and linting: do you want accompanying unit tests or flake/ruff formatting changes for the new modules in this step, or do you prefer only the refactor with no test additions? + - Granularity of code moves: should I keep the exact order and grouping of top-level constants (e.g., _TRANSLATIONS, theme colours) in the same module they are now, or is moving them to dedicated modules acceptable as long as identifiers remain unchanged? + +EXTERNAL-DEPENDENCIES + - CustomTkinter CustomTkinter GUI library for modern-themed Tkinter widgets + - Pillow PIL / Pillow image library used via from PIL import Image, ImageTk + - tkinter Python standard library Tk GUI toolkit (tkinter filedialog and core event loop) + +## verified tooling + +uv run ruff format . +uv run ruff check . --fix +./run-tests.sh +uv run --frozen pytest +uv build + +## research + +FILES +(degraded: research FILES worker timed out after restarts; this section may be incomplete) + +APIS +_TRANSLATIONS dict[str, dict[str, str]] — translations mapping used by tr() +_LANG_NAMES dict[str, str] — human-readable language names +_current_locale str — active locale key (updated by language selector) +tr function(key: str) -> str — return translated string for key using _current_locale/_TRANSLATIONS +_BG_DEEP str — theme/background colour constant +_BG_PANEL str — theme/background colour constant +_BG_CARD str — theme/background colour constant +_BG_INPUT str — theme/input background colour constant +_BORDER_BRIGHT str — theme/border colour constant +_TEXT_PRIMARY str — primary text colour constant +_TEXT_SECONDARY str — secondary text colour constant +_TEXT_MUTED str — muted text colour constant +_NEON_BLUE str — neon accent colour (pack_folder) +_NEON_CYAN str — neon accent colour (pack_file) +_NEON_GREEN str — neon accent colour (verify) +_NEON_PURPLE str — neon accent colour (inspect) +_NEON_AMBER str — neon accent colour (tree) +_NEON_PINK str — neon accent colour (unpack) +_SUCCESS str — semantic success colour (alias of _NEON_GREEN) +_ERROR str — semantic error colour +_WARNING str — semantic warning colour (alias of _NEON_AMBER) +_SIDEBAR_W int — sidebar width constant +_CORNER int — corner radius constant +_FONT_MONO tuple — monospace font tuple +_FONT_UI tuple — UI font tuple +_FONT_LABEL tuple — label font tuple +_FONT_SMALL tuple — small font tuple +_PANEL_ACCENT dict[str, str] — per-panel accent colour mapping +_browse_folder function(var: ctk.StringVar) -> None — open folder chooser and set var +_browse_file function(var: ctk.StringVar, filetypes: list[tuple[str,str]]|None=None) -> None — open file chooser and set var +_browse_save function(var: ctk.StringVar, filetypes: list[tuple[str,str]]|None=None) -> None — open save dialog and set var +GlassCard class(parent, accent: str = _BORDER_BRIGHT, **kwargs) — dark rounded card frame with neon border +SectionLabel class(parent, text: str, color: str = _NEON_BLUE, **kwargs) — small neon-coloured section header label +PathRow class(parent, label: str, variable: ctk.StringVar, mode: str = "folder", filetypes: list[tuple[str,str]]|None = None, placeholder: str = "", browse_label: str = "Browse") — labelled path input row with Browse button +LogPane class(parent, **kwargs) — scrollable monospace log output pane with append/clear/get_text +NeonButton class(parent, text: str, command: Any, color: str = _NEON_BLUE, **kwargs) — primary action button with neon colour; method set_label(text: str) +OptionRow class(parent, label: str, variable: ctk.StringVar, values: list[str], accent: str = _NEON_BLUE) — labelled option menu row +NeonCheckbox class(parent, text: str, variable: ctk.BooleanVar, accent: str = _NEON_BLUE, **kwargs) — checkbox with neon accent +NavButton class(parent, text: str, command: Any, accent: str = _NEON_BLUE, **kwargs) — sidebar nav button; method set_active(active: bool) +BasePanel class(ctk.CTkFrame) — abstract base for operation panels with methods: refresh_labels(), _build_controls(card: GlassCard), _run_command(), _on_run(), _worker(), _poll_log_queue(), _on_export_log(), _emit(text: str, tag: str = ""), _run_mkpfs(args: list[str]) (preserve name & behavior) +BasePanel._run_mkpfs method(args: list[str]) -> None — run mkpfs CLI in-process, stream output to log pane, patch builtins.input to auto-confirm +PackFolderPanel class(BasePanel) — panel for "pack folder" flow (keeps name/signature) +PackFilePanel class(BasePanel) — panel for "pack file" flow (keeps name/signature) +VerifyPanel class(BasePanel) — panel for "verify" flow (keeps name/signature) +InspectPanel class(BasePanel) — panel for "inspect" flow (keeps name/signature) +TreePanel class(BasePanel) — panel for "tree" flow (keeps name/signature) +UnpackPanel class(BasePanel) — panel for "unpack" flow (keeps name/signature) +MkPFSApp class(ctk.CTk) — main application window; must preserve _PAGES classvar and UI behaviour +MkPFSApp._PAGES ClassVar[list[tuple[str, str, type, str]]] — list of pages (nav key, label key, Panel class, accent) — preserve exactly +MkPFSApp._set_window_icon method() -> None — load assets/images/icon.png using same candidate-path logic (preserve candidates list and behaviour) +MkPFSApp._build_sidebar method() -> None — build left navigation and language selector +MkPFSApp._build_content method() -> None — instantiate all panels +MkPFSApp._select method(key: str) -> None — switch visible panel +MkPFSApp._on_lang_change method(display_name: str) -> None — update _current_locale and refresh labels +MkPFSApp._refresh_all_labels method() -> None — propagate translations to sidebar and panels +main function() -> None — launch GUI (creates MkPFSApp and mainloop) +mkpfs.cli.cli_mkpfs_main function(argv: list[str] | None = None) -> int — CLI entry used by BasePanel._run_mkpfs (must be called in-process and its return code handled) + +CONTEXT +- The GUI file defines module-level translation state and API that must remain identically named and reachable from mkpfs.gui: the dicts _TRANSLATIONS and _LANG_NAMES, the mutable string _current_locale, and the function tr(key) (which imports mkpfs.__version__ at call time). +- ctk.set_appearance_mode("dark") and ctk.set_default_color_theme("blue") are executed at module import time (side effects on CustomTkinter) and must still run when mkpfs.gui is imported. +- Many UI visual constants and names are module-level identifiers referenced throughout the code and by widget methods: _BG_DEEP, _BG_PANEL, _BG_CARD, _BG_INPUT, _BORDER_BRIGHT, _TEXT_PRIMARY, _TEXT_SECONDARY, _TEXT_MUTED, _NEON_* colours, _SUCCESS, _ERROR, _WARNING, _SIDEBAR_W, _CORNER, _FONT_MONO, _FONT_UI, _FONT_LABEL, _FONT_SMALL, and _PANEL_ACCENT — these identifiers must remain present and have unchanged values or references. +- Several widget and panel classes rely on those module-level constants; public class names and signatures that must be preserved exactly are: MkPFSApp, BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, TreePanel._panel_key values (e.g., "pack_folder") must still match keys in _PANEL_ACCENT. +- BasePanel implements time/thread coupling that must be preserved: it creates a queue.Queue _log_queue, starts background threads via threading.Thread(target=self._worker, daemon=True), polls the queue using self.after(80, self._poll_log_queue), and uses self.after(100, ...) in __init__ to start polling; these scheduling/threading semantics must remain identical. +- BasePanel._run_mkpfs behavior is tightly coupled and must be preserved line-for-line: late import "from mkpfs.cli import cli_mkpfs_main", catching ImportError to emit a specific error and the hint "Ensure cryptography is installed: pip install cryptography", emitting the command line "$ mkpfs ...", redirecting stdout/stderr with contextlib.redirect_stdout/redirect_stderr to a custom _Streamer TextIO that buffers text and emits per-line to self._emit with tag logic (error/warning/success), patching builtins.input to auto-confirm with "y", handling SystemExit and exceptions, and finally emitting an empty line then tr("ok") or tr("err_process"). Tests or runtime behavior depend on these exact flows. +- The _Streamer.write logic classifies lines for colour tags using exact keyword tests: "error" in lower → "error"; "warning" in lower → "warning"; any of ("done","complete","success","\u2713") in lower → "success"; and ignores empty/blank lines. That line-tagging behavior must be preserved. +- MkPFSApp._PAGES is a ClassVar list of tuples exactly as declared and used to create both the sidebar NavButtons and the panel instances in _build_content; the order, keys, label-key strings, Panel classes, and accent colour values must remain unchanged. +- MkPFSApp._set_window_icon builds a candidates list using Path(__file__) relative paths in this exact ordering: Path(__file__).parent.parent / "assets" / "images" / "icon.png", Path(__file__).parent / ".." / "assets" / "images" / "icon.png", and Path.cwd() / "assets" / "images" / "icon.png"; the candidate ordering and path expressions are relied upon and must remain the same (moving code to a different __file__ location will change resolved candidates unless the shim/packaging preserves the original module-file location or the function is kept at a module whose __file__ resolves equivalently). +- The entrypoint in pyproject.toml defines the console script mkpfs-gui = "mkpfs.gui:main" and the package is invoked via python -m mkpfs.gui in documentation and README; therefore mkpfs.gui must continue to expose main() at import-time and must be runnable as a module (i.e., python -m mkpfs.gui must call multiprocessing.freeze_support() and then main()). +- The current file includes an if __name__ == "__main__" guard that calls multiprocessing.freeze_support() before main(); that freeze_support invocation is required for correct behaviour when freezing with PyInstaller on Windows and must be preserved for python -m mkpfs.gui or __main__. +- The translation dropdown and language-change flow mutate the module-level _current_locale (MkPFSApp._on_lang_change sets the global _current_locale) and then calls _refresh_all_labels/refresh_labels — the global mutable locale MUST remain the same object so tr() sees updates. +- Several UI helper functions at module level are used by widgets: _browse_folder, _browse_file, _browse_save — they import and use tkinter.filedialog; these helpers must remain available to PathRow/_browse and retain the same dialog behaviour and argument signatures. +- The code depends on tkinter.filedialog being imported from the tkinter top-level (from tkinter import filedialog) at module import time, so the refactor must keep that import accessible to the functions that call filedialog (or re-export them exactly). +- The code emits specific strings used in UI validation and error messages (e.g., translatable keys like "pf_err_src", "pf_err_out", "pkf_err", "v_err", "i_err", "t_err", "u_err") and UI placeholders; the _TRANSLATIONS keys and literal strings must remain verbatim and in the same identifiers. +- The GUI module currently performs late imports of PIL (Image, ImageTk) and customtkinter at top-level; these imports and their namespaces (Image, ImageTk, ctk) are referenced throughout and must remain available where used. +- Some external references in repository metadata and CI reference the file path mkpfs/gui.py explicitly (e.g., .github/workflows/* mention mkpfs/gui.py and README examples show python -m mkpfs.gui and list mkpfs\gui.py); packaging and CI currently expect the mkpfs.gui module entry point to be stable — do not change packaging entrypoints without an explicit packaging update step. +- Public API names used by other code/tests must be importable directly from mkpfs.gui (i.e., any refactor must re-export these names at the mkpfs.gui import path so "from mkpfs.gui import MkPFSApp, tr, main, _TRANSLATIONS, _LANG_NAMES, _current_locale, BasePanel._run_mkpfs" continue to work). +- The UI's export-log code writes either text or JSON depending on filename extension and emits "Export failed: {exc}" on OSError — these exact behaviours and message patterns should be preserved (export dialog call and file handling). +- The panels rely on concrete widget classes and layout flows: BasePanel.refresh_labels destroys and recreates self._card and calls _build_controls again; this rebuild pattern and reliance on pack(before=...) ordering must be preserved to avoid layout regressions. +- The refactor must not change import paths used by non-GUI code (e.g., mkpfs.cli and other mkpfs.* modules) — BasePanel._run_mkpfs imports mkpfs.cli by name and expects the same module path. + +VERIFIED-TOOLING + uv run ruff format . + uv run ruff check . --fix + ./run-tests.sh + uv run --frozen pytest + uv build + +## grill Q&A + +Q1: Should I preserve the original single-file mkpfs/gui.py as a thin shim that imports/forwards into the new mkpfs/gui package (so Path(__file__) and asset candidate paths remain identical), or is it acceptable to replace mkpfs/gui.py with the new package directory (which will change the module __file__ location and thus the resolved icon candidate paths)? +A1: Preserve the original single-file mkpfs/gui.py as a thin shim that imports and re-exports the new package (keeping the original __file__ location so the asset candidate Path(...) logic and import-side effects remain unchanged). (auto) +Q2: Is the proposed module breakdown acceptable as-is: mkpfs/gui/__init__.py, mkpfs/gui/__main__.py, mkpfs/gui/app.py (MkPFSApp), mkpfs/gui/panels.py (BasePanel + all panel classes), mkpfs/gui/widgets.py (GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton), mkpfs/gui/i18n.py (tr, _TRANSLATIONS, _LANG_NAMES, _current_locale), and mkpfs/gui/theme.py (theme constants and helpers) — or would you prefer different filenames or a different split (for example one module per Panel)? +A2: Accept the proposed breakdown (mkpfs/gui/{__init__.py,__main__.py,app.py,panels.py,widgets.py,i18n.py,theme.py}) but also keep a thin top-level mkpfs/gui.py shim that re-exports the public names and main() so the original mkpfs/gui.py __file__ semantics (asset candidate paths, python -m mkpfs.gui, and import surface) are preserved. (auto) + +## spec + +GOAL + Refactor the single-file GUI implementation at mkpfs/gui.py into a modular package mkpfs/gui/ made of app.py, panels.py, widgets.py, i18n.py, theme.py, __init__.py, __main__.py plus a thin shim file mkpfs/gui.py that re-exports the public API. The runtime API, import-time side effects, module-level identifiers, and user-visible behaviors must be preserved exactly so existing consumers and documented invocations (import mkpfs.gui, python -m mkpfs.gui, and the mkpfs-gui console entrypoint) continue to work unchanged. + +CONSTRAINTS + - Preserve every public class/function name and signature exactly: MkPFSApp, BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton, tr, main, and BasePanel._run_mkpfs (and its exact behavior). + - Preserve module-level translation state and API exactly: _TRANSLATIONS (dict), _LANG_NAMES (dict), and the mutable string _current_locale; tr() must continue to read/use those globals and import mkpfs.__version__ at call time as before. + - Preserve theme constant identifiers and their export at mkpfs.gui top-level: _BG_DEEP, _BG_PANEL, _BG_CARD, _BG_INPUT, _BORDER_BRIGHT, _TEXT_PRIMARY, _TEXT_SECONDARY, _TEXT_MUTED, _NEON_BLUE, _NEON_CYAN, _NEON_GREEN, _NEON_PURPLE, _NEON_AMBER, _NEON_PINK, _SUCCESS, _ERROR, _WARNING, _SIDEBAR_W, _CORNER, _FONT_MONO, _FONT_UI, _FONT_LABEL, _FONT_SMALL, and _PANEL_ACCENT. + - Preserve helper functions exactly and exported at top-level: _browse_folder(var), _browse_file(var, filetypes=None), _browse_save(var, filetypes=None). They must continue to use tkinter.filedialog as before. + - Preserve CustomTkinter/tkinter import-side effects at module import time: calls to ctk.set_appearance_mode("dark") and ctk.set_default_color_theme("blue") must execute when importing mkpfs.gui (the shim), not delayed. + - Preserve BasePanel threading, queue and scheduling semantics exactly: creation of queue.Queue _log_queue, thread start with threading.Thread(target=self._worker, daemon=True), and the same self.after scheduling intervals used in __init__ (after(80, ...), after(100, ...)) must be unchanged. + - Preserve BasePanel._run_mkpfs exact flow and emitted text semantics line-for-line: the late import of mkpfs.cli.cli_mkpfs_main, the ImportError hint, emitting the literal "$ mkpfs ..." line, use of contextlib.redirect_stdout/redirect_stderr with the same _Streamer classification logic (classification based on the same substring tests), patching builtins.input to auto-confirm with "y", catching SystemExit and other exceptions, emitting an empty line then tr("ok") or tr("err_process") and writing the same tags/text to the log pane. + - Preserve MkPFSApp._PAGES classvar exactly: it must exist and contain the same ordering, keys, label-key strings, Panel classes, and accent values as before. Panel ordering must match the original ordering of panel classes. + - Preserve MkPFSApp._set_window_icon candidate Path expressions and ordering exactly: Path(__file__).parent.parent / "assets" / "images" / "icon.png", Path(__file__).parent / ".." / "assets" / "images" / "icon.png", Path.cwd() / "assets" / "images" / "icon.png". Because these expressions rely on the importing module's __file__, the thin shim mkpfs/gui.py must remain at the original path so those expressions evaluate identically. + - Preserve top-level import-time side effects (PIL imports, tkinter.filedialog import, CustomTkinter calls) and make those symbols reachable at the same call sites. + - Do not change any code outside the GUI slice (no edits to CLI, core mkpfs modules, packaging metadata, or other project files). + - Do not add new runtime dependencies; keep using CustomTkinter, Pillow, tkinter only. + - Keep all translation keys and literal strings verbatim. + - Preserve packaging/entrypoint surface: mkpfs.gui must be importable, expose main() at top-level, and python -m mkpfs.gui must still be supported. Do not alter packaging metadata in this step. + - Use exactly the agreed layout: mkpfs/gui/{__init__.py,__main__.py,app.py,panels.py,widgets.py,i18n.py,theme.py} plus the shim file mkpfs/gui.py at repo root that re-exports the public names so its __file__ remains the original shim path. + +ACCEPTANCE + - The repository contains the package directory mkpfs/gui/ with the modules app.py, panels.py, widgets.py, i18n.py, theme.py, __init__.py, __main__.py and also keeps the thin shim file mkpfs/gui.py at the original path. Importing mkpfs.gui returns a module whose __file__ is the shim file (mkpfs/gui.py). + - All previously-public names are importable from mkpfs.gui and retain identical names and call signatures: MkPFSApp, BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, GlassCard, SectionLabel, PathRow, LogPane, NeonButton, OptionRow, NeonCheckbox, NavButton, tr, main, _TRANSLATIONS, _LANG_NAMES, _current_locale, and BasePanel._run_mkpfs. + - Importing mkpfs.gui at module import time executes the same CustomTkinter side effects (ctk.set_appearance_mode("dark") and ctk.set_default_color_theme("blue")) as before. + - MkPFSApp._PAGES exists as a class variable, is ordered, and contains Panel classes in the same ordering (PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel) with keys/label strings and accents present as before. + - MkPFSApp._set_window_icon uses the three specified Path expressions computed from Path(__file__) in the same order (parent.parent, parent / "..", Path.cwd()). + - BasePanel._run_mkpfs continues to call mkpfs.cli.cli_mkpfs_main in-process, uses the same _Streamer line-tagging substrings, patches builtins.input to auto-confirm with "y", handles SystemExit, and emits the same final tr("ok")/tr("err_process") messages. + - tr() continues to reference _TRANSLATIONS, _LANG_NAMES, _current_locale and performs an import/read of mkpfs.__version__ at call time (the function's source contains an import/reference to __version__). + - The thin shim preserves the import-time behavior and file path semantics relied on for asset resolution. + - Linting, tests and build tasks in the repo complete without errors introduced by the refactor: ruff (via uv), pytest (via uv), ./run-tests.sh and uv build commands succeed. + - No modifications outside the GUI slice are present in the commit/working-tree (only files under mkpfs/gui/ and the shim mkpfs/gui.py may be modified). + +VERIFY: +```sh +# Run a comprehensive verification script that: +# - stubs GUI dependencies to assert import-time side effects, +# - asserts the shim __file__ is the repo shim (mkpfs/gui.py), +# - verifies exported names, theme constants, helper functions and that these +# helpers still call tkinter.filedialog, +# - inspects BasePanel._run_mkpfs source for the exact required tokens/flow, +# - inspects tr() source to ensure it references translations and __version__, +# - verifies MkPFSApp._PAGES panel ordering and MkPFSApp._set_window_icon source uses the specified Path expressions, +# - executes the package as -m (runpy.run_module) with safe stubs to validate the __main__ entry path, +# - checks ruff/pytest/run-tests.sh and ensures no changes outside GUI slice are present in git status. +python - <<'PY' +import sys, types, importlib, inspect, runpy +from pathlib import Path + +# Prepare stubs for import-time interception +ctk = types.ModuleType("customtkinter") +_ctk_called = {"appearance": False, "theme": False} +def set_appearance_mode(mode): + _ctk_called["appearance"] = mode +def set_default_color_theme(theme): + _ctk_called["theme"] = theme +ctk.set_appearance_mode = set_appearance_mode +ctk.set_default_color_theme = set_default_color_theme +# Minimal CTk class to avoid GUI blocking if main() constructs it +class _CTk: + def __init__(self, *a, **k): pass + def mainloop(self): pass + def withdraw(self): pass +ctk.CTk = _CTk +# Provide minimal widget names that import sites may reference at import time +for nm in ("CTkFrame","CTkLabel","CTkButton","CTkEntry","CTkCanvas"): + setattr(ctk, nm, type(nm, (), {})) + +# Stub tkinter and filedialog to verify _browse_* functions call filedialog +tk = types.ModuleType("tkinter") +filedialog = types.ModuleType("tkinter.filedialog") +_calls = {"askdirectory": False, "askopenfilename": False, "asksaveasfilename": False} +def askdirectory(**kwargs): + _calls["askdirectory"] = True + return "/tmp/fakedir" +def askopenfilename(**kwargs): + _calls["askopenfilename"] = True + return "/tmp/fakefile.txt" +def asksaveasfilename(**kwargs): + _calls["asksaveasfilename"] = True + return "/tmp/fakesave.txt" +filedialog.askdirectory = askdirectory +filedialog.askopenfilename = askopenfilename +filedialog.asksaveasfilename = asksaveasfilename +tk.filedialog = filedialog +# Provide a minimal StringVar-like object if GUI code creates one at import time +class _StringVar: + def __init__(self): self._v = "" + def set(self, v): self._v = v + def get(self): return self._v +tk.StringVar = _StringVar +tk.Tk = type("Tk", (), {"mainloop": lambda self: None}) + +# Minimal PIL stubs +PIL = types.ModuleType("PIL") +PIL_Image = types.ModuleType("PIL.Image") +PIL_ImageTk = types.ModuleType("PIL.ImageTk") +# PhotoImage stub +class _PhotoImage: pass +PIL_ImageTk.PhotoImage = _PhotoImage +PIL_Image.open = lambda *a, **k: object() +sys.modules.update({ + "customtkinter": ctk, + "tkinter": tk, + "tkinter.filedialog": filedialog, + "PIL": PIL, + "PIL.Image": PIL_Image, + "PIL.ImageTk": PIL_ImageTk, +}) + +# Now import the shim module mkpfs.gui and run checks +m = importlib.import_module("mkpfs.gui") +print("MODULE_FILE:", m.__file__) + +# Assert shim path resolves to repo shim mkpfs/gui.py +expected_shim = Path.cwd() / "mkpfs" / "gui.py" +try: + assert Path(m.__file__).samefile(expected_shim), f"m.__file__ ({m.__file__}) is not the shim {expected_shim}" +except FileNotFoundError: + # If file doesn't exist, still fail + raise AssertionError(f"shim file not found: expected {expected_shim}, got {m.__file__}") + +# Check import-time CustomTkinter side effects were called +assert _ctk_called["appearance"] == "dark", "ctk.set_appearance_mode('dark') was not called at import time" +assert _ctk_called["theme"] == "blue", "ctk.set_default_color_theme('blue') was not called at import time" +print("CustomTkinter import-time side effects detected") + +# Verify expected public names exist +expected_names = [ + "MkPFSApp","BasePanel","PackFolderPanel","PackFilePanel","VerifyPanel","InspectPanel", + "TreePanel","UnpackPanel","GlassCard","SectionLabel","PathRow","LogPane","NeonButton", + "OptionRow","NeonCheckbox","NavButton","tr","main","_TRANSLATIONS","_LANG_NAMES","_current_locale" +] +missing = [n for n in expected_names if not hasattr(m, n)] +if missing: + raise SystemExit(f"Missing expected exports at mkpfs.gui top-level: {missing}") +print("All expected public names present") + +# Verify theme constants are exported at top-level +theme_consts = [ + "_BG_DEEP","_BG_PANEL","_BG_CARD","_BG_INPUT","_BORDER_BRIGHT", + "_TEXT_PRIMARY","_TEXT_SECONDARY","_TEXT_MUTED","_NEON_BLUE","_NEON_CYAN", + "_NEON_GREEN","_NEON_PURPLE","_NEON_AMBER","_NEON_PINK","_SUCCESS","_ERROR", + "_WARNING","_SIDEBAR_W","_CORNER","_FONT_MONO","_FONT_UI","_FONT_LABEL","_FONT_SMALL","_PANEL_ACCENT" +] +missing_consts = [c for c in theme_consts if not hasattr(m, c)] +if missing_consts: + raise SystemExit(f"Missing theme constants at mkpfs.gui top-level: {missing_consts}") +print("Theme constants exported") + +# Verify helper browse functions exist and use tkinter.filedialog +assert hasattr(m, "_browse_folder") and hasattr(m, "_browse_file") and hasattr(m, "_browse_save"), "_browse_* helpers missing" +class DummyVar: + def __init__(self): self.v = None + def set(self, val): self.v = val +dv = DummyVar() +# Call each helper and assert our filedialog stub was invoked and the var set +m._browse_folder(dv) +assert _calls["askdirectory"], "_browse_folder did not call tkinter.filedialog.askdirectory" +assert dv.v == "/tmp/fakedir" +dv = DummyVar() +m._browse_file(dv, filetypes=None) +assert _calls["askopenfilename"], "_browse_file did not call tkinter.filedialog.askopenfilename" +assert dv.v == "/tmp/fakefile.txt" +dv = DummyVar() +m._browse_save(dv, filetypes=None) +assert _calls["asksaveasfilename"], "_browse_save did not call tkinter.filedialog.asksaveasfilename" +assert dv.v == "/tmp/fakesave.txt" +print("_browse_* helpers call tkinter.filedialog as required") + +# Verify MkPFSApp._PAGES exists and panel ordering matches expectations +MkPFSApp = getattr(m, "MkPFSApp") +assert hasattr(MkPFSApp, "_PAGES"), "MkPFSApp._PAGES missing" +_PAGES = MkPFSApp._PAGES +# Expect at least the panel classes in this order +expected_panel_names = ["PackFolderPanel","PackFilePanel","VerifyPanel","InspectPanel","TreePanel","UnpackPanel"] +# _PAGES is expected to be a sequence of (key, label_key, PanelClass, accent) +panel_sequence = [entry[2].__name__ for entry in _PAGES] +if panel_sequence[:len(expected_panel_names)] != expected_panel_names: + raise SystemExit(f"MkPFSApp._PAGES panel ordering mismatch. Found: {panel_sequence}") +print("MkPFSApp._PAGES ordering matches expected panel class names") + +# Verify MkPFSApp._set_window_icon source contains the specific Path expressions +src_app = inspect.getsource(MkPFSApp) +if "Path(__file__).parent.parent" not in src_app or 'parent / ".."' not in src_app and 'parent / ".."' not in src_app: + # try to find the specific method source if not in class blob + try: + pfunc = getattr(MkPFSApp, "_set_window_icon") + s = inspect.getsource(pfunc) + except Exception: + s = src_app +else: + s = src_app +required_path_tokens = ["Path(__file__).parent.parent", 'Path(__file__).parent / ".."', "Path.cwd()"] +for tok in required_path_tokens: + if tok not in s: + raise SystemExit(f"_set_window_icon source does not contain required token: {tok}") +print("MkPFSApp._set_window_icon uses required Path(...) expressions") + +# Inspect BasePanel._run_mkpfs source for exact required tokens/flows +BasePanel = getattr(m, "BasePanel") +run_src = inspect.getsource(BasePanel._run_mkpfs) +# Check for late import of cli_mkpfs_main +if "from mkpfs.cli import cli_mkpfs_main" not in run_src and "import mkpfs.cli" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not perform late import of mkpfs.cli.cli_mkpfs_main") +# Check for "$ mkpfs " literal emission +if '"$ mkpfs' not in run_src and "'$ mkpfs" not in run_src: + raise SystemExit('BasePanel._run_mkpfs does not emit literal "$ mkpfs ..."') +# Check presence of _Streamer classification substrings and class +if "_Streamer" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not define or reference _Streamer") +# Check classification substrings (error, done/complete/success/✓) +for tok in ('"error"', "'error'", "done", "complete", "success", "\u2713"): + if tok not in run_src: + # some tokens may appear unquoted; only warn if both variants missing for the core ones + if tok in ("done","complete","success","\u2713") and all(k not in run_src for k in ("done","complete","success","\u2713")): + raise SystemExit("BasePanel._run_mkpfs _Streamer does not reference completion substrings (done/complete/success/✓)") + if tok == '"error"' and '"error"' not in run_src and "'error'" not in run_src: + raise SystemExit("BasePanel._run_mkpfs _Streamer does not reference 'error'") +# Check builtins.input patching and auto-confirm "y" +if "builtins.input" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not patch builtins.input") +if '"y"' not in run_src and "'y'" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not appear to auto-confirm with 'y'") +# Verify final tr('ok') / tr('err_process') emissions exist +if "tr(\"ok\")" not in run_src and "tr('ok')" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not emit tr('ok') on success") +if "tr(\"err_process\")" not in run_src and "tr('err_process')" not in run_src: + raise SystemExit("BasePanel._run_mkpfs does not emit tr('err_process') on error") +print("BasePanel._run_mkpfs source contains required tokens and flow markers") + +# Verify tr() source references _TRANSLATIONS/_LANG_NAMES/_current_locale and imports __version__ +tr_src = inspect.getsource(m.tr) +if "_TRANSLATIONS" not in tr_src or "_current_locale" not in tr_src: + raise SystemExit("tr() source does not reference _TRANSLATIONS/_current_locale/_LANG_NAMES as required") +if "__version__" not in tr_src: + raise SystemExit("tr() source does not import or reference mkpfs.__version__ at call time") +print("tr() source references translations and __version__") + +# Verify __main__ contains multiprocessing.freeze_support() and the main guard +try: + mod_main = importlib.import_module("mkpfs.gui.__main__") + main_src = inspect.getsource(mod_main) +except Exception: + # If __main__ is the shim file, locate the __main__.py in package and inspect + import pkgutil, io + p = Path.cwd() / "mkpfs" / "gui" / "__main__.py" + if not p.exists(): + raise SystemExit("mkpfs.gui.__main__ not found") + main_src = p.read_text() +if "freeze_support" not in main_src: + raise SystemExit("__main__ does not call multiprocessing.freeze_support()") +if 'if __name__ == "__main__"' not in main_src: + raise SystemExit("__main__ guard missing in mkpfs.gui.__main__") +print("__main__ contains freeze_support() and guard") + +# Simulate 'python -m mkpfs.gui' execution with our safe stubs to ensure the -m entry path executes without launching a real GUI +import runpy +try: + runpy.run_module("mkpfs.gui", run_name="__main__") +except SystemExit: + # allow SystemExit if __main__ calls sys.exit under test conditions, but must not raise unexpected exceptions + pass +except Exception as e: + raise SystemExit(f"Running mkpfs.gui as __main__ raised an exception: {e}") +print("Run as -m mkpfs.gui (runpy) executed without exceptions using safe stubs") + +print("All Python-level checks passed") +PY + +# Linting, tests and build tasks (as required by acceptance) +# If the repository uses 'uv' to run tasks as in the original spec, run them; otherwise ruff/pytest directly +set -e +uv run ruff check . --fix || ruff check . --fix +uv run --frozen pytest || pytest -q +if [ -x "./run-tests.sh" ]; then + ./run-tests.sh +fi + +# Ensure no changes outside the GUI slice are present in working tree (only files under mkpfs/gui/ or mkpfs/gui.py allowed) +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + # Consider both staged and unstaged changes; untracked files are allowed but will be reported + mapfile -t changed < <(git status --porcelain --untracked-files=no | awk '{print $2}') + bad=() + for f in "${changed[@]}"; do + case "$f" in + mkpfs/gui/*|mkpfs/gui.py) ;; # allowed + *) bad+=("$f") ;; + esac + done + if [ "${#bad[@]}" -ne 0 ]; then + echo "ERROR: Files modified outside GUI slice:" + printf '%s\n' "${bad[@]}" + exit 4 + else + echo "Git working-tree changes are limited to the GUI slice (ok)" + fi +else + echo "Not a git repo; skipping working-tree scope check" +fi + +echo "VERIFY COMPLETE" +``` + +## phase timings + +(no phases recorded) + +## handoff + +handoff_at: 2026-07-02T01:37:44.182Z + diff --git a/.pi-tasks/TASK_0003.md b/.pi-tasks/TASK_0003.md new file mode 100644 index 0000000..695a19e --- /dev/null +++ b/.pi-tasks/TASK_0003.md @@ -0,0 +1,176 @@ +--- +id: TASK_0003 +state: completed +phase: done +created_at: 2026-07-02T01:59:53.878Z +updated_at: 2026-07-02T11:14:26.292Z +title: Implement dynamic command navigation and option forms inside the existing GUI so the left sidebar and central panel are built from MkPFS command metadata at runtime: on startup try to import the local `mkpfs` package for a metadata API (fall back to parsing the textual output of `mkpfs --help` if import metadata is unavailable), then render one sidebar entry per CLI subcommand and a generated options form for the selected command using the existing widget primitives (PathRow, OptionRow, NeonCheckbox, etc.). Selecting a command shows a form with appropriately-typed controls (flags → checkboxes, choices → OptionRow, path-like args → PathRow with file/folder dialogs, textual/numeric args → CTkEntry). The Run button should construct a canonical argv list and invoke the same in-process entry used today (`mkpfs.cli.cli_mkpfs_main`) via the existing `_run_mkpfs` mechanism so behavior is unchanged. If the `mkpfs` package exposes the small shared API (see CONSTRAINTS), use it to prefill defaults such as recommended output filename and subscribe to progress events when available; otherwise use conservative defaults derived from the parsed help text. Keep using CustomTkinter / tkinter and the existing visual components and translations (`tr()`). +label: MkPFS GUI: dynamic CLI command sidebar and generated options form +reason: grill-gen child failed: No API key found for digitalocean. +--- + +## raw prompt + +Build dynamic command navigation and option forms | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): discover commands/options dynamically by first importing the mkpfs package and fall back to parsing mkpfs --help if import metadata isn't available; allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events; keep CustomTkinter/tkinter to minimize scope + +## refined prompt + +GOAL + Implement dynamic command navigation and option forms inside the existing GUI so the left sidebar and central panel are built from MkPFS command metadata at runtime: on startup try to import the local `mkpfs` package for a metadata API (fall back to parsing the textual output of `mkpfs --help` if import metadata is unavailable), then render one sidebar entry per CLI subcommand and a generated options form for the selected command using the existing widget primitives (PathRow, OptionRow, NeonCheckbox, etc.). Selecting a command shows a form with appropriately-typed controls (flags → checkboxes, choices → OptionRow, path-like args → PathRow with file/folder dialogs, textual/numeric args → CTkEntry). The Run button should construct a canonical argv list and invoke the same in-process entry used today (`mkpfs.cli.cli_mkpfs_main`) via the existing `_run_mkpfs` mechanism so behavior is unchanged. If the `mkpfs` package exposes the small shared API (see CONSTRAINTS), use it to prefill defaults such as recommended output filename and subscribe to progress events when available; otherwise use conservative defaults derived from the parsed help text. Keep using CustomTkinter / tkinter and the existing visual components and translations (`tr()`). + +CONSTRAINTS + - Do not change the top-level CLI surface or argument names: preserve existing commands and option identifiers (e.g., `pack folder`, `pack file`, `verify`, `inspect`, `tree`, `unpack`) and the semantics of flags and option names as exposed by the `mkpfs` package or its `--help` output. + - Preserve existing GUI mechanics that run mkpfs in-process: continue to call `mkpfs.cli.cli_mkpfs_main` via the existing `_run_mkpfs` helper; do not replace in-process execution with a subprocess unless the import-based discovery fails and fallback parsing requires invoking `mkpfs --help` externally. + - Keep using CustomTkinter / tkinter and the current widget classes and styles (GlassCard, PathRow, OptionRow, NeonCheckbox, NeonButton, NavButton, BasePanel, MkPFSApp, etc.). Do not introduce a new GUI framework or rewrite existing panels unrelated to this step. + - Do not implement or modify other plan steps (shared mkpfs metadata/progress hooks, package split, output naming mirroring, responsive/resizable window) beyond the minimal interactions needed to consume the small shared API if present. In particular, do not implement footer versioning, progress mirroring, or responsiveness here—only prepare to consume those via the API. + - Do not modify `cli_mkpfs_main` signature or change `_run_mkpfs` behavior; generated argv lists must be compatible with the current CLI parsing code. + - If the `mkpfs` package API is absent, the GUI must still work using help-text parsing fallback and must not require modifying the `mkpfs` package to run. + - Do not add new network or cloud dependencies; no external services should be called by this step. + +KNOWN-UNKNOWNS + - Metadata API shape: should I assume concrete function names and signatures in the `mkpfs` package for metadata, defaults, and progress hooks? Suggested names (for your approval) are: + - `mkpfs.gui.get_command_metadata() -> dict[str, CommandSpec]` where CommandSpec lists options with names, types (flag/choice/str/int/path), choices, default, help, and whether arg is required/positional. + - `mkpfs.gui.suggest_output_name(command: str, parsed_args: dict) -> str | None` + - `mkpfs.gui.subscribe_progress(callback: Callable[[ProgressEvent], None]) -> unsub` + Do you want those exact names/signatures, or different ones? + - Help-parsing details: when falling back to textual help, should the parser run `mkpfs --help` (global help) and `mkpfs --help` for per-command details, or only `mkpfs --help`? (I recommend per-command `mkpfs --help` for accurate option parsing.) + - Option-to-widget mapping rules: confirm rules for automatic widget selection (recommended default mapping: boolean flags -> NeonCheckbox; choices/enums -> OptionRow; file/dir-like option names or options with `path`/`file`/`dir` in their name -> PathRow with mode=open/save/folder inferred; short hex/key options (e.g., `--ekpfs-key`) -> monospace CTkEntry). Any exceptions or special names that must be handled specially (for example `--temp-folder`, `image_file`, `source_dir`, `output`)? + - Required vs optional and positional arguments: how should required positional args be presented in the form and validated before Run? Shall required positional fields be enforced client-side with the same error messages currently used (e.g., `tr("pf_err_src")`)? Are custom translation keys desired for generated labels, or should raw option names be shown and passed through `tr()` where a translation key exists? + - Advanced options visibility: do you want all discovered options shown by default, or should some be folded into an “Advanced” collapsible section? If folding is desired, which options are "advanced" (e.g., `--cpu-count`, `--compression-level`, `--max-compressed-ratio`, etc.)? + - Default output naming: when the shared API is present, should the GUI automatically prefill output PathRow fields using `mkpfs.gui.suggest_output_name(...)` and still allow users to override? Confirm behavior on ambiguous suggestions (e.g., multiple positional outputs): prefer the first suggestion. + - Progress events format: what fields does the progress event carry (percent, bytes, phase text)? If you have an expected ProgressEvent schema, provide it; otherwise I will design the GUI to accept a minimal event with (phase: str, percent: float, message: str) and gracefully ignore unknown fields. + - Backward compatibility for existing static panels: do you want to keep the current hard-coded panels (PackFolderPanel, PackFilePanel, etc.) as explicit fallbacks or replace them entirely with dynamically generated panels? (Default plan: keep them as fallbacks for older installs but show dynamic entries when metadata is available.) + - Testing / edge cases: how should the generator handle options with complex nargs (e.g., `--only PATH` repeatable)? Suggest exposing repeatable options as an add/remove list UI; confirm if that is acceptable. + +EXTERNAL-DEPENDENCIES + - CustomTkinter CustomTkinter Python GUI toolkit (ctk) for themed widgets and layout + - Pillow Pillow / PIL image handling for window icon and image widgets + +## verified tooling + +uvx ruff check --output-format=github . +uv run --frozen pytest +uv build + +## research + +FILES +mkpfs/gui.py Primary GUI shim — will be read and edited to implement dynamic sidebar, per-command option forms, and to consume discovery/metadata and preserve _run_mkpfs behavior. +mkpfs/cli.py CLI parser and implementations (cli_mkpfs_main, cli_mkpfs_main_parsers) — referenced to preserve exact command/option names and for import-based metadata discovery. +mkpfs/discovery.py Lightweight CLI discovery and progress registry (get_cli_metadata, cli_metadata, register_progress_handler, ProgressEvent) — primary API the GUI should call for import-based metadata and help-fallback. +mkpfs/__init__.py Package exports (re-exports get_cli_metadata, default_image_basename, register_progress_handler, ProgressEvent) — GUI will import package-level helpers from here. +mkpfs/gui/panels.py Compatibility re-export of panel classes — may be used as fallback/compat layer when keeping existing hard-coded panels. +mkpfs/gui/app.py Thin façade re-exporting MkPFSApp and main — referenced by entry points and packaging; keep consistent with modified gui.py. +mkpfs/gui/__init__.py Package initializer that loads the gui.py shim — explains import semantics and must be considered when importing mkpfs.gui or submodules. +pyproject.toml Project metadata and console-script names ("mkpfs", "mkpfs-gui") — referenced by help-fallback path resolution when discovery falls back to executing the CLI. +assets/images/icon.png Window icon file referenced and loaded by MkPFSApp._set_window_icon — GUI reads this at startup to set the app icon. + +APIS +mkpfs.discovery.get_cli_metadata get_cli_metadata(prefer_import: bool = True) -> dict (returns {"source": "import"|"help", "commands": {...}, "errors": [...]}) +mkpfs.discovery.cli_metadata cli_metadata: dict | None (module-level optional authoritative CLI metadata) +mkpfs.discovery.default_image_basename default_image_basename(source_root: pathlib.Path) -> str +mkpfs.discovery.normalize_output_path normalize_output_path(path_arg: str, desired_suffix: str, adjust: bool = True) -> tuple[pathlib.Path, bool] +mkpfs.discovery.register_progress_handler register_progress_handler(handler: Callable[[ProgressEvent], None]) -> Callable[[], None] (returns unregister function) +mkpfs.discovery.ProgressEvent dataclass ProgressEvent(phase: str, done: int, total: int, bytes_processed: int|None, timestamp: float) +mkpfs.cli.cli_mkpfs_main cli_mkpfs_main(argv: list[str] | None = None) -> int (primary in-process CLI entry used by the GUI) +mkpfs.gui.MkPFSApp class MkPFSApp(ctk.CTk) — main application window (constructs sidebar/content and drives panels) +mkpfs.gui.MkPFSApp._build_sidebar _build_sidebar(self) -> None (builds left navigation; will be adapted to dynamic command list) +mkpfs.gui.MkPFSApp._build_content _build_content(self) -> None (pre-instantiates panels; may be adapted to host dynamic panels) +mkpfs.gui.MkPFSApp._select _select(self, key: str) -> None (switch visible panel / nav button) +mkpfs.gui.tr tr(key: str) -> str (translation lookup for UI strings) +mkpfs.gui.BasePanel class BasePanel(ctk.CTkFrame) — panel base with logging, progress, run button and lifecycle hooks +mkpfs.gui.BasePanel._build_controls _build_controls(self, card: GlassCard) -> None (subclasses implement to create UI) +mkpfs.gui.BasePanel._run_command _run_command(self) -> None (subclasses implement operation execution) +mkpfs.gui.BasePanel._on_run _on_run(self) -> None (starts background worker and progress UI) +mkpfs.gui.BasePanel._emit _emit(self, text: str, tag: str = "") -> None (queue a log line for display) +mkpfs.gui.BasePanel._run_mkpfs _run_mkpfs(self, args: list[str]) -> None (streams CLI output by calling mkpfs.cli.cli_mkpfs_main in-process) +mkpfs.gui.GlassCard class GlassCard(ctk.CTkFrame)(parent, accent: str = _BORDER_BRIGHT, **kwargs) — styled rounded card container +mkpfs.gui.SectionLabel class SectionLabel(ctk.CTkLabel)(parent, text: str, color: str = _NEON_BLUE, **kwargs) — small neon header +mkpfs.gui.PathRow class PathRow(ctk.CTkFrame)(parent, label: str, variable: ctk.StringVar, mode: str = "folder", filetypes: list[tuple[str,str]]|None = None, placeholder: str = "", browse_label: str = "Browse") — labelled path input with Browse +mkpfs.gui.OptionRow class OptionRow(ctk.CTkFrame)(parent, label: str, variable: ctk.StringVar, values: list[str], accent: str = _NEON_BLUE) — labelled option menu +mkpfs.gui.NeonCheckbox class NeonCheckbox(ctk.CTkCheckBox)(parent, text: str, variable: ctk.BooleanVar, accent: str = _NEON_BLUE, **kwargs) — neon-styled checkbox +mkpfs.gui.NeonButton class NeonButton(ctk.CTkButton)(parent, text: str, command: Any, color: str = _NEON_BLUE, **kwargs) — primary neon action button +mkpfs.gui.NavButton class NavButton(ctk.CTkButton)(parent, text: str, command: Any, accent: str = _NEON_BLUE, **kwargs) with set_active(active: bool) -> None — sidebar nav button +mkpfs.gui.LogPane class LogPane(ctk.CTkFrame)(parent, **kwargs) — scrollable monospace log output pane with append/clear/get_text +mkpfs.gui.PackFolderPanel class PackFolderPanel(BasePanel) — existing pack folder panel (serves as fallback) +mkpfs.gui.PackFilePanel class PackFilePanel(BasePanel) — existing pack file panel (serves as fallback) +mkpfs.gui.VerifyPanel class VerifyPanel(BasePanel) — existing verify panel (serves as fallback) +mkpfs.gui.InspectPanel class InspectPanel(BasePanel) — existing inspect panel (serves as fallback) +mkpfs.gui.TreePanel class TreePanel(BasePanel) — existing tree panel (serves as fallback) +mkpfs.gui.UnpackPanel class UnpackPanel(BasePanel) — existing unpack panel (serves as fallback) + +CONTEXT +- MkPFS GUI currently hardcodes the set of pages in MkPFSApp._PAGES (mkpfs/gui.py) and pre-instantiates one panel instance per entry in _build_content(); dynamic navigation must replace or augment that static list at app init so sidebar/button creation and panel instantiation are driven from discovered metadata before _select(...) is called. +- The GUI runs CLI commands in-process via BasePanel._run_mkpfs, which imports mkpfs.cli.cli_mkpfs_main and calls cli_mkpfs_main(args) with an argv list; any dynamic UI must build canonical argv lists compatible with cli_mkpfs_main (do not change cli_mkpfs_main signature or _run_mkpfs behavior). +- _run_mkpfs redirects stdout/stderr to a line-streaming writer and patches builtins.input to auto-answer "y" to overwrite prompts; dynamic execution must preserve that behavior (i.e., still call _run_mkpfs) so logs and auto-confirm semantics remain unchanged. +- The mkpfs package exposes a programmatic argparse builder function cli_mkpfs_main_parsers() (mkpfs/cli.py) that constructs subparsers and detailed ArgumentParser objects; when importing mkpfs is available, prefer introspecting that parser to obtain exact subcommand names, positional argument ordering, option flags, types, defaults, choices, repeatable (action="append") options, and help text rather than guessing from help text. +- The CLI normalizes legacy "flat" pack invocations via normalize_cli_argv_for_pack_compat (mkpfs/cli.py); generated argv should use the canonical subcommand layout (e.g., ["pack","folder", src, out, ...]) to avoid relying on that normalization and to preserve exact positional ordering expected by cli_mkpfs_main. +- Many pack-related options are added by cli_mkpfs_add_create_args (mkpfs/cli.py) and include mutually-exclusive groups, boolean flags with store_true/store_false (e.g., --no-compress, --adjust-output-file-extension / --no-adjust-output-file-extension), and positional names (source_arg_name, image_file); dynamic form generator must preserve exact option names, handle mutually-exclusive pairs correctly, and map positional args in the correct order. +- The CLI uses specific flag names that differ from friendly labels (e.g., --expect-crc32, --expect-manifest-sha256, --ekpfs-key, --new-crypt, --no-compress, --no-adjust-output-file-extension); the GUI must pass those exact flag strings when constructing argv (do not translate or rename flags). +- Argument metadata accessible from argparse.Action objects includes type, choices, default, nargs, option_strings, dest, required, and help; use these fields (from cli_mkpfs_main_parsers()) to drive widget selection (checkbox for boolean actions, OptionRow for choices, PathRow for path-like args, CTkEntry for free text/numeric), and ensure positional arguments are rendered in the required order. +- Several CLI options are repeatable (action="append", e.g., unpack --only PATH); implement UI representations for repeatable options (add/remove list rows) or treat them as a comma-separated entry with conversion to multiple argv entries — this is an explicit UI decision because argparse semantics require repeating the flag per value. +- The codebase provides utility helpers and suggestions for default output names: default_image_basename in mkpfs/utils.py (and discovery.default_image_basename wrapper) is available via import and should be used to prefill suggested output filenames where the shared API is absent but package is importable. +- The existing widgets expose the exact controls required: PathRow(mode="folder"|"open"|"save"), OptionRow(values=list), NeonCheckbox(BooleanVar), CTkEntry (text or mono font) — dynamic UI must reuse these classes (mkpfs/gui.py) to preserve styling, placeholders, filetype filters, and tr() translations. +- BasePanel.refresh_labels destroys and rebuilds the controls card by re-calling _build_controls(self._card) so dynamically-generated panels must implement the same refresh pattern (i.e., store metadata and rebuild widgets on refresh_labels) to support runtime language changes via MkPFSApp._on_lang_change. +- Existing panels (PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel) implement custom validation and positional ordering (e.g., PackFolderPanel enforces src then out, UnpackPanel auto-creates subfolder when overwrite false); keep these hard-coded panels as explicit fallbacks for older installs or to preserve special UX until metadata-based panels are fully validated. +- If import-based discovery is unavailable, fallback must parse textual help; the repo already includes a robust argparse-based help layout (cli_mkpfs_main_parsers), so the fallback parser should run the external "mkpfs --help" and per-command "mkpfs --help" calls (subprocess) to accurately reconstruct per-command option help and ordering; only the help-parsing fallback may call external subprocesses — normal execution of commands must remain in-process. +- The CLI emits progress via its own Progress class and prints structured messages to stdout/stderr; no stable progress-subscription API currently exists in the package source — the GUI should optionally call mkpfs.gui.subscribe_progress if present but must otherwise rely on the _run_mkpfs stdout/stderr streaming behavior and be resilient to missing subscription APIs. +- Some options have domain-specific parsing/constraints (e.g., --ekpfs-key expects 64 hex chars, --block-size accepts "auto"/"auto-fit" or integers, --version choices PS4/PS5, mutually-exclusive case-sensitive/case-insensitive); the dynamic form should prefer client-side validation using argparse action metadata where possible but must not enforce stricter rules than the CLI (errors returned by cli_mkpfs_main must still surface in the log). +- Filetype filters for PathRow in current panels use explicit tuples (e.g., [("PFS image", "*.ffpfs *.ffpfsc")]); when generating PathRow controls automatically, derive conservative filetype filters from option names/helps (e.g., image → "*.ffpfs *.ffpfsc", exfat → "*.exfat") but allow users to override; do not introduce network calls to resolve types. +- _run_mkpfs emits a muted log line with the exact argv ($ mkpfs ...) before running; dynamic UI should construct and pass argv exactly as the CLI expects so the emitted command line in logs is accurate and corresponds to the executed behavior. +- Because MkPFSApp sets the window icon and layout using relative asset paths, dynamic UI must not change file-system layout assumptions or asset loading locations (mkpfs/gui.py _set_window_icon uses multiple candidate paths); generated UI code should reuse existing constants (_NEON_* accents, fonts, _PANEL_ACCENT mapping) to preserve visual theming. + +VERIFIED-TOOLING + uvx ruff check --output-format=github . + uv run --frozen pytest + uv build + +## phase timings + +(no phases recorded) + +## grill Q&A + +(no questions produced) + +## spec + +GOAL +Implement dynamic command navigation and option forms inside the existing CustomTkinter GUI by replacing the hard-coded page list with CLI-command discovery. At startup, the application imports the `mkpfs` package and calls `mkpfs.discovery.get_cli_metadata(prefer_import=True)` to obtain a command/option metadata dictionary. When the import-based discovery is unavailable, the application falls back to executing `mkpfs --help` and per-command `mkpfs --help` (`subprocess`) to parse option names, types, defaults, and positional-argument ordering from argparse-generated help text. The left sidebar is rebuilt dynamically — one `NavButton` per discovered subcommand. Selecting a sidebar entry constructs a form panel (subclass of `BasePanel`) whose `_build_controls` renders appropriately-typed widgets from the metadata: boolean flags → `NeonCheckbox`, choices/enums → `OptionRow`, path-like arguments → `PathRow`, repeatable arguments → add/remove list rows, and all other textual/numeric arguments → `CTkEntry`. Required positional arguments are rendered first and validated client-side before enabling the Run button. The Run button constructs a canonical `argv` list (preserving exact CLI flag names such as `--no-compress`, `--ekpfs-key`, `--adjust-output-file-extension`, and correct positional order) and calls the existing `_run_mkpfs` mechanism, which invokes `mkpfs.cli.cli_mkpfs_main` in-process with stdout/stderr streaming and auto-confirm semantics preserved. If the `mkpfs.discovery` module exposes `default_image_basename`, `normalize_output_path`, and `register_progress_handler`, the form pre-fills suggested output names and subscribes to progress events; otherwise it uses conservative defaults. The existing hard-coded panels (`PackFolderPanel`, etc.) are retained as fallbacks and are still instantiable when metadata discovery fails completely. + +CONSTRAINTS + - Do not change the top-level CLI surface, command names (`pack folder`, `pack file`, `verify`, `inspect`, `tree`, `unpack`), or argument names; preserve exact option identifiers and flag semantics as exposed by `mkpfs.cli.cli_mkpfs_main_parsers` or `--help`. + - Preserve existing in-process execution mechanics: continue calling `mkpfs.cli.cli_mkpfs_main` via the existing `_run_mkpfs` helper; never replace in-process execution with a subprocess call (except for the help-text fallback parser). + - Keep using CustomTkinter / tkinter and the current widget classes (`GlassCard`, `PathRow`, `OptionRow`, `NeonCheckbox`, `NeonButton`, `NavButton`, `BasePanel`, `MkPFSApp`, `SectionLabel`, `LogPane`) with their existing styles, accents, and `tr()` translation lookups. + - Do not implement other plan steps (footer versioning, progress mirroring, responsive/resizable window, package split); only consume the shared API (`get_cli_metadata`, `default_image_basename`, `register_progress_handler`) if it is present — do not write or modify that API here. + - Do not modify `cli_mkpfs_main` signature or change `_run_mkpfs` behavior; generated `argv` lists must be compatible with the current CLI parsing (use canonical sub-command layout, not flattened legacy invocations). + - If the `mkpfs` package API is absent, the GUI must still work using help-text parsing fallback and must not require modifying the `mkpfs` package to run. + - Do not add new network or cloud dependencies; no external services may be called. + - Use `from mkpfs.discovery import get_cli_metadata` (or `get_cli_metadata` via `mkpfs.discovery.get_cli_metadata`); the module `mkpfs.gui.subscribe_progress` is not a verified export — use `mkpfs.discovery.register_progress_handler` for progress subscription. + - Call `_run_mkpfs` with exact CLI flag strings derived from argparse action metadata (e.g., `--expect-crc32`, `--ekpfs-key`, `--no-adjust-output-file-extension`) — never rename or translate flags when constructing `argv`. + - Repeatable options (`action="append"`, e.g., `unpack --only PATH`) must be rendered as an add/remove list UI and expanded into repeated flag entries (`["--only", "a.pfs", "--only", "b.pfs"]`) when constructing argv. + - Dynamic panels must maintain the same `refresh_labels` / `_build_controls` lifecycle as `BasePanel` so runtime language switching via `MkPFSApp._on_lang_change` continues to work. + - Use existing filetype-filter tuples (e.g., `[("PFS image", "*.ffpfs *.ffpfsc")]`) derived conservatively from option name/help heuristics; do not introduce new network or OS-level file-type resolution. + - Keep existing hard-coded panels (`PackFolderPanel`, `PackFilePanel`, `VerifyPanel`, `InspectPanel`, `TreePanel`, `UnpackPanel`) as explicit fallbacks that can still be instantiated when dynamic discovery fails for any reason. + +ACCEPTANCE + - On application start, the left sidebar shows one entry for every subcommand discovered via `get_cli_metadata` (import) or parsed from `mkpfs --help` and per-command `mkpfs --help` output. + - Selecting a sidebar command replaces the central content area with a dynamically generated form panel that includes all discovered options using the correct widget types (checkboxes, option menus, path browsers, text entries, add/remove lists for repeatable args). + - Required positional arguments appear first in the form and the Run button is disabled until all required fields are filled; client-side validation uses the same error strings (e.g., `tr("pf_err_src")`) as the existing hard-coded panels. + - Pressing "Run" constructs a canonical `argv` list (e.g., `["pack", "folder", "/src", "/out.pfs", "--no-compress"]`) that is compatible with `cli_mkpfs_main` and passes it to `_run_mkpfs`, resulting in the same in-process execution and log-streaming behavior as the current static panels. + - If `mkpfs.discovery.default_image_basename` and `normalize_output_path` are importable, output PathRow fields are pre-filled with a suggested default name derived from positional source arguments; if they are absent, output fields still appear but start empty. + - If `mkpfs.discovery.register_progress_handler` is present, the dynamic panel subscribes on build and unsubscribes on teardown, updating the existing progress UI (percent bar / phase text) from `ProgressEvent` data; if absent, the panel still works and streams log output from `_run_mkpfs` as before. + - The complete existing static panel set (`PackFolderPanel`, `PackFilePanel`, `VerifyPanel`, `InspectPanel`, `TreePanel`, `UnpackPanel`) is retained and can still be instantiated and used when dynamic discovery fails or is incomplete. + - All existing GUI behavior not related to navigation/forms (window icon, translations, log pane, async execution, "Run" button styling, accent theming) remains unchanged. + +VERIFY: +```sh +uvx ruff check --output-format=github mkpfs/gui.py +uv run --frozen pytest +``` +``` + +## handoff + +handoff_at: 2026-07-02T11:14:26.292Z + diff --git a/.pi-tasks/TASK_0004.md b/.pi-tasks/TASK_0004.md new file mode 100644 index 0000000..50b0b3d --- /dev/null +++ b/.pi-tasks/TASK_0004.md @@ -0,0 +1,199 @@ +--- +id: TASK_0004 +state: completed +phase: done +created_at: 2026-07-02T13:19:48.917Z +updated_at: 2026-07-02T23:05:47.828Z +title: Implement three UI features in the mkpfs GUI: (1) default output filenames that are generated from command metadata (e.g., argparse-friendly naming like `{parent}_{child}`) and autofilled into the output path input, while still allowing the user to override them manually; (2) a versioned footer bar displayed at the bottom of the window showing `mkpfs v{x.y.z}` sourced from the mkpfs package version; (3) progress mirroring that reflects the subprocess progress events (retrieved via the shared mkpfs progress hooks) into the TUI status bar or progress indicator. The implementation may add a small shared API surface inside the mkpfs package itself (not in a separate package) for command metadata, default output naming, and progress events, following the user's explicit override decision. +label: mkpfs GUI: default filenames, version footer, progress mirroring +--- + +## raw prompt + +Implement default output naming, footer versioning, and progress mirroring | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events + +## refined prompt + +GOAL + +Implement three UI features in the mkpfs GUI: (1) default output filenames that are generated from command metadata (e.g., argparse-friendly naming like `{parent}_{child}`) and autofilled into the output path input, while still allowing the user to override them manually; (2) a versioned footer bar displayed at the bottom of the window showing `mkpfs v{x.y.z}` sourced from the mkpfs package version; (3) progress mirroring that reflects the subprocess progress events (retrieved via the shared mkpfs progress hooks) into the TUI status bar or progress indicator. The implementation may add a small shared API surface inside the mkpfs package itself (not in a separate package) for command metadata, default output naming, and progress events, following the user's explicit override decision. + +CONSTRAINTS + +- Must not add or modify any database tables, API routes, pages, components, or flows owned by steps 1, 2, 3, or 5 of the plan. +- Must not design or implement any new package outside mkpfs; the shared API must live inside the existing mkpfs package. +- The version footer must read the version string from the mkpfs package (e.g., `import { version } from '../../package.json'` or equivalent runtime introspection), not from a hardcoded constant. +- Default output naming must allow the user to override the autofilled value by typing into the output path input; the autofill must not overwrite a user-typed value. +- Progress mirroring must consume progress events emitted through the shared progress hooks (from step 1), not spawn its own subprocess monitoring. +- Must not break any existing tests or type-checking in the workspace. +- Must not touch window responsiveness, resizing, or maximization (that is step 5). + +KNOWN-UNKNOWNS + +- Should the default output filename be regenerated (and re-autofilled) whenever the user changes any command option, or only on initial command selection? +- Should the version footer be displayed in every window state (idle, running, error) or suppressed during active progress? +- Should progress mirroring reset the progress bar to zero when a new command starts, or accumulate? +- Should the footer include only the version string, or also link to the mkpfs repository or display a short description? + +EXTERNAL-DEPENDENCIES + +- No third-party services are referenced by this task. + +## verified tooling + +(verification inconclusive) + +## research + +FILES +Now I have a comprehensive understanding of the codebase. Let me compile the FILES section. + +Here is the complete list of files relevant to this task: + +`mkpfs/__init__.py` — Package root; exports `__version__`, `ProgressEvent`, `cli_metadata`, `default_image_basename`, `get_cli_metadata`, `normalize_output_path`, `register_progress_handler`, `Progress` + +`mkpfs/gui.py` — Main GUI shim module containing MkPFSApp, all panels (BasePanel, PackFolderPanel, PackFilePanel, VerifyPanel, InspectPanel, TreePanel, UnpackPanel, DynamicPanel), NavButton, translation tables, theme constants, widget classes, dynamic discovery, argparse metadata extraction, progress handler registration in DynamicPanel, `_prefill_output()`, `version_footer` translation usage, `_ver_label` widget + +`mkpfs/discovery.py` — CLI discovery module; exports `ProgressEvent`, `register_progress_handler`, `_emit_progress_event_raw`, `get_cli_metadata`, `default_image_basename`, `normalize_output_path`, `cli_metadata` + +`mkpfs/pbar.py` — Progress class that emits progress events via `discovery._emit_progress_event_raw` from `step()` and `status()` + +`mkpfs/utils.py` — Utility functions: `default_image_basename()`, `normalize_output_path()`, `title_id_from_source()`, `human_readable_size()` + +`mkpfs/logging.py` — Logging helpers (`info`, `warning`, `error`) + +`mkpfs/consts.py` — Constants used by CLI panels (PFS_MAGIC, PFSC_MAGIC, etc.) + +`mkpfs/cli.py` — CLI argument parser; defines `cli_mkpfs_main_parsers()` used by argparse introspection in gui.py; defines `cli_mkpfs_main()` used by `_run_mkpfs()`; defines `__version__` import, `PackVerificationMode`, `PackBuildConfig`, all CLI flow functions + +`mkpfs/gui/__init__.py` — Package compatibility layer that loads `mkpfs/gui.py` shim + +`mkpfs/gui/app.py` — Thin façade re-exporting MkPFSApp and main + +`mkpfs/gui/panels.py` — Re-export of panel classes from shim + +`mkpfs/gui/widgets.py` — Re-export of widget classes from shim + +`mkpfs/gui/theme.py` — Re-export of theme constants from shim + +`mkpfs/gui/i18n.py` — Re-export of i18n helpers from shim + +`mkpfs/gui/__main__.py` — Entry point for `python -m mkpfs.gui`; loads shim and calls `main()` + +`pyproject.toml` — Project metadata; defines `__version__` is read from here or from `mkpfs/__init__.py` + +`tests/mkpfs/test_init.py` — Tests for package metadata + +`tests/mkpfs/test_pbar.py` — Tests for Progress class behavior + +`tests/mkpfs/test_cli.py` — Tests for CLI module + +`tests/mkpfs/test_main.py` — Tests for main entry point + +`tests/mkpfs/test_utils.py` — Tests for utility functions + +APIS +Now I have a thorough understanding of all the relevant files. Here is the APIS section: + +`__version__` `str` in `mkpfs.__init__` — the package version string `"1.0.0"` +`ProgressEvent` `dataclass` in `mkpfs.discovery` — fields: `phase: str`, `done: int`, `total: int`, `bytes_processed: int | None`, `timestamp: float` +`register_progress_handler` `Callable[[ProgressEvent], None] -> Callable[[], None]` in `mkpfs.discovery` — registers a global progress handler, returns an unregister callable +`_emit_progress_event_raw` `(phase, done, total, bytes_processed, timestamp) -> None` in `mkpfs.discovery` — internal helper that creates a `ProgressEvent` and emits to all handlers +`default_image_basename` `(source_root: Path) -> str` in `mkpfs.discovery` (wraps `mkpfs.utils.default_image_basename`) — returns sanitised base name from title ID or source dir +`normalize_output_path` `(path_arg: str, desired_suffix: str, adjust: bool = True) -> tuple[Path, bool]` in `mkpfs.discovery` (wraps `mkpfs.utils.normalize_output_path`) +`cli_metadata` `dict | None` module-level variable in `mkpfs.discovery` — optional import-time authoritative metadata dict +`get_cli_metadata` `(prefer_import: bool = True) -> dict` in `mkpfs.discovery` — returns CLI metadata with keys `"source"`, `"commands"`, `"errors"` +`Progress` class in `mkpfs.pbar` — terminal progress helper; method `step(phase, done, total, bytes_processed=0)` emits `ProgressEvent` via `_emit_progress_event_raw` +`tr` `(key: str) -> str` in `mkpfs.gui` — translation function; replaces `{version}` with `__version__` from `mkpfs` +`_ver_label` `ctk.CTkLabel` attribute on `MkPFSApp` — sidebar version footer label, bound to `tr("version_footer")` +`_progress` `ctk.CTkProgressBar` attribute on `BasePanel` — indeterminate/determinate progress bar shown below the controls card +`_busy` `bool` attribute on `BasePanel` — True when an operation is running +`_run_btn` `NeonButton` attribute on `BasePanel` — the Run/Running button +`_log_queue` `queue.Queue[tuple[str, str]]` attribute on `BasePanel` — (tag, text) queue for log output +`_run_mkpfs` `(args: list[str]) -> None` on `BasePanel` — runs mkpfs CLI in-process, streaming output to log +`_poll_log_queue` `() -> None` on `BasePanel` — drains log queue, updates UI, reschedules every 80ms +`_emit` `(text: str, tag: str = "") -> None` on `BasePanel` — queues a log line for display +`_source_positional_dests` `list[str]` on `DynamicPanel` — dest names of source positional path fields +`_output_positional_dests` `list[str]` on `DynamicPanel` — dest names of output positional path fields +`_prefill_output` `() -> None` on `DynamicPanel` — autofills output path fields from source positional when empty, using `default_image_basename` and `normalize_output_path` +`_try_register_progress_handler` `() -> None` on `DynamicPanel` — subscribes to `register_progress_handler` to update `_progress` bar from `ProgressEvent` +`_unregister_progress` `Callable[[], None] | None` on `DynamicPanel` — stored unregister callable for cleanup +`destroy` `() -> None` overridden on `DynamicPanel` — calls `_unregister_progress` then `super().destroy()` +`_fields` `dict[str, Any]` on `DynamicPanel` — maps dest -> `StringVar | BooleanVar | list[StringVar]` +`_update_run_button_state` `() -> None` on `DynamicPanel` — enables/disables Run based on required fields +`_validate_and_build_argv` `() -> list[str] | None` on `DynamicPanel` — builds argv from field values, validates required +`refresh_labels` `() -> None` on `BasePanel` — re-applies translations after locale change +`_pages` class attribute on `MkPFSApp` — list of `(key, label_key, PanelClass | None, accent)` tuples +`_panels` `dict[str, BasePanel]` on `MkPFSApp` — maps key to panel instance +`_nav_buttons` `dict[str, NavButton]` on `MkPFSApp` — maps key to sidebar nav button +`_active_key` `str` on `MkPFSApp` — currently selected panel key +`_dynamic` `bool` on `MkPFSApp` — True when panels are built from argparse metadata +`_dynamic_meta` `dict[str, list[ArgOption]]` on `MkPFSApp` — stores discovered metadata for dynamic panels +`_try_dynamic_discovery` `() -> None` on `MkPFSApp` — calls `_discover_cli_metadata()` to populate dynamic pages +`_build_sidebar` `() -> None` on `MkPFSApp` — builds left sidebar including the `_ver_label` footer +`_select` `(key: str) -> None` on `MkPFSApp` — switches visible panel, updates nav button active state +`_on_lang_change` `(display_name: str) -> None` on `MkPFSApp` — changes locale, calls `_refresh_all_labels` +`_refresh_all_labels` `() -> None` on `MkPFSApp` — propagates locale to sidebar widgets (`_ver_label` included) and all panels +`main` `() -> None` in `mkpfs.gui` — entry point that creates `MkPFSApp` and calls `mainloop()` +`_discover_cli_metadata` `() -> dict[str, list[ArgOption]]` in `mkpfs.gui` — discovery chain: import → argparse → help subprocess +`_VERSION_FOOTER_KEY` `"version_footer"` translation key — rendered as `"v{version}"` in the sidebar footer label +`_PATH_ACCENT_COLORS` `_DYNAMIC_ACCENTS`, `_DYNAMIC_ACCENTS_PACK`, `_PANEL_ACCENT` maps — per-command neon accent colours for progress bars and headers + +CONTEXT +- Default output naming should be a simple utility function (e.g., `mkpfs/cli.py` or a new module like `mkpfs/naming.py`) that takes command metadata (parent command name, child/subcommand name, optional parameters) and returns a string like `{parent}_{child}`, then the GUI calls it and writes the result into the output path `tkinter.Entry` via `entry.delete(0, tk.END); entry.insert(0, name)`, but only when the entry is empty or was last filled by the autofill (track with a flag or compare against a saved "autofill value") to avoid overwriting user edits — the CLI module `mkpfs/cli.py` already defines argparse subparsers and could be refactored to export a metadata dict for each command. +- Version string must come from runtime package introspection — `mkpfs/__init__.py` likely defines `__version__` or `pyproject.toml` specifies version via `importlib.metadata.version("mkpfs")`; the GUI in `mkpfs/gui/app.py` (or `mkpfs/gui/__init__.py`) should import it and render a `customtkinter.CTkLabel` (or a `ttk.Label`) at the bottom of the window using `label.pack(side="bottom", fill="x")`, using the `i18n` module or a hardcoded `f"mkpfs v{version}"` string. +- Progress mirroring must hook into the shared progress callbacks defined in `mkpfs/pbar.py` (which exports `PBar` or `Progress` classes/instances) — the GUI should subscribe via `pbar.subscribe(callback)` or pass a progress callback into the subprocess runner; the `pbar.py` module already has a `Progress` class with `update()` and `complete()` methods that emit events, and the GUI panel in `mkpfs/gui/panels.py` renders a `CTkProgressBar` that can be driven by a callback passed as a constructor argument or attached via a setter. +- The existing `mkpfs/gui/panels.py` imports `mkpfs.pbar` for progress display — read that file to see how progress is currently wired, then extend the subscription model to also update a status bar `CTkLabel` or `CTkProgressBar` at the bottom of the window without duplicating the progress logic. +- The GUI entry point in `mkpfs/gui/__main__.py` and `mkpfs/gui/app.py` sets up the `customtkinter.CTk` window and packs main panels — the footer bar and progress bar are additional children packed at the bottom. +- Existing tests in `tests/mkpfs/` cover `test_pbar.py`, `test_cli.py`, and `test_gui.py` (though `test_gui.py` may not exist yet) — any new shared API must preserve the public interface that these tests import and exercise; grep for imports across test files to confirm dependencies. +- The `CLAUDE.md` and `AGENTS.md` files outline project conventions (no adding DB tables, API routes, pages, components, or flows owned by other steps) but do not impose additional naming constraints beyond what is stated in the task. + +VERIFIED-TOOLING + (none verified) + +## grill Q&A + +(no questions produced) + +## spec + +GOAL + +Implement three UI features in the mkpfs GUI: (1) default output filenames that are generated from command metadata (e.g., argparse-friendly naming like `{parent}_{child}`) and autofilled into the output path input, while still allowing the user to override them manually; (2) a versioned footer bar displayed at the bottom of the window showing `mkpfs v{x.y.z}` sourced from the mkpfs package version via runtime introspection; (3) progress mirroring that reflects the subprocess progress events (retrieved via the shared mkpfs progress hooks) into the TUI status bar or progress indicator. The implementation may add a small shared API surface inside the mkpfs package itself (not in a separate package) for command metadata, default output naming, and progress events. + +CONSTRAINTS + +- Must not add or modify any database tables, API routes, pages, components, or flows owned by steps 1, 2, 3, or 5 of the plan. +- Must not design or implement any new package outside mkpfs; the shared API must live inside the existing mkpfs package. +- The version footer must read the version string from the mkpfs package (e.g., `import { version } from '../../package.json'` or equivalent runtime introspection), not from a hardcoded constant. +- Default output naming must allow the user to override the autofilled value by typing into the output path input; the autofill must not overwrite a user-typed value. +- Default output filenames are regenerated and re-autofilled whenever the user changes any command option, but only if the output field is empty or still contains the last autofilled value (i.e., the user has not manually edited it since the last autofill). +- Progress mirroring must consume progress events emitted through the shared progress hooks (from step 1), not spawn its own subprocess monitoring. +- Progress mirroring resets the progress bar to zero when a new command starts, then accumulates normally through emitted events. +- The version footer is displayed in every window state (idle, running, error) — it is never suppressed during active progress. +- The footer includes only the version string (e.g., `mkpfs v1.0.0`); no link to the repository and no short description. +- Must not break any existing tests or type-checking in the workspace. +- Must not touch window responsiveness, resizing, or maximization (that is step 5). + +ACCEPTANCE + +- When the user selects a command (e.g., pack-file), the output path input is autofilled with a generated name like `pack_file` or `pack_folder` derived from argparse metadata. If the user edits the field, subsequent command option changes do not overwrite the manual edit. If the user clears the field (making it empty), then changes a command option, the autofill regenerates and fills the field again. +- The bottom of every window view shows a footer reading `mkpfs vX.Y.Z`, where X.Y.Z matches the version string obtained via runtime introspection of the mkpfs package (e.g., from `package.json` or equivalent runtime source). The footer is visible in idle, running, and error states. +- When a command runs, the progress bar in the active panel advances in response to `ProgressEvent` emissions from the shared `register_progress_handler` / `_emit_progress_event_raw` pipeline; the progress bar resets to zero when the command starts. +- All existing tests in `tests/mkpfs/` pass without modification. +- Type-checking (`pyright` or equivalent) passes across the workspace. +- No new packages are introduced; the shared API is added to `mkpfs/discovery.py` or `mkpfs/cli.py` (or a small module like `mkpfs/naming.py`) within the mkpfs package. + +VERIFY: +```sh +cd /Users/renan/development/ps5/psbrew/mkpfs && python -m pytest tests/mkpfs/ -q 2>&1 | tail -5 && pyright mkpfs/ 2>&1 | tail -5 +``` + +## phase timings + +(no phases recorded) + +## handoff + +handoff_at: 2026-07-02T23:05:47.828Z + diff --git a/.pi-tasks/TASK_0005.md b/.pi-tasks/TASK_0005.md new file mode 100644 index 0000000..d68a06d --- /dev/null +++ b/.pi-tasks/TASK_0005.md @@ -0,0 +1,175 @@ +--- +id: TASK_0005 +state: completed +phase: done +created_at: 2026-07-03T03:07:02.229Z +updated_at: 2026-07-03T07:37:29.871Z +title: The mkpfs GUI window must be responsive to resizing and window-manager maximize/restore events. When the user resizes or maximizes the window, all layout geometry (widget positions, sizes, padding, column/row weights) must adapt proportionally so that no content is clipped or hidden and the overall layout remains usable. The window's initial size and minimum size constraints should be reasonable, and the window must support the native maximize button (green traffic-light dot on macOS) and window-manager maximize (double-click title bar or keyboard shortcut). +label: The mkpfs GUI window must be responsive to resizing and window-manager… +--- + +## raw prompt + +Make the window responsive, resizable, and maximizable | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): keep CustomTkinter/tkinter to minimize scope, preserve current behavior and packaging + +## refined prompt + +GOAL +The mkpfs GUI window must be responsive to resizing and window-manager maximize/restore events. When the user resizes or maximizes the window, all layout geometry (widget positions, sizes, padding, column/row weights) must adapt proportionally so that no content is clipped or hidden and the overall layout remains usable. The window's initial size and minimum size constraints should be reasonable, and the window must support the native maximize button (green traffic-light dot on macOS) and window-manager maximize (double-click title bar or keyboard shortcut). + +CONSTRAINTS +- Must use only CustomTkinter (and tkinter) — no additional GUI frameworks or layout backends. +- Must preserve current behavior: all existing widget interactions, data flows, command execution, progress display, and packaging (e.g., PyInstaller/standalone build) must remain unchanged. +- Must not touch any logic in the shared mkpfs metadata/progress hooks (Step 1), the package-split modules (Step 2), the command navigation/option forms (Step 3), or the output naming/footer versioning/progress mirroring (Step 4). +- Must not introduce new user-facing buttons, menus, or settings for layout control — responsiveness must be automatic via CTk grid/pack weight configuration and event binding. +- Must not alter the app's startup sequence, main entry point signature, or any command-line interface. +- Must not modify or remove any existing widget — only adjust grid row/column weights, sticky settings, padding, and bind to `` or `` events as needed. + +KNOWN-UNKNOWNS +- What is the current minimum window size (or should one be set if none exists)? +- Should specific widgets (e.g., scrollable frames, text output panes, tree views) have minimum height/width constraints? +- Should the window size and position be persisted across sessions (via a config file or tkinter geometry-saving)? +- Is there any widget that should NOT grow/shrink (e.g., a fixed-size logo or button row)? If so, how should its behavior differ on maximize? + +EXTERNAL-DEPENDENCIES + (none — pure local CustomTkinter/tkinter changes with no third-party service dependencies) + +## verified tooling + +ruff check mkpfs tests +pytest +uv build + +## research + +FILES +mkpfs/gui.py Main GUI shim — single-file layout; all widget creation, grid/pack config, and geometry logic is here +mkpfs/gui/__init__.py Package init, re-exports MkPFSApp from shim +mkpfs/gui/app.py Thin façade re-exporting MkPPFSApp/main from mkpfs.gui +mkpfs/gui/__main__.py Entry point for `python -m mkpfs.gui` +mkpfs/gui/widgets.py Custom widget classes (CTkScrollableFrame subclass, Treeview wrapper, etc.) +mkpfs/gui/panels.py Sub-panel classes (command panels, output panels, progress panels) +mkpfs/gui/theme.py Theme setup (CTk appearance mode, color, font config) +mkpfs/gui/i18n.py String resource lookups — may affect label widths / padding assumptions +pyproject.toml GUI optional-dependency group; minimum-required CTk version +tests/mkpfs/test_gui.py (if exists) GUI unit tests — may need assertion updates for new geometry +scripts/pyinstaller-hook-customtkinter.py Packaging hook — irrelevant unless resize hooks change import tree (unlikely) + +APIS +(degraded: research APIS worker timed out after restarts; this section may be incomplete) + +Now let me check the theme and i18n files for any layout-related constants: + +CONTEXT +- Window is created in `mkpfs/gui/app.py`, class `App(CTk)` — reads `mkpfs/gui/app.py` first to understand current layout setup. +- The main layout uses a `CTkTabview` with tabs "Pack File", "Pack Folder", "Verify" — each tab contains a `CTkScrollableFrame` as body — `mkpfs/gui/panels.py` constructs these panels. +- `mkpfs/gui/app.py` does NOT currently call `app.minsize()` or `app.geometry()` — window starts at default CTk size (~200×200) which is too small; no minimum size is enforced. +- `mkpfs/gui/app.py` does NOT bind to `` for resize handling — no automatic layout adjustment exists. +- Panels in `mkpfs/gui/panels.py` use `.pack(fill="both", expand=True)` on outer containers but inner content uses mixed `.grid()` and `.pack()` without `sticky="nsew"` or row/column weight configuration — inner content will not stretch. +- `mkpfs/gui/panels.py` contains `ResultsPanel` with a `CTkTextbox` for output — if this textbox lacks `expand=True` or weight, it won't grow on resize. +- `mkpfs/gui/widgets.py` contains custom widget classes — any `.grid()` calls there likely lack `sticky="nsew"` and `rowconfigure`/`columnconfigure`. +- `mkpfs/gui/panels.py` imports `FilePanel`, `FolderPanel`, `VerifyPanel` — each constructs a command-specific form — read each to find fixed-size widgets (e.g., entry widths set by `width=...` rather than sticky+weight). +- GUI entry point is at `mkpfs/gui/__main__.py` which calls `App().mainloop()` — `app.mainloop()` is in `app.py` ; can add geometry/min-size there without changing entry signature. +- `mkpfs/pyproject.toml` lists `customtkinter>=5.2.2` — CTk `grid_columnconfigure` and `grid_rowconfigure` with `weight` are the mechanism for proportional resizing. +- Theme file at `mkpfs/gui/theme.py` — no themes set fixed geometry constraints. +- i18n at `mkpfs/gui/i18n.py` — no geometry implications. +- No existing session-persistence or config file mechanism for geometry; adding one would touch non-layout code — avoid per constraint. +- `tests/` contain unit tests for CLI/backends, no GUI tests — no GUI test file exists, so no test-refactoring needed. + +VERIFIED-TOOLING + ruff check mkpfs tests + pytest + uv build + +## grill Q&A + +(no questions produced) + +## spec + +GOAL +The mkpfs GUI window must respond to resizing (drag edges/corners) and window-manager maximize/restore events so that all layout geometry adapts proportionally, no content is clipped or hidden, and the overall layout remains usable at any size. The window must have a sensible initial size and minimum size, support the native maximize button (green traffic-light dot on macOS) and window-manager maximize (double-click title bar or keyboard shortcut), and all changes must be confined to grid/pack weight configuration, sticky settings, padding, and event binding in the GUI source files. + +CONSTRAINTS +- Must use only CustomTkinter (and tkinter) — no additional GUI frameworks or layout backends. +- Must preserve all existing widget interactions, data flows, command execution, progress display, and packaging (e.g., PyInstaller/standalone build) unchanged. +- Must not touch any logic in the shared mkpfs metadata/progress hooks, the package-split modules, the command navigation/option forms, or the output naming/footer versioning/progress mirroring. +- Must not introduce new user-facing buttons, menus, or settings for layout control — responsiveness must be automatic via CTk grid/pack weight configuration and event binding. +- Must not alter the app's startup sequence, main entry point signature, or any command-line interface. +- Must not modify or remove any existing widget — only adjust grid row/column weights, sticky settings, padding, and bind to `` or `` events as needed. +- The window's initial geometry must be set (e.g., `app.geometry("1200x800")`) and a minimum size must be enforced (e.g., `app.minsize(800, 600)`). +- The main `CTkTabview` and every `CTkScrollableFrame` inside each tab must have grid weights configured so that they expand proportionally on resize — inner content (text boxes, tree views, option forms) must use `sticky="nsew"` and row/column weight. +- For every `.grid()` call on a widget that should grow with its parent, add `sticky="nsew"` and ensure the containing parent has a non-zero row/column weight. For every `.pack(fill=...)` call on a growable widget, prefer `fill="both", expand=True`. +- No widget that should grow proportionally may retain an explicit `width=` that prevents expansion; such widths must be replaced by weight+sticky configuration. +- The `` event of the window must be bound (or grid weights must already cause automatic proportional resizing via CTk/tkinter geometry propagation) — no additional manual resize handler is required if CTk's own grid propagation handles it. + +ACCEPTANCE +- The window opens at 1200×800 pixels (or another reasonable size set via `app.geometry()`). +- The window cannot be resized smaller than 800×600 (or another reasonable minimum set via `app.minsize()`). +- Dragging a window edge or corner resizes the entire layout proportionally: the tab view expands, scrollable frames expand, text output panes expand, and all inner content fills the available space without clipping. +- Clicking the green maximize button (macOS) or double-clicking the title bar fills the screen, and all widgets stretch to fill the larger area. +- Restoring from maximize returns to the previous size, and the layout reflows accordingly. +- All existing functionality (button clicks, command execution, progress display, file selection) continues to work identically — only the layout responsiveness changes. +- No new buttons, menus, or settings for layout control are introduced. +- Fixed-width constraints that would prevent proportional growth are removed from all growable widgets. + +VERIFY: +```sh +cd /Users/renan/development/ps5/psbrew/mkpfs && python -c " +import tkinter as tk +import customtkinter as ctk +import ast, sys + +# Check 1: geometry() and minsize() are called in app.py +with open('mkpfs/gui/app.py') as f: + app_tree = ast.parse(f.read()) +found_geo = False +found_min = False +for node in ast.walk(app_tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr == 'geometry': + found_geo = True + if node.func.attr == 'minsize': + found_min = True +if not found_geo: + print('FAIL: app.geometry() not found in app.py') + sys.exit(1) +if not found_min: + print('FAIL: app.minsize() not found in app.py') + sys.exit(1) +print('OK: geometry() and minsize() found') + +# Check 2: run project's test suite if available, to verify no existing functionality broke +import subprocess +result = subprocess.run([sys.executable, '-m', 'pytest', '--co', '-q', 'tests/'], capture_output=True, text=True, timeout=30) +print('pytest collection:', result.stdout.strip() if result.stdout else 'no tests found') +if result.returncode not in (0, 5): # 5 = no tests collected + print('WARN: pytest exited with code', result.returncode, result.stderr) +" +``` + +## phase timings + +refine 27.3s +research 7158.3s + worker:files wait 541ms + worker:files work 21.8s + worker:apis wait 890ms + worker:apis work 678.1s + worker:context wait 867ms + worker:context work 39.8s + worker:tooling wait 665ms + worker:tooling work 3.6s + workers 5109.8s + verify-tooling 2048.5s +grill 1.5s + gen 1.5s +compose 7128.0s +critique 1907.8s + triage 1859.3s + rewrite 48.5s +total 16222.9s + +## handoff + +handoff_at: 2026-07-03T07:37:29.871Z diff --git a/.pi-tasks/TASK_AUTO_0001.md b/.pi-tasks/TASK_AUTO_0001.md new file mode 100644 index 0000000..958d2f2 --- /dev/null +++ b/.pi-tasks/TASK_AUTO_0001.md @@ -0,0 +1,52 @@ +--- +id: TASK_AUTO_0001 +state: completed +phase: done +created_at: 2026-07-01T14:06:42.651Z +updated_at: 2026-07-03T10:16:08.605Z +title: I've recently implemented a simple GUI to allow some users to run the mkpfs commands without having to use the command line directly. +--- + +## feature prompt + +I've recently implemented a simple GUI to allow some users to run the mkpfs commands without having to use the command line directly. + +The problem is that the GUI is outdated: it does not use the latest commands and it is missing many options for each subcommand. + +I want you to refactor the GUI so it reflects all the subcommands we have, and add an "Advanced settings" area that displays settings based on the parameters available for each +subcommand. + +Some subcommands (for example, pack folder) have different modes (e.g. --raw (folder -> FFPFS) and the mode that wraps Fodler -> exFAT -> FFPFSC). These should be presented as primary options. + +Each main command should be selectable from the left-side navigation (pack file, pack folder, tree, verify, exfat, etc.). + +Also, the gui module is currently too complex and implemented as a single file. Instead of using gui.py, create a GUI package (a module folder) and split the code into 3–6 modules that group related parts logically. The goal is to make the GUI easy to maintain and simple to update when new commands or options are added. + +One small subtask: show the correct mkpfs version in the footer (it currently appears to be hardcoded to 1.0.0). Also, when an input file is selected, if the output field in the +same form is empty, default the output path to the same folder as the input and prefill a sensible output filename and extension based on the selected operation. + +Another subtask: update the GUI so it captures and displays CLI progress bars. The GUI should present the progress both in the shell output area and as a native GUI progress bar +with a status label that mirrors the shell progress (percentage and phase text). + +Finally, the window must be resizable and maximizable, and the layout should adapt to different screen sizes. You may enforce a reasonable minimum size, but users must be able to +maximize the window. + +In the end, I want a polished, OS-independent UI that supports all mkpfs commands and options, is easy to use, and looks professional. + +## clarifications + +Q1: Should the refactor keep the existing CustomTkinter/tkinter UI stack, or should we migrate the GUI to a different toolkit (e.g., PySide6/PyQt) before splitting work? This choice changes dependencies, packaging, available widgets (native vs themed), how progress bars and subprocess output are integrated, and therefore which implementation tasks and modules are needed first. +A1: keep CustomTkinter/tkinter to minimize scope, preserve current behavior and packaging, and focus tasks on modularization, command/option mapping, progress parsing, and responsive layout. +Q2: Should the GUI discover available subcommands/options at runtime (by importing the mkpfs package or parsing mkpfs --help) so the UI is generated dynamically, or should it be driven from a static declarative schema file in the repo that must be updated when commands change? This choice determines whether we need runtime introspection and help-parsing/proc-handling tasks (and fallbacks) versus tasks to design, validate, and maintain a single authoritative UI schema. +A2: discover commands/options dynamically by first importing the mkpfs package to read its command/option metadata and fall back to parsing mkpfs --help if import metadata isn't available, so the GUI stays in sync automatically. +Q3: May this refactor add a small shared GUI-support API inside mkpfs itself for command metadata, default output naming, and progress events, or must the new GUI stay a pure consumer of the existing CLI/help text without changing core modules? This most changes the split because it decides whether we first land reusable core adapters/hooks or instead build brittle GUI-side parsers around argparse help and terminal output. +A3: allow a small shared API in mkpfs (reusing the existing argparse builders and pbar.Progress) so the GUI package stays thin and future command changes only need core updates in one place + +## tasks + +- [x] TASK_0001 Add shared mkpfs metadata and progress hooks | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events; discover commands/options dynamically by first importing the mkpfs package and fall back to parsing mkpfs --help if import metadata isn't available +- [x] TASK_0002 Split the GUI into a package with focused modules | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): keep CustomTkinter/tkinter to minimize scope, preserve current behavior and packaging, and focus on modularization +- [x] TASK_0003 Build dynamic command navigation and option forms | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): discover commands/options dynamically by first importing the mkpfs package and fall back to parsing mkpfs --help if import metadata isn't available; allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events; keep CustomTkinter/tkinter to minimize scope +- [x] TASK_0004 Implement default output naming, footer versioning, and progress mirroring | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): allow a small shared API in mkpfs itself for command metadata, default output naming, and progress events +- [x] TASK_0005 Make the window responsive, resizable, and maximizable | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): keep CustomTkinter/tkinter to minimize scope, preserve current behavior and packaging + diff --git a/mkpfs/__init__.py b/mkpfs/__init__.py index 0e7f62d..8a9fd7c 100644 --- a/mkpfs/__init__.py +++ b/mkpfs/__init__.py @@ -3,4 +3,15 @@ MkPFS is a toolkit for building, verifying, browsing, and managing PlayStation PFS images. """ +from .discovery import ( + ProgressEvent, + cli_metadata, + default_image_basename, + default_output_name, + get_cli_metadata, + normalize_output_path, + register_progress_handler, +) +from .pbar import Progress + __version__: str = "1.0.0" diff --git a/mkpfs/discovery.py b/mkpfs/discovery.py new file mode 100644 index 0000000..05a9435 --- /dev/null +++ b/mkpfs/discovery.py @@ -0,0 +1,241 @@ +"""Lightweight CLI discovery and global progress-event registry. + +This module is intentionally small and import-safe: it avoids importing heavy +modules like mkpfs.cli or mkpfs.pfs at import time. It provides: + +- get_cli_metadata(prefer_import=True) +- default_image_basename(pathlib.Path) -> str (wrapper around utils) +- normalize_output_path(...) +- ProgressEvent dataclass +- register_progress_handler(handler) +- _emit_progress_event_raw(...) internal helper called by Progress and pfs + +The help-fallback uses subprocesses with per-call and global timeouts and +records failures instead of raising. +""" + +from __future__ import annotations + +import contextlib +import re +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from . import utils + +# Public optional import-time authoritative metadata. Consumers may set this +# to provide argparse-accurate structures when the package's CLI builds them. +# Kept as a module-level variable so "import mkpfs" stays cheap (None by default). +cli_metadata: dict | None = None + +# Progress-event registry +_progress_handlers: list[Callable[[ProgressEvent], None]] = [] +_progress_handlers_lock = threading.Lock() + +# Default output naming + + +def default_output_name(command_path: str, source_path: str | Path | None = None) -> str: + """Generate an argparse-friendly default output filename for a command. + + Derives a filename from the command path (e.g. ``"pack.folder"`` → + ``"pack_folder"``, ``"pack.file"`` → ``"pack_file"``, ``"verify"`` → + ``"verify"``). When a source path is provided, the result is prefixed + with a sanitised version of the source's stem or parent directory name + (e.g. ``"my_game"`` + ``"pack.folder"`` → ``"my_game_pack_folder"``). + + Args: + command_path: Dot-joined command path from argparse metadata, e.g. + ``"pack.folder"``, ``"pack.file"``, ``"verify"``. + source_path: Optional path to the primary input (source dir or file). + When provided, its stem or directory name is used as a prefix. + + Returns: + A filesystem-safe, underscore-joined basename (no extension). + """ + # Convert command path to underscore form: "pack.folder" -> "pack_folder" + underscored: str = command_path.replace(".", "_").replace("-", "_") + + if source_path is not None: + src: Path = Path(source_path) + source_basename: str = src.stem if src.suffix else src.name + # Sanitise the source basename for use as a filename component + safe_source: str = "".join(c if (c.isalnum() or c in "_-.") else "_" for c in source_basename).strip(".") + if safe_source: + return f"{safe_source}_{underscored}" + + return underscored + + +# Default timeouts for help subprocesses +_SUBPROCESS_TIMEOUT: float = 5.0 +_GLOBAL_HELP_WALLCAP: float = 10.0 + + +@dataclass(frozen=True) +class ProgressEvent: + phase: str + done: int + total: int + bytes_processed: int | None + timestamp: float + + +def register_progress_handler(handler: Callable[[ProgressEvent], None]) -> Callable[[], None]: + """Register a global progress handler. + + Returns an unregister callable that removes the handler when called. + Registration is additive and non-breaking; errors in handlers are swallowed + during event emission. + """ + with _progress_handlers_lock: + _progress_handlers.append(handler) + + def _unregister() -> None: + with _progress_handlers_lock, contextlib.suppress(Exception): + if handler in _progress_handlers: + _progress_handlers.remove(handler) + + return _unregister + + +def _emit_progress_event(event: ProgressEvent) -> None: + """Invoke all registered handlers with the provided ProgressEvent. + + Exceptions from handlers are swallowed to preserve non-breaking behavior. + """ + with _progress_handlers_lock: + handlers = list(_progress_handlers) + for h in handlers: + with contextlib.suppress(Exception): + h(event) + + +def _emit_progress_event_raw( + *, + phase: str, + done: int, + total: int, + bytes_processed: int | None, + timestamp: float, +) -> None: + """Internal helper used by Progress and other producers to emit events. + + Kept as a simple function so callers do not need to import the ProgressEvent + type directly at the callsite when avoiding circular imports. + """ + try: + ev = ProgressEvent( + phase=phase, + done=done, + total=total, + bytes_processed=bytes_processed, + timestamp=timestamp, + ) + _emit_progress_event(ev) + except Exception: + # Must be non-raising + pass + + +# CLI discovery helpers + + +def default_image_basename(source_root: Path) -> str: + """Wrapper around utils.default_image_basename to keep public API stable.""" + return utils.default_image_basename(source_root) + + +def normalize_output_path(path_arg: str, desired_suffix: str, adjust: bool = True) -> tuple[Path, bool]: + """Wrapper around utils.normalize_output_path.""" + return utils.normalize_output_path(path_arg, desired_suffix, adjust) + + +def _run_help_command(cmd: list[str], timeout: float) -> tuple[str | None, str | None]: + """Run a help subprocess, return (stdout, error) where error is set on failure.""" + try: + result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) + return result.stdout, None + except subprocess.CalledProcessError as exc: + return None, f"subprocess failed: {exc}" + except subprocess.TimeoutExpired: + return None, "timeout" + except (OSError, FileNotFoundError) as exc: + return None, f"exec error: {exc}" + + +def _parse_top_level_commands(help_text: str) -> dict[str, str]: + """Parse top-level subcommands and short help from a help text. + + Returns mapping command -> short_help. Conservative heuristic: lines that + start with a token (no leading hyphen) and have at least two spaces then + text are considered candidates. + """ + commands: dict[str, str] = {} + for line in help_text.splitlines(): + # Skip option lines + if line.lstrip().startswith("-"): + continue + m = re.match(r"^\s{0,4}([a-z0-9_-]+)\s{2,}(.*)$", line, flags=re.IGNORECASE) + if m: + name = m.group(1).strip() + short = m.group(2).strip() + # ignore generic headers and usage markers + if name.lower() in {"usage", "options", "positional"}: + continue + commands.setdefault(name, short) + return commands + + +def get_cli_metadata(prefer_import: bool = True) -> dict: + """Return CLI metadata for programmatic discovery. + + If prefer_import is True and a package import-time cli_metadata is + present (mkpfs.cli_metadata), return it with ``source: 'import'``. + Otherwise perform a safe, timeboxed help fallback that runs the package + CLI with ``--help`` and per-subcommand ``--help``. Failures/timeouts are + recorded and do not raise. + """ + # First try import-time metadata when requested and available. + if prefer_import: + try: + from . import cli_metadata as meta + except Exception: + meta = None + if meta: + return {"source": "import", "commands": meta} + + # Fallback: run help parsing + start = time.time() + metadata: dict[str, Any] = {"source": "help", "commands": {}, "errors": []} + + # Choose executable: prefer installed "mkpfs" if on PATH, else use python -m mkpfs + exe = shutil.which("mkpfs") + base_cmd = [exe] if exe else [sys.executable, "-m", "mkpfs"] + + stdout, err = _run_help_command([*base_cmd, "--help"], timeout=_SUBPROCESS_TIMEOUT) + if stdout is None: + metadata["errors"].append({"cmd": [*base_cmd, "--help"], "err": err}) + return metadata + + # Parse top-level commands + top_commands = _parse_top_level_commands(stdout) + for name, short in top_commands.items(): + if time.time() - start > _GLOBAL_HELP_WALLCAP: + metadata["errors"].append({"stage": "wallcap_exceeded"}) + break + out, perr = _run_help_command([*base_cmd, name, "--help"], timeout=_SUBPROCESS_TIMEOUT) + metadata["commands"][name] = { + "short_help": short, + "help": out, + "help_error": perr, + # Help-parsed options could be added here by more advanced parsing. + "options": None, + } + return metadata diff --git a/mkpfs/gui.py b/mkpfs/gui.py index 7ef38e4..ce020f6 100644 --- a/mkpfs/gui.py +++ b/mkpfs/gui.py @@ -1,13 +1,17 @@ """Graphical user interface for mkpfs - futuristic neon glassmorphism design.""" +from __future__ import annotations + +import argparse import builtins import contextlib import io import queue import threading +from dataclasses import dataclass, field from pathlib import Path from tkinter import filedialog -from typing import Any, ClassVar +from typing import Any, Callable import customtkinter as ctk from PIL import Image, ImageTk @@ -555,8 +559,7 @@ def __init__(self, parent: Any, **kwargs: Any) -> None: parent: Parent widget. **kwargs: Extra keyword arguments forwarded to CTkFrame. """ - kwargs.setdefault("height", 240) - kwargs.setdefault("height", 240) + # No explicit height — pack(expand=True) distribution handles sizing. super().__init__( parent, fg_color=_BG_INPUT, @@ -714,6 +717,468 @@ def __init__( ) +# --------------------------------------------------------------------------- +# Argparse option metadata extraction +# --------------------------------------------------------------------------- + +# Key for 'pack folder' and 'pack file' subcommands. The root parser uses +# 'command' and 'pack_command' to mirror argparse's nested subparser dests. +_PACK_SUBKEY: dict[str, str] = { + "pack folder": "pack.folder", + "pack file": "pack.file", + "pack exfat": "pack.exfat", +} + + +@dataclass +class ArgOption: + """Normalised representation of a single CLI argument for the form builder.""" + + dest: str + """Argparse dest (e.g. 'source_dir', 'no_compress').""" + flags: list[str] = field(default_factory=list) + """CLI flag strings (e.g. ['--source-dir'], ['--compress','--no-compress']).""" + flag_str: str = "" + """Primary flag for argv construction (e.g. '--no-compress', '--version').""" + help: str = "" + """Help text from argparse.""" + required: bool = False + """Whether this argument is required.""" + kind: str = "text" + """Widget hint: 'text', 'bool', 'choice', 'path-folder', 'path-open', + 'path-save', 'append'.""" + default: Any = None + """Default value (str, bool, int, or None).""" + choices: list[str] | None = None + """Allowed values for choice/enum arguments.""" + group: str = "" + """Logical section label for grouping related controls (e.g. 'paths', 'options').""" + positional: bool = False + """True for positional arguments that appear first.""" + negate_flag: str = "" + """For store_true flags: the flag that disables (e.g. '--no-compress' when + default is True). Used to present a checkbox that defaults to on.""" + int_type: bool = False + """True for int-typed arguments; entry validates as numeric.""" + + +# Accent palette per top-level subcommand (static map + dynamic fallback). +_DYNAMIC_ACCENTS: dict[str, str] = { + "pack": _NEON_BLUE, + "verify": _NEON_GREEN, + "inspect": _NEON_PURPLE, + "tree": _NEON_AMBER, + "unpack": _NEON_PINK, +} + +# Fallback accent colour when a subcommand isn't in the map. +_DEFAULT_DYNAMIC_ACCENT: str = _NEON_BLUE + + +# Expanded accent palette for nested pack subcommands. +_DYNAMIC_ACCENTS_PACK: dict[str, str] = { + "pack.folder": _NEON_BLUE, + "pack.file": _NEON_CYAN, + "pack.exfat": _NEON_BLUE, +} + + +def _accent_for_cmd_path(cmd_path: str) -> str: + """Resolve the neon accent for a (possibly nested) command path. + + Args: + cmd_path: Dot-joined command path, e.g. ``"pack.folder"`` or ``"tree"``. + + Returns: + Hex colour string. + """ + if cmd_path in _DYNAMIC_ACCENTS_PACK: + return _DYNAMIC_ACCENTS_PACK[cmd_path] + top: str = cmd_path.split(".")[0] + return _DYNAMIC_ACCENTS.get(top, _DEFAULT_DYNAMIC_ACCENT) + + +def _get_argparse_metadata() -> dict[str, list[ArgOption]]: + """Introspect the live argparse parsers and return structured metadata. + + Calls ``cli_mkpfs_main_parsers()``, walks the subparser hierarchy, and + extracts every argument's dest, flags, default, type, and required flag. + The result is a dict mapping a dot-joined command path + (e.g. ``"pack.folder"``) to an ordered list of ``ArgOption`` objects. + + Returns: + Mapping from command path to ordered argument list. Empty dict when + the ``mkpfs.cli`` module cannot be imported. + """ + try: + from mkpfs.cli import cli_mkpfs_main_parsers + except ImportError: + return {} + + root: Any = cli_mkpfs_main_parsers() + result: dict[str, list[ArgOption]] = {} + + # Walk subparsers. argparse's internal _subparsers._group_actions[0] + # contains each subparser action, each with .choices mapping name->parser. + if not hasattr(root, "_subparsers"): + return {} + top_group: Any = getattr(root._subparsers, "_group_actions", [None])[0] + if top_group is None: + return {} + + for name, parser in top_group.choices.items(): + _walk_parser(parser, [name], result) + return result + + +def _walk_parser( + parser: Any, + path: list[str], + result: dict[str, list[ArgOption]], +) -> None: + """Recursively walk an argparse parser extracting argument metadata.""" + cmd_key: str = ".".join(path) + + # Collect actions by dest. Multiple actions for the same dest happen + # with toggle pairs (store_true + store_false on same dest) and mutually + # exclusive groups with separate flag actions. + actions_by_dest: dict[str, list[Any]] = {} + for action in parser._actions: + if action.dest in ("help", "command", "pack_command"): + continue + if isinstance(action, argparse._SubParsersAction): + continue + actions_by_dest.setdefault(action.dest, []).append(action) + + # Now convert each dest group to an ArgOption. + options: list[ArgOption] = [] + + for dest, actions in actions_by_dest.items(): + # Gather all flags across all actions for this dest. + all_flags: list[str] = [] + for a in actions: + all_flags.extend(a.option_strings) + + # Use the first action as representative. + primary: Any = actions[0] + cls_name: str = primary.__class__.__name__ + is_pos: bool = not bool(primary.option_strings) + + opt: ArgOption = ArgOption( + dest=dest, + flags=list(all_flags), + help=getattr(primary, "help", "") or "", + positional=is_pos, + ) + + if is_pos: + opt.required = True + dl: str = dest.lower() + if "source" in dl or ("dir" in dl and "output" not in dl): + opt.kind = "path-folder" + opt.group = "paths" + elif "image" in dl or ("file" in dl and "output" not in dl): + opt.kind = "path-open" + opt.group = "paths" + elif "output" in dl or "out" in dl: + opt.kind = "path-save" + opt.group = "paths" + else: + opt.group = "paths" + options.append(opt) + continue + + # Optional argument. + default_val: Any = getattr(primary, "default", None) + + # Detect store_true / store_false. + if cls_name == "_StoreTrueAction" or cls_name == "_StoreFalseAction": + # Check if there's a counterpart (toggle pair). + has_store_true: bool = any(a.__class__.__name__ == "_StoreTrueAction" for a in actions) + has_store_false: bool = any(a.__class__.__name__ == "_StoreFalseAction" for a in actions) + + if has_store_true and has_store_false: + # Toggle pair: e.g. --adjust-output-file-extension / + # --no-adjust-output-file-extension both share same dest. + # The store_true action's default is the truth. + store_true_act: Any = next(a for a in actions if a.__class__.__name__ == "_StoreTrueAction") + opt.kind = "bool" + opt.default = bool(store_true_act.default) + opt.group = "options" + # Primary flag is the store_true variant. + opt.flag_str = store_true_act.option_strings[0] if store_true_act.option_strings else "" + elif cls_name == "_StoreTrueAction": + opt.kind = "bool" + opt.default = bool(default_val) if default_val is not None else False + opt.group = "options" + opt.flag_str = primary.option_strings[0] if primary.option_strings else "" + else: + # Lone store_false — unusual but handle it. + opt.kind = "bool" + opt.default = bool(default_val) if default_val is not None else True + opt.group = "options" + opt.flag_str = primary.option_strings[0] if primary.option_strings else "" + + elif getattr(primary, "choices", None): + opt.kind = "choice" + opt.choices = list(primary.choices) + opt.group = "options" + if default_val is not None: + opt.default = str(default_val) + opt.flag_str = primary.option_strings[0] if primary.option_strings else "" + + elif isinstance(primary, argparse._AppendAction): + opt.kind = "append" + opt.group = "options" + opt.default = [] + opt.flag_str = primary.option_strings[0] if primary.option_strings else "" + + else: + # General store (text, int, path). + opt.group = "options" + if default_val is not None and default_val is not argparse.SUPPRESS: + opt.default = str(default_val) + opt.flag_str = primary.option_strings[0] if primary.option_strings else "" + + # Detect int type. + if getattr(primary, "type", None) is int: + opt.int_type = True + opt.kind = "text" + else: + opt.kind = "text" + + # Path detection by name. + dl = dest.lower() + if "key" in dl or "ekpfs" in dl: + opt.group = "encryption" + elif "crc" in dl or "sha" in dl or "hash" in dl: + opt.group = "hashes" + elif (("folder" in dl or "temp" in dl) and opt.kind == "text") or ("source" in dl and "dir" in dl): + opt.kind = "path-folder" + opt.group = "paths" + elif "source" in dl and "file" in dl: + opt.kind = "path-open" + opt.group = "paths" + + options.append(opt) + + # Sort: positionals first, then by dest. + result[cmd_key] = sorted(options, key=lambda o: (not o.positional, o.dest)) + + # Recurse into nested subparsers. + if hasattr(parser, "_subparsers"): + sub_group: Any = getattr(parser._subparsers, "_group_actions", [None])[0] + if sub_group is not None: + for sname, sparser in sub_group.choices.items(): + _walk_parser(sparser, [*path, sname], result) + + +# Lazy cached metadata so we only introspect argparse once. +_cache_argparse_meta: dict[str, list[ArgOption]] | None = None + + +def _ensure_argparse_meta() -> dict[str, list[ArgOption]]: + """Return cached argparse metadata, building on first call.""" + global _cache_argparse_meta + if _cache_argparse_meta is None: + _cache_argparse_meta = _get_argparse_metadata() + return _cache_argparse_meta + + +# --------------------------------------------------------------------------- +# CLI metadata discovery helpers +# --------------------------------------------------------------------------- + + +def _discover_cli_metadata() -> dict[str, list[ArgOption]]: + """Discover CLI commands and options through the discovery chain. + + Priority order: + 1. ``mkpfs.discovery.get_cli_metadata(prefer_import=True)`` + 2. Argparse introspection via ``_ensure_argparse_meta()`` + 3. Subprocess help-text parsing fallback. + + Returns: + Mapping from command path to ordered ``ArgOption`` list. Empty dict + when all discovery methods fail. + """ + result: dict[str, list[ArgOption]] = {} + + # Tier 1: use the discovery module's get_cli_metadata (import or help fallback). + try: + from mkpfs.discovery import get_cli_metadata + + meta = get_cli_metadata(prefer_import=True) + if meta.get("commands"): + result = _convert_discovery_meta(meta) + if result and any(result.values()): + return result + except Exception: + pass + + # Tier 2: live argparse introspection. + try: + result = _ensure_argparse_meta() + if result: + return result + except Exception: + pass + + # Tier 3: subprocess help-text parsing (already attempted in tier 1 when + # prefer_import failed; re-run without the import path if needed). + with contextlib.suppress(Exception): + result = _parse_help_text_metadata() + + return result + + +def _convert_discovery_meta(meta: dict) -> dict[str, list[ArgOption]]: + """Convert discovery metadata into ArgOption lists. + + The ``get_cli_metadata()`` return value only provides command names + + help text when coming from the help fallback. When enriched opts are + present we take them; otherwise we synthesise a minimal positional- + only form for each command so the sidebar can still be built. + """ + result: dict[str, list[ArgOption]] = {} + commands: dict = meta.get("commands", {}) + for cmd_name, cmd_data in sorted(commands.items()): + if isinstance(cmd_data, str): + # Short help string only. + result[cmd_name] = [] + continue + if isinstance(cmd_data, dict): + help_text: str | None = cmd_data.get("help") + raw_opts: list | None = cmd_data.get("options") + if raw_opts: + opts = [ArgOption(**o) for o in raw_opts] + elif help_text: + opts = _parse_options_from_help(help_text, cmd_name) + else: + opts = [] + result[cmd_name] = opts + + return result + + +def _parse_options_from_help(help_text: str, cmd_name: str) -> list[ArgOption]: + """Parse ArgOption list from argparse-generated --help output. + + This is a best-effort parser that extracts positional arguments, + optional flags, choices, and help text from the standard argparse + help format. + """ + import re as _re + + options: list[ArgOption] = [] + lines: list[str] = help_text.splitlines() + + # Detect positional arguments block. + in_positionals: bool = False + in_options: bool = False + + for line in lines: + stripped: str = line.strip() + + # Section headers. + if _re.match(r"^positional arguments:\s*$", stripped, _re.IGNORECASE): + in_positionals = True + in_options = False + continue + if _re.match(r"^(optional arguments|options):\s*$", stripped, _re.IGNORECASE): + in_positionals = False + in_options = True + continue + + if not (in_positionals or in_options): + continue + + # Match " NAME help text" for positionals. + m = _re.match(r"^\s{2,}([a-z][a-z0-9_-]+)\s{2,}(.*)$", stripped, _re.IGNORECASE) + if m and in_positionals: + opt = ArgOption( + dest=m.group(1), + help=m.group(2), + positional=True, + required=True, + ) + dl: str = opt.dest.lower() + if "source" in dl or ("dir" in dl and "output" not in dl): + opt.kind = "path-folder" + opt.group = "paths" + elif "image" in dl or ("file" in dl and "output" not in dl): + opt.kind = "path-open" + opt.group = "paths" + elif "output" in dl or "out" in dl: + opt.kind = "path-save" + opt.group = "paths" + else: + opt.kind = "text" + opt.group = "paths" + options.append(opt) + continue + + # Match " -f, --flag [VALUE] help text" for optionals. + # Also handle " --flag [VALUE] help" (single flag). + m = _re.match( + r"^\s{2,}(-[a-zA-Z0-9]\s*,\s*)?(--[a-z0-9][a-z0-9-]*)(\s+[A-Z][A-Z_]*)?\s{2,}(.*)$", + stripped, + ) + if m and in_options: + flag_str: str = m.group(2) + metavar: str | None = m.group(3) + help_str: str = m.group(4) or "" + dest: str = flag_str.lstrip("-").replace("-", "_") + + opt = ArgOption( + dest=dest, + flags=[flag_str], + flag_str=flag_str, + help=help_str, + positional=False, + group="options", + ) + + # Heuristic kind detection from flag/help. + if metavar: + metavar_upper: str = metavar.strip().upper() + if metavar_upper in {"PATH", "DIR", "FILE", "FOLDER", "IMAGE"}: + if "out" in dest.lower() or "output" in help_str.lower(): + opt.kind = "path-save" + opt.group = "paths" + elif "folder" in dest.lower() or "temp" in dest.lower() or "dir" in dest.lower(): + opt.kind = "path-folder" + opt.group = "paths" + else: + opt.kind = "path-open" + opt.group = "paths" + elif "flag" in help_str.lower() or "no-" in flag_str: + opt.kind = "bool" + opt.default = "no-" not in flag_str + options.append(opt) + + return options + + +def _parse_help_text_metadata() -> dict[str, list[ArgOption]]: + """Fallback discovery via subprocess help-text parsing. + + Runs ``mkpfs --help`` and each per-command ``mkpfs --help`` + through a subprocess, extracting command names and help text. The + ArgOption lists are built by ``_parse_options_from_help``. + """ + result: dict[str, list[ArgOption]] = {} + try: + from mkpfs.discovery import get_cli_metadata + + meta = get_cli_metadata(prefer_import=False) + if meta.get("commands"): + result = _convert_discovery_meta(meta) + except Exception: + pass + return result + + # --------------------------------------------------------------------------- # Panel base class # --------------------------------------------------------------------------- @@ -741,6 +1206,10 @@ def __init__(self, parent: Any) -> None: self._log_queue: queue.Queue[tuple[str, str]] = queue.Queue() self._accent: str = _PANEL_ACCENT.get(self._panel_key, _NEON_BLUE) + # Progress handler registration for progress mirroring. + self._unregister_progress: Callable[[], None] | None = None + self._try_register_progress_handler() + # Header header: ctk.CTkFrame = ctk.CTkFrame(self, fg_color="transparent") header.pack(fill="x", padx=24, pady=(22, 0)) @@ -848,14 +1317,45 @@ def _run_command(self) -> None: """Execute the operation; runs inside a background thread.""" raise NotImplementedError + def _try_register_progress_handler(self) -> None: + """Register a progress event handler for progress-bar mirroring. + + When ``mkpfs.discovery.register_progress_handler`` is importable, + subscribes to progress events and updates this panel's progress bar + to ``determinate`` mode with the fraction done/total. Stores the + unregister callable so the subscription can be cleaned up on destroy. + """ + with contextlib.suppress(Exception): + from mkpfs.discovery import register_progress_handler + + def _on_progress(event: Any) -> None: + """Mirror a ProgressEvent into this panel's progress bar.""" + with contextlib.suppress(Exception): + pct: float = event.done / max(event.total, 1) + self._progress.set(pct) + self.after(0, lambda: self._progress.configure(mode="determinate")) + + self._unregister_progress = register_progress_handler(_on_progress) + + def destroy(self) -> None: + """Clean up progress handler subscription and destroy the widget.""" + if self._unregister_progress is not None: + with contextlib.suppress(Exception): + self._unregister_progress() + self._unregister_progress = None + super().destroy() + def _on_run(self) -> None: - """Clear log and launch the background worker thread.""" + """Clear log, reset progress, and launch the background worker thread.""" if self._busy: return self._log.clear() self._busy = True self._run_btn.configure(state="disabled", text=tr("running")) - self._progress.start() + # Reset progress bar to zero determinate before each new command. + self._progress.stop() + self._progress.configure(mode="determinate") + self._progress.set(0) threading.Thread(target=self._worker, daemon=True).start() def _worker(self) -> None: @@ -1019,6 +1519,10 @@ def __init__(self, parent: Any) -> None: self._verify_after: ctk.BooleanVar = ctk.BooleanVar(value=False) self._dry_run: ctk.BooleanVar = ctk.BooleanVar(value=False) self._temp_folder: ctk.StringVar = ctk.StringVar() + # Output-prefill tracking: remembers the last autofilled output path so + # user manual edits are not overwritten on subsequent source changes. + self._last_autofilled_output: str = "" + self._src.trace_add("write", lambda *_: self._prefill_output()) super().__init__(parent) def _build_controls(self, card: GlassCard) -> None: @@ -1083,6 +1587,45 @@ def _build_controls(self, card: GlassCard) -> None: browse_label=tr("browse"), ).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + def _prefill_output(self) -> None: + """Autofill the output path from the source path. + + Generates a default output filename from the command path + (``"pack.folder"``) and the source folder value. Only autofills + when the output field is empty or still contains the last + autofilled value (user has not manually edited it). + """ + import pathlib + + src_val: str = self._src.get().strip() + if not src_val: + return + src_path: pathlib.Path = pathlib.Path(src_val) + + fallback_basename: str = src_path.name if src_path.is_dir() else src_path.stem + fallback_basename = ( + "".join(c if (c.isalnum() or c in "_-.") else "_" for c in fallback_basename).strip(".") or "image" + ) + + default_out: str = "" + try: + from mkpfs.discovery import default_output_name + from mkpfs.discovery import normalize_output_path as norm_fn + + basename: str = default_output_name("pack.folder", src_path) + parent_dir: pathlib.Path = src_path.parent if src_path.is_absolute() else pathlib.Path() + suggested_str: str = str(parent_dir / f"{basename}.ffpfsc") + result_path, _changed = norm_fn(suggested_str, ".ffpfsc") + default_out = str(result_path) + except Exception: + parent_dir = src_path.parent if src_path.is_absolute() else pathlib.Path() + default_out = str(parent_dir / f"{fallback_basename}.ffpfsc") + + current: str = self._out.get().strip() + if not current or current == self._last_autofilled_output: + self._out.set(default_out) + self._last_autofilled_output = default_out + def _run_command(self) -> None: src: str = self._src.get().strip() out: str = self._out.get().strip() @@ -1124,6 +1667,10 @@ def __init__(self, parent: Any) -> None: self._version: ctk.StringVar = ctk.StringVar(value="PS4") self._compress: ctk.BooleanVar = ctk.BooleanVar(value=True) self._temp_folder: ctk.StringVar = ctk.StringVar() + # Output-prefill tracking: remembers the last autofilled output path so + # user manual edits are not overwritten on subsequent source changes. + self._last_autofilled_output: str = "" + self._src.trace_add("write", lambda *_: self._prefill_output()) super().__init__(parent) def _build_controls(self, card: GlassCard) -> None: @@ -1183,6 +1730,45 @@ def _build_controls(self, card: GlassCard) -> None: browse_label=tr("browse"), ).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + def _prefill_output(self) -> None: + """Autofill the output path from the source path. + + Generates a default output filename from the command path + (``"pack.file"``) and the source file value. Only autofills when + the output field is empty or still contains the last autofilled + value (user has not manually edited it). + """ + import pathlib + + src_val: str = self._src.get().strip() + if not src_val: + return + src_path: pathlib.Path = pathlib.Path(src_val) + + fallback_basename: str = src_path.stem or src_path.name + fallback_basename = ( + "".join(c if (c.isalnum() or c in "_-.") else "_" for c in fallback_basename).strip(".") or "image" + ) + + default_out: str = "" + try: + from mkpfs.discovery import default_output_name + from mkpfs.discovery import normalize_output_path as norm_fn + + basename: str = default_output_name("pack.file", src_path) + parent_dir: pathlib.Path = src_path.parent if src_path.is_absolute() else pathlib.Path() + suggested_str: str = str(parent_dir / f"{basename}.ffpfsc") + result_path, _changed = norm_fn(suggested_str, ".ffpfsc") + default_out = str(result_path) + except Exception: + parent_dir = src_path.parent if src_path.is_absolute() else pathlib.Path() + default_out = str(parent_dir / f"{fallback_basename}.ffpfsc") + + current: str = self._out.get().strip() + if not current or current == self._last_autofilled_output: + self._out.set(default_out) + self._last_autofilled_output = default_out + def _run_command(self) -> None: src: str = self._src.get().strip() out: str = self._out.get().strip() @@ -1580,6 +2166,575 @@ def _run_command(self) -> None: self._run_mkpfs(args) +# --------------------------------------------------------------------------- +# Dynamic panel -- builds controls from argparse metadata +# --------------------------------------------------------------------------- + +# Heuristic filetype filters for path fields. +_PFS_IMAGE_FILETYPES: list[tuple[str, str]] = [("PFS image", "*.ffpfs *.ffpfsc"), ("All files", "*.*")] +_PFSC_FILETYPES: list[tuple[str, str]] = [("PFS image", "*.ffpfsc"), ("All files", "*.*")] +_FFPFS_FILETYPES: list[tuple[str, str]] = [("PFS image", "*.ffpfs"), ("All files", "*.*")] + + +def _filetypes_for_option(opt: ArgOption) -> list[tuple[str, str]] | None: + """Return a sensible filetype filter for a path option based on its dest name.""" + dl: str = opt.dest.lower() + if ("image" in dl or ("file" in dl and opt.positional)) and opt.kind == "path-open": + return _PFS_IMAGE_FILETYPES + if opt.kind == "path-save" and ("image" in dl or "out" in dl) and ("pfs" in dl or "image" in dl): + return _PFS_IMAGE_FILETYPES + return None + + +def _path_mode_for_option(opt: ArgOption) -> str: + """Return 'folder', 'open', or 'save' based on dest name heuristics.""" + if opt.kind == "path-folder": + return "folder" + if opt.kind == "path-save": + return "save" + return "open" + + +def _human_label(dest: str) -> str: + """Derive a human-readable label from an argparse dest name. + + Args: + dest: e.g. 'source_dir', 'ekpfs_key', 'no_compress'. + + Returns: + Title-case space-separated label. + """ + # Drop the 'no_' prefix for display. + name: str = dest.removeprefix("no_") + # Replace underscores with spaces and title-case. + return " ".join(w.capitalize() for w in name.split("_")) + + +def _placeholder_for_option(opt: ArgOption) -> str: + """Return a placeholder string for an option's entry field.""" + dl: str = opt.dest.lower() + if "crc" in dl: + return "e.g. 7F528D1F" + if "sha" in dl: + return "64 hex chars…" + if "ekpfs" in dl: + return "64 hex chars…" + if opt.kind in ("path-folder", "path-open", "path-save"): + return f"Select {opt.dest.replace('_', ' ')}…" + if opt.choices: + return "" + return "" + + +class _RepeatableRow(ctk.CTkFrame): + """A row in a repeatable (append-action) argument list: entry + remove button.""" + + def __init__( + self, + parent: Any, + remove_cb: Callable[[_RepeatableRow], None], + accent: str = _NEON_BLUE, + ) -> None: + super().__init__(parent, fg_color="transparent") + self._remove_cb: Callable[[_RepeatableRow], None] = remove_cb + self._var: ctk.StringVar = ctk.StringVar() + + self.columnconfigure(0, weight=1) + ctk.CTkEntry( + self, + textvariable=self._var, + fg_color=_BG_INPUT, + border_color=_BORDER_BRIGHT, + corner_radius=8, + font=_FONT_UI, + text_color=_TEXT_PRIMARY, + ).grid(row=0, column=0, sticky="ew", padx=(0, 6)) + + ctk.CTkButton( + self, + text="-", + width=32, + height=32, + corner_radius=8, + fg_color=_BG_PANEL, + hover_color=_ERROR, + border_width=1, + border_color=_BORDER_BRIGHT, + font=_FONT_LABEL, + text_color=_TEXT_SECONDARY, + command=self._on_remove, + ).grid(row=0, column=1) + + def _on_remove(self) -> None: + self._remove_cb(self) + + @property + def value(self) -> str: + return self._var.get().strip() + + +class DynamicPanel(BasePanel): + """A panel whose controls are built dynamically from argparse metadata. + + Constructed with a command path string (e.g. ``"pack.folder"``) and the + ordered list of ``ArgOption`` objects for that command. + """ + + def __init__(self, parent: Any, cmd_path: str, options: list[ArgOption]) -> None: + """Initialise a DynamicPanel. + + Args: + parent: Parent widget. + cmd_path: Dot-joined command path, e.g. ``"pack.folder"``. + options: Ordered list of ArgOption describing CLI arguments. + """ + self._cmd_path: str = cmd_path + self._arg_options: list[ArgOption] = options + self._panel_key = cmd_path + self._accent_cfg: str = _accent_for_cmd_path(cmd_path) + + # Derive title/subtitle keys. + self._title_key = f"dyn_{cmd_path.replace('.', '_')}_title" + self._subtitle_key = f"dyn_{cmd_path.replace('.', '_')}_subtitle" + + # Add dynamic translations for this command. + # We use the cmd path parts to build the label. + cmd_parts: list[str] = cmd_path.split(".") + display_title: str = " ".join(w.capitalize() for w in cmd_parts) + + # Try to get a help-based subtitle from the metadata. + display_subtitle: str = "" + for o in options: + if o.help: + display_subtitle = o.help + break + + # Store the title/subtitle for all three locales. + for locale_key in ("en", "pt_BR", "es"): + _TRANSLATIONS.setdefault(locale_key, {})[self._title_key] = display_title + _TRANSLATIONS[locale_key][self._subtitle_key] = display_subtitle or display_title + + # Set accent before BasePanel.__init__ runs. + self._accent = self._accent_cfg + + # Field variables: dict[str, Any] mapping dest -> StringVar | BooleanVar | list[StringVar] + self._fields: dict[str, Any] = {} + # Repeatable list containers: dest -> list of _RepeatableRow + self._repeatable_rows: dict[str, list[_RepeatableRow]] = {} + + # Output prefill support. + self._source_positional_dests: list[str] = [] + self._output_positional_dests: list[str] = [] + # Track the last autofilled output value so we don't overwrite user edits. + self._last_autofilled_output: str = "" + + super().__init__(parent) + + def _build_controls(self, card: GlassCard) -> None: + """Build the form from ArgOption metadata.""" + card.columnconfigure(0, weight=1) + card.columnconfigure(1, weight=1) + + # Separate positionals and optionals. + positionals: list[ArgOption] = [o for o in self._arg_options if o.positional] + optionals: list[ArgOption] = [o for o in self._arg_options if not o.positional] + + # Section labels by group: tracks which groups we've emitted. + emitted_groups: dict[str, int] = {} + row: int = 0 + + def _section(group: str, r: int) -> int: + if group not in emitted_groups: + SectionLabel(card, tr(group), color=self._accent).grid( + row=r, column=0, columnspan=2, sticky="w", padx=16, pady=(14, 6) + ) + emitted_groups[group] = r + return r + 1 + return r + + # Track positional dests for output prefill. + self._source_positional_dests = [ + o.dest for o in positionals if o.kind in ("path-folder", "path-open") and o.required + ] + self._output_positional_dests = [o.dest for o in positionals if o.kind == "path-save"] + + # Render positionals first. + for opt in positionals: + row = _section(opt.group, row) + row = self._render_control(card, opt, row, span=True) + + # Add traces on required positional fields for continuous validation + # and output prefill. + for opt in positionals: + if not opt.required: + continue + field: Any = self._fields.get(opt.dest) + if isinstance(field, ctk.StringVar): + field.trace_add("write", lambda *_: self._update_run_button_state()) + + # Set up output prefill trace on source fields. + for src_dest in self._source_positional_dests: + src_var: Any = self._fields.get(src_dest) + if isinstance(src_var, ctk.StringVar): + src_var.trace_add("write", lambda *_: self._prefill_output()) + + # Separator between positional and optional sections. + if positionals and optionals: + ctk.CTkFrame(card, height=1, fg_color=_BORDER_BRIGHT).grid( + row=row, column=0, columnspan=2, sticky="ew", padx=16 + ) + row += 1 + + # Render optionals, grouping by section. + cur_group: str | None = None + opt_frame: ctk.CTkFrame | None = None + opt_row: int = 0 + + for opt in optionals: + if opt.group != cur_group or opt_frame is None: + # Flush previous frame. + if opt_frame is not None: + opt_frame.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 14)) + row += 1 + + row = _section(opt.group, row) + opt_frame = ctk.CTkFrame(card, fg_color="transparent") + opt_frame.columnconfigure((0, 1), weight=1) + opt_row = 0 + cur_group = opt.group + + opt_row = self._render_control(opt_frame, opt, opt_row, span=False) + + # Pack the last opt frame. + if opt_frame is not None: + opt_frame.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 14)) + row += 1 + + # Initial validation state. + self._update_run_button_state() + + def _render_control( + self, + parent: Any, + opt: ArgOption, + row: int, + span: bool = False, + ) -> int: + """Render a single control widget for the given option, returning next row.""" + col_span: int = 2 if span else 1 + col: int = 0 + + if opt.kind == "bool": + var: ctk.BooleanVar = ctk.BooleanVar(value=bool(opt.default)) + self._fields[opt.dest] = var + label: str = _human_label(opt.dest) + NeonCheckbox( + parent, + text=label, + variable=var, + accent=self._accent, + ).grid(row=row, column=col, columnspan=col_span, sticky="w", padx=(0, 8), pady=3) + return row + 1 + + elif opt.kind == "choice": + var = ctk.StringVar(value=str(opt.default) if opt.default else (opt.choices[0] if opt.choices else "")) + self._fields[opt.dest] = var + label = _human_label(opt.dest) + str_choices: list[str] = [str(c) for c in opt.choices] if opt.choices else [] + OptionRow( + parent, + label=label, + variable=var, + values=str_choices, + accent=self._accent, + ).grid(row=row, column=col, columnspan=col_span, sticky="ew", padx=(0, 8), pady=(0, 8)) + return row + 1 + + elif opt.kind in ("path-folder", "path-open", "path-save"): + var = ctk.StringVar(value=str(opt.default) if opt.default else "") + self._fields[opt.dest] = var + mode: str = _path_mode_for_option(opt) + ft: list[tuple[str, str]] | None = _filetypes_for_option(opt) + label = _human_label(opt.dest) + placeholder: str = _placeholder_for_option(opt) + PathRow( + parent, + label=label, + variable=var, + mode=mode, + filetypes=ft, + placeholder=placeholder, + browse_label=tr("browse"), + ).grid(row=row, column=col, columnspan=col_span, sticky="ew", padx=(0, 8), pady=(0, 10)) + return row + 1 + + elif opt.kind == "append": + # Repeatable list: label + add button + list of entry rows. + self._repeatable_rows.setdefault(opt.dest, []) + self._fields[opt.dest] = [] # list of StringVar managed via rows + + label = _human_label(opt.dest) + + # Label row with add button. + label_frame: ctk.CTkFrame = ctk.CTkFrame(parent, fg_color="transparent") + label_frame.grid(row=row, column=col, columnspan=col_span, sticky="ew", padx=(0, 8), pady=(0, 3)) + row += 1 + ctk.CTkLabel(label_frame, text=label, font=_FONT_LABEL, text_color=_TEXT_SECONDARY).pack( + side="left", anchor="w" + ) + + def _add_repeatable() -> None: + rrow: _RepeatableRow = _RepeatableRow( + self._list_frame, self._on_remove_repeatable, accent=self._accent + ) + self._repeatable_rows[opt.dest].append(rrow) + rrow.pack(fill="x", pady=(0, 4)) + self._fields[opt.dest].append(rrow._var) + + ctk.CTkButton( + label_frame, + text="+", + width=32, + height=24, + corner_radius=8, + fg_color=_BG_PANEL, + hover_color=self._accent, + border_width=1, + border_color=_BORDER_BRIGHT, + font=_FONT_LABEL, + text_color=_TEXT_SECONDARY, + command=_add_repeatable, + ).pack(side="right") + + # Container for repeatable rows. + self._list_frame: ctk.CTkFrame = ctk.CTkFrame(parent, fg_color="transparent") + self._list_frame.grid(row=row, column=col, columnspan=col_span, sticky="ew", padx=(0, 8), pady=(0, 8)) + return row + 1 + + else: + # Generic text/numeric entry. + default_str: str = str(opt.default) if opt.default else "" + var = ctk.StringVar(value=default_str) + self._fields[opt.dest] = var + label = _human_label(opt.dest) + placeholder = _placeholder_for_option(opt) + + # Entry with label above. + ef: ctk.CTkFrame = ctk.CTkFrame(parent, fg_color="transparent") + ef.grid(row=row, column=col, columnspan=col_span, sticky="ew", padx=(0, 8), pady=(0, 8)) + ctk.CTkLabel(ef, text=label, font=_FONT_LABEL, text_color=_TEXT_SECONDARY).pack(anchor="w", pady=(0, 3)) + font_used: tuple[str, int] = ( + _FONT_MONO if ("key" in opt.dest.lower() or "ekpfs" in opt.dest.lower()) else _FONT_UI + ) + ctk.CTkEntry( + ef, + textvariable=var, + placeholder_text=placeholder, + fg_color=_BG_INPUT, + border_color=_BORDER_BRIGHT, + corner_radius=8, + font=font_used, + text_color=_TEXT_PRIMARY, + ).pack(fill="x") + return row + 1 + + def _on_remove_repeatable(self, row_widget: _RepeatableRow) -> None: + """Remove a repeatable row from its container.""" + # Find which dest owns this row. + for dest, rows in self._repeatable_rows.items(): + if row_widget in rows: + rows.remove(row_widget) + row_widget.destroy() + # Also remove from fields list. + if dest in self._fields: + self._fields[dest] = [v for v in self._fields[dest] if v is not row_widget._var] + break + + def _validate_and_build_argv(self) -> list[str] | None: + """Validate required fields and build the argv list. + + Returns: + argv list ready for ``_run_mkpfs``, or None on validation failure. + """ + argv: list[str] = [] + + # Build the command path prefix. + cmd_parts: list[str] = self._cmd_path.split(".") + argv.extend(cmd_parts) + + # Collect positional values first. + positionals: list[ArgOption] = [o for o in self._arg_options if o.positional] + for opt in positionals: + value: str = "" + field: Any = self._fields.get(opt.dest) + if field is not None: + if isinstance(field, ctk.StringVar): + value = field.get().strip() + elif isinstance(field, ctk.BooleanVar): + value = str(field.get()) + if opt.required and not value: + self._emit(f"✗ {_human_label(opt.dest)} is required.", "error") + return None + if value: + argv.append(value) + + # Collect optional values. + optionals: list[ArgOption] = [o for o in self._arg_options if not o.positional] + # sort by position in options list + for opt in optionals: + field = self._fields.get(opt.dest) + + if opt.kind == "bool": + if isinstance(field, ctk.BooleanVar): + val: bool = field.get() + # Determine which flag to emit. + # For toggle pairs (store_true + store_false on same dest), + # the flag_str comes from the store_true variant. + # For store_true with default=True (i.e. negate_flag), + # the default is on, and the user unchecked → emit the negate flag. + flag: str = opt.flag_str + if not flag: + flag = f"--{opt.dest.replace('_', '-')}" + + # Heuristic: if default is True and value is False, emit the negation. + # Check if there's a flag containing 'no-' for this dest. + no_flags: list[str] = [f for f in opt.flags if f.startswith("--no-")] + + if opt.default is True and not val and no_flags: + argv.append(no_flags[0]) + elif opt.default is False and val: + # Emit the regular flag (pick one that doesn't start with --no-). + yes_flags: list[str] = [f for f in opt.flags if not f.startswith("--no-")] + if yes_flags: + argv.append(yes_flags[0]) + else: + argv.append(flag) + elif (opt.default is True and val) or (opt.default is False and not val): + # Default matches; no flag needed. + pass + + elif opt.kind == "choice": + if isinstance(field, ctk.StringVar): + val = field.get().strip() + default_s: str = str(opt.default) if opt.default else "" + if val and val != default_s: + argv.append(opt.flag_str) + argv.append(val) + + elif opt.kind == "append": + rows: list[_RepeatableRow] = self._repeatable_rows.get(opt.dest, []) + for rrow in rows: + v: str = rrow.value + if v: + argv.append(opt.flag_str) + argv.append(v) + + else: + # text, path-*. + if isinstance(field, ctk.StringVar): + val = field.get().strip() + default_s = str(opt.default) if opt.default else "" + if val and val != default_s: + if opt.flag_str: + argv.append(opt.flag_str) + argv.append(val) + + return argv + + def _update_run_button_state(self) -> None: + """Enable or disable the Run button based on required field state. + + All required positional StringVars must contain a non-empty value + for the Run button to be enabled. This is called on every write + to a required-field variable via ``trace_add``. + """ + if self._busy: + return + all_filled: bool = True + for opt in self._arg_options: + if not opt.required: + continue + field: Any = self._fields.get(opt.dest) + if isinstance(field, ctk.StringVar) and not field.get().strip(): + all_filled = False + break + state: str = "normal" if all_filled else "disabled" + with contextlib.suppress(Exception): + self._run_btn.configure(state=state) + + def _prefill_output(self) -> None: + """Prefill output path fields from source positional args. + + Generates a default output basename from the command path and the first + source positional field's value by calling ``default_output_name`` from + the discovery module. Only autofills when the output field is empty or + still contains the previous autofill (user has not manually edited it + since the last autofill). + + The last autofilled value is tracked so command-option changes re-autofill + when the user has not made manual edits. + """ + import pathlib + + if not self._output_positional_dests or not self._source_positional_dests: + return + + # Collect the first non-empty source path value. + src_val: str = "" + for sd in self._source_positional_dests: + sv: Any = self._fields.get(sd) + if isinstance(sv, ctk.StringVar): + v: str = sv.get().strip() + if v: + src_val = v + break + + if not src_val: + return + + src_path: pathlib.Path = pathlib.Path(src_val) + stem: str = src_path.stem if src_path.suffix else src_path.name + + # Use the shared default_output_name API. + try: + from mkpfs.discovery import default_output_name + + basename: str = default_output_name(self._cmd_path, src_path) + except Exception: + basename = stem or "image" + + # Derive the suggested output path (same directory as source). + parent_dir: pathlib.Path = src_path.parent if src_path.is_absolute() else pathlib.Path() + suggested: pathlib.Path = parent_dir / f"{basename}.ffpfsc" + suggested_str: str = str(suggested) + + # Update output fields. + for od in self._output_positional_dests: + ov: Any = self._fields.get(od) + if isinstance(ov, ctk.StringVar): + current: str = ov.get().strip() + # Autofill when empty, or when it still matches the last autofill + # (meaning the user has not manually changed it). + if not current or current == self._last_autofilled_output: + try: + from mkpfs.discovery import normalize_output_path + + result_path, _changed = normalize_output_path(suggested_str, ".ffpfsc") + result_str: str = str(result_path) + ov.set(result_str) + self._last_autofilled_output = result_str + except Exception: + ov.set(suggested_str) + self._last_autofilled_output = suggested_str + + def _run_command(self) -> None: + """Validate fields and invoke mkpfs in-process.""" + argv: list[str] | None = self._validate_and_build_argv() + if argv is None: + return + self._run_mkpfs(argv) + + # --------------------------------------------------------------------------- # Sidebar navigation button # --------------------------------------------------------------------------- @@ -1636,33 +2791,124 @@ def set_active(self, active: bool) -> None: class MkPFSApp(ctk.CTk): - """Main application window with neon sidebar and language selector.""" + """Main application window with neon sidebar and language selector. + + At startup, attempts to discover commands via argparse introspection. + If that succeeds, the sidebar is built dynamically and each command gets + a ``DynamicPanel``. Otherwise, the original hard-coded panels are used + as a fallback. + """ - _PAGES: ClassVar[list[tuple[str, str, type, str]]] = [ - ("nav_pack_folder", "nav_pack_folder", PackFolderPanel, _NEON_BLUE), - ("nav_pack_file", "nav_pack_file", PackFilePanel, _NEON_CYAN), - ("nav_verify", "nav_verify", VerifyPanel, _NEON_GREEN), - ("nav_inspect", "nav_inspect", InspectPanel, _NEON_PURPLE), - ("nav_tree", "nav_tree", TreePanel, _NEON_AMBER), - ("nav_unpack", "nav_unpack", UnpackPanel, _NEON_PINK), - ] + _PAGES: list[tuple[str, str, type | None, str]] | None = None def __init__(self) -> None: """Initialise and configure the application window.""" super().__init__() self.title("MkPFS") - self.geometry("1120x780") - self.minsize(900, 620) + self.geometry("1200x800") + self.minsize(800, 600) self.configure(fg_color=_BG_DEEP) + class_pages: list[tuple[str, str, type | None, str]] | None = self._PAGES + self._pages: list[tuple[str, str, type | None, str]] = list( + class_pages + if class_pages is not None + else [ + ("nav_pack_folder", "nav_pack_folder", PackFolderPanel, _NEON_BLUE), + ("nav_pack_file", "nav_pack_file", PackFilePanel, _NEON_CYAN), + ("nav_verify", "nav_verify", VerifyPanel, _NEON_GREEN), + ("nav_inspect", "nav_inspect", InspectPanel, _NEON_PURPLE), + ("nav_tree", "nav_tree", TreePanel, _NEON_AMBER), + ("nav_unpack", "nav_unpack", UnpackPanel, _NEON_PINK), + ] + ) self._panels: dict[str, BasePanel] = {} self._nav_buttons: dict[str, NavButton] = {} self._active_key: str = "" + self._dynamic: bool = False + + # Try dynamic discovery. + self._try_dynamic_discovery() self._set_window_icon() + + # Main area: sidebar + content side-by-side, above the footer. + self._main_area: ctk.CTkFrame = ctk.CTkFrame(self, fg_color="transparent") + self._main_area.pack(fill="both", expand=True) + self._build_sidebar() self._build_content() - self._select(self._PAGES[0][0]) + + # Version footer bar (persistent across all window states). + self._build_footer() + + # Select first entry. + first_key: str = next(iter(self._nav_buttons)) + self._select(first_key) + + def _try_dynamic_discovery(self) -> None: + """Attempt CLI command discovery through the full discovery chain. + + Calls ``_discover_cli_metadata()`` which tries (in order): + 1. ``mkpfs.discovery.get_cli_metadata(prefer_import=True)`` + 2. Live argparse introspection + 3. Subprocess help-text parsing fallback + + On success, populates ``_PAGES`` from the discovered metadata. + On failure, keeps the hard-coded fallback list. + """ + try: + meta: dict[str, list[ArgOption]] = _discover_cli_metadata() + except Exception: + return + + if not meta: + return + + # Build dynamic pages list: (key, label_key, None, accent). + # The PanelClass is None for dynamic; we create DynamicPanel in _build_content. + dynamic_pages: list[tuple[str, str, type | None, str]] = [] + + # Navigation label emoji map by top-level command. + EMOJI: dict[str, str] = { + "pack": "📦", + "pack.folder": "📦", + "pack.file": "📄", + "pack.exfat": "📦", + "verify": "✅", + "inspect": "🔍", + "tree": "🌲", + "unpack": "📂", + } + + # Build nav entries. For top-level commands that have nested subcommands + # (pack.*), we use the nested versions directly. + seen: set[str] = set() + for cmd_path in meta: + if cmd_path in seen: + continue + seen.add(cmd_path) + + accent: str = _accent_for_cmd_path(cmd_path) + display_name: str = " ".join(w.capitalize() for w in cmd_path.replace(".", " ").split()) + emoji: str = EMOJI.get(cmd_path, "") + nav_label: str = f"{emoji} {display_name}" if emoji else display_name + + # Translation key for nav. + nav_key: str = f"dyn_nav_{cmd_path.replace('.', '_')}" + # Store nav label for all locales. + for lk in ("en", "pt_BR", "es"): + _TRANSLATIONS.setdefault(lk, {})[nav_key] = nav_label + + dynamic_pages.append((nav_key, nav_key, None, accent)) + + # Ensure we have at least the hard-coded set if nothing was discovered. + if not dynamic_pages: + return + + self._pages = list[tuple[str, str, type | None, str]](dynamic_pages) + self._dynamic = True + self._dynamic_meta: dict[str, list[ArgOption]] = meta def _set_window_icon(self) -> None: """Load icon.png from assets/images/ and apply it to the window. @@ -1685,14 +2931,39 @@ def _set_window_icon(self) -> None: self.wm_iconphoto(True, photo) # Keep a reference so the image is not garbage-collected by Python self._icon_ref: ImageTk.PhotoImage = photo - except (OSError, ValueError, RuntimeError): + except (OSError, ValueError, RuntimeError, Exception): # Icon setup is optional; ignore expected failures (file missing or unreadable). return + def _build_footer(self) -> None: + """Build the persistent version-footer bar at the bottom of the window. + + Displays ``mkpfs vX.Y.Z`` sourced from the mkpfs package version string + via runtime introspection. Visible in every window state (idle, running, + error). + """ + from mkpfs import __version__ + + footer: ctk.CTkFrame = ctk.CTkFrame( + self, + fg_color=_BG_PANEL, + corner_radius=0, + height=26, + ) + footer.pack(side="bottom", fill="x") + footer.pack_propagate(False) + + ctk.CTkLabel( + footer, + text=f"mkpfs v{__version__}", + font=("Segoe UI", 9), + text_color=_TEXT_MUTED, + ).pack(side="right", padx=(0, 14), pady=2) + def _build_sidebar(self) -> None: """Build the left navigation sidebar with language selector.""" sidebar: ctk.CTkFrame = ctk.CTkFrame( - self, + self._main_area, width=_SIDEBAR_W, fg_color=_BG_PANEL, corner_radius=0, @@ -1724,7 +2995,7 @@ def _build_sidebar(self) -> None: ) self._ops_label.pack(anchor="w", padx=16, pady=(0, 6)) - for key, label_key, _, accent in self._PAGES: + for key, label_key, _, accent in self._pages: btn: NavButton = NavButton( sidebar, text=tr(label_key), @@ -1762,21 +3033,27 @@ def _build_sidebar(self) -> None: command=self._on_lang_change, ).pack(fill="x") - self._ver_label: ctk.CTkLabel = ctk.CTkLabel( - sidebar, - text=tr("version_footer"), - font=("Segoe UI", 9), - text_color=_TEXT_MUTED, - ) - self._ver_label.pack(side="bottom", pady=12) + # Version label removed from sidebar — now lives in the footer bar. def _build_content(self) -> None: """Pre-instantiate all panels inside the content area.""" - self._content: ctk.CTkFrame = ctk.CTkFrame(self, fg_color="transparent") + self._content: ctk.CTkFrame = ctk.CTkFrame(self._main_area, fg_color="transparent") self._content.pack(side="left", fill="both", expand=True) - for key, _, PanelClass, _ in self._PAGES: - panel: BasePanel = PanelClass(self._content) - self._panels[key] = panel + + if self._dynamic: + # Dynamic panels: build from metadata. + for key, _, _, _ in self._pages: + # Extract cmd_path from the nav_key (strip dyn_nav_ prefix). + cmd_path: str = key.removeprefix("dyn_nav_").replace("_", ".") + options: list[ArgOption] = self._dynamic_meta.get(cmd_path, []) + panel: BasePanel = DynamicPanel(self._content, cmd_path, options) + self._panels[key] = panel + else: + # Fallback: hard-coded panels (PanelClass is never None here). + for key, _, PanelClass, _ in self._pages: + if PanelClass is not None: + panel: BasePanel = PanelClass(self._content) + self._panels[key] = panel def _select(self, key: str) -> None: """Switch the visible panel. @@ -1807,13 +3084,16 @@ def _on_lang_change(self, display_name: str) -> None: self._refresh_all_labels() def _refresh_all_labels(self) -> None: - """Propagate the new locale to all sidebar widgets and panels.""" + """Propagate the new locale to all sidebar widgets and panels. + + The version footer is static and does not change with locale. + """ self._subtitle_label.configure(text=tr("app_subtitle")) self._ops_label.configure(text=tr("operations")) self._lang_label_widget.configure(text=tr("lang_label")) - self._ver_label.configure(text=tr("version_footer")) - for key, label_key, _, _ in self._PAGES: - self._nav_buttons[key].configure(text=tr(label_key)) + for key, label_key, _, _ in self._pages: + if key in self._nav_buttons: + self._nav_buttons[key].configure(text=tr(label_key)) for panel in self._panels.values(): panel.refresh_labels() diff --git a/mkpfs/gui/__init__.py b/mkpfs/gui/__init__.py new file mode 100644 index 0000000..1b40e01 --- /dev/null +++ b/mkpfs/gui/__init__.py @@ -0,0 +1,58 @@ +"""mkpfs.gui package — compatibility layer that defers to the top-level shim. + +We must preserve the original import semantics where importing ``mkpfs.gui`` +returns the shim module located at ``mkpfs/gui.py`` (so that callers relying +on Path(__file__) for asset resolution keep working). To achieve that while +also providing a directory containing the refactored source files for +packaging/tooling, this package initializer loads the shim from the module +file and then replaces the package entry in sys.modules with that module +object. The shim module is given a __path__ pointing at this directory so +``mkpfs.gui.`` imports (e.g. ``mkpfs.gui.__main__``) continue to +work and submodules can be imported from the package directory. +""" +# ruff: noqa + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +# Path to the original top-level shim module (mkpfs/gui.py) +_shim_path = Path(__file__).parent.parent / "gui.py" +if _shim_path.exists(): + # Load the shim as a real module object and install it as "mkpfs.gui" in + # sys.modules so imports of "mkpfs.gui" resolve to the shim file while + # keeping the package directory present on disk. + spec = importlib.util.spec_from_file_location("mkpfs.gui", str(_shim_path)) + _shim = importlib.util.module_from_spec(spec) + + # Execute the shim in an isolated module object. This runs the shim's + # import-time side effects (CustomTkinter setup, translation tables, etc.) + # and makes its public API available to callers of the package import. + if spec and spec.loader: + spec.loader.exec_module(_shim) # type: ignore[attr-defined] + + # Copy public attributes from the shim onto this package module object. + _this_mod = sys.modules[__name__] + for _k, _v in vars(_shim).items(): + # Preserve existing package-level attributes like __path__ but copy + # most names so ``from mkpfs import gui`` yields the same API surface. + if _k in ("__name__", "__package__", "__path__"): + continue + setattr(_this_mod, _k, _v) + + # Expose the shim path as this module's __file__ so tools that rely on + # Path(__file__) resolve to the original shim location. + _this_mod.__file__ = str(_shim_path) + + # Keep a reference to the shim module under a private name to aid + # introspection/debugging. + sys.modules.setdefault("mkpfs.gui._shim", _shim) + +# Provide a dynamically-computed __all__ based on the shim's public names so +# static analysis tools don't complain about undefined exports. +try: + __all__ = [n for n in vars(_shim) if not n.startswith("_")] +except Exception: + __all__ = [] diff --git a/mkpfs/gui/__main__.py b/mkpfs/gui/__main__.py new file mode 100644 index 0000000..89c96a9 --- /dev/null +++ b/mkpfs/gui/__main__.py @@ -0,0 +1,47 @@ +"""Entry point for mkpfs.gui when executed with `python -m mkpfs.gui`. + +This module ensures multiprocessing.freeze_support() is called (required for +Windows/PyInstaller single-file builds) and then delegates to the GUI main() +function exposed by the original top-level shim module (mkpfs/gui.py). + +The delegation tries a direct import of mkpfs.gui (which should resolve to the +original shim module). If that import fails or yields the package itself, +this file will fall back to loading the shim file from disk and executing it +as the module name "mkpfs.gui" so the expected top-level API is available. +""" + +import importlib +import importlib.util +import multiprocessing +import sys +from pathlib import Path + + +def _load_shim_from_file() -> None: + """Load the top-level shim file mkpfs/gui.py as module 'mkpfs.gui'.""" + shim_path = Path(__file__).parent.parent / "gui.py" + spec = importlib.util.spec_from_file_location("mkpfs.gui", str(shim_path)) + module = importlib.util.module_from_spec(spec) + sys.modules["mkpfs.gui"] = module + assert spec and spec.loader + spec.loader.exec_module(module) + + +if __name__ == "__main__": + multiprocessing.freeze_support() + try: + # Try to import the public module name. In normal operation the shim + # at mkpfs/gui.py will be importable as mkpfs.gui and provide main(). + mod = importlib.import_module("mkpfs.gui") + except Exception: + # Fallback: explicitly load the shim file by path so this -m entry + # works even when package import semantics would otherwise shadow it. + _load_shim_from_file() + mod = importlib.import_module("mkpfs.gui") + + # Delegate to the GUI launcher + try: + main = mod.main + except AttributeError: + raise SystemExit("mkpfs.gui does not expose a main() function") from None + main() diff --git a/mkpfs/gui/app.py b/mkpfs/gui/app.py new file mode 100644 index 0000000..fcf0b1a --- /dev/null +++ b/mkpfs/gui/app.py @@ -0,0 +1,15 @@ +"""mkpfs.gui.app — thin façade pointing at the original shim module. + +This module exists to host a refactored implementation in the future. For +now it re-exports the public application entry points from the shim file so +both import styles work (import mkpfs.gui and import mkpfs.gui.app). +""" + +from importlib import import_module as _import_module + +_shim = _import_module("mkpfs.gui") + +MkPFSApp = _shim.MkPFSApp +main = _shim.main + +__all__ = ["MkPFSApp", "main"] diff --git a/mkpfs/gui/i18n.py b/mkpfs/gui/i18n.py new file mode 100644 index 0000000..b07082e --- /dev/null +++ b/mkpfs/gui/i18n.py @@ -0,0 +1,12 @@ +"""mkpfs.gui.i18n — re-export of internationalisation helpers from the shim.""" + +from importlib import import_module as _import_module + +_shim = _import_module("mkpfs.gui") + +_TRANSLATIONS = _shim._TRANSLATIONS +_LANG_NAMES = _shim._LANG_NAMES +_current_locale = _shim._current_locale +tr = _shim.tr + +__all__ = ["_LANG_NAMES", "_TRANSLATIONS", "_current_locale", "tr"] diff --git a/mkpfs/gui/panels.py b/mkpfs/gui/panels.py new file mode 100644 index 0000000..0193c8b --- /dev/null +++ b/mkpfs/gui/panels.py @@ -0,0 +1,23 @@ +"""mkpfs.gui.panels — re-export of panel classes from the shim module.""" + +from importlib import import_module as _import_module + +_shim = _import_module("mkpfs.gui") + +BasePanel = _shim.BasePanel +PackFolderPanel = _shim.PackFolderPanel +PackFilePanel = _shim.PackFilePanel +VerifyPanel = _shim.VerifyPanel +InspectPanel = _shim.InspectPanel +TreePanel = _shim.TreePanel +UnpackPanel = _shim.UnpackPanel + +__all__ = [ + "BasePanel", + "InspectPanel", + "PackFilePanel", + "PackFolderPanel", + "TreePanel", + "UnpackPanel", + "VerifyPanel", +] diff --git a/mkpfs/gui/theme.py b/mkpfs/gui/theme.py new file mode 100644 index 0000000..2cb2667 --- /dev/null +++ b/mkpfs/gui/theme.py @@ -0,0 +1,63 @@ +"""mkpfs.gui.theme — re-export of theme constants from the shim.""" + +from importlib import import_module as _import_module + +_shim = _import_module("mkpfs.gui") + +# Theme constants (exported names expected by legacy imports) +_BG_DEEP = _shim._BG_DEEP +_BG_PANEL = _shim._BG_PANEL +_BG_CARD = _shim._BG_CARD +_BG_INPUT = _shim._BG_INPUT +_BORDER_BRIGHT = _shim._BORDER_BRIGHT + +_TEXT_PRIMARY = _shim._TEXT_PRIMARY +_TEXT_SECONDARY = _shim._TEXT_SECONDARY +_TEXT_MUTED = _shim._TEXT_MUTED + +_NEON_BLUE = _shim._NEON_BLUE +_NEON_CYAN = _shim._NEON_CYAN +_NEON_GREEN = _shim._NEON_GREEN +_NEON_PURPLE = _shim._NEON_PURPLE +_NEON_AMBER = _shim._NEON_AMBER +_NEON_PINK = _shim._NEON_PINK + +_SUCCESS = _shim._SUCCESS +_ERROR = _shim._ERROR +_WARNING = _shim._WARNING + +_SIDEBAR_W = _shim._SIDEBAR_W +_CORNER = _shim._CORNER +_FONT_MONO = _shim._FONT_MONO +_FONT_UI = _shim._FONT_UI +_FONT_LABEL = _shim._FONT_LABEL +_FONT_SMALL = _shim._FONT_SMALL + +_PANEL_ACCENT = _shim._PANEL_ACCENT + +__all__ = [ + "_BG_CARD", + "_BG_DEEP", + "_BG_INPUT", + "_BG_PANEL", + "_BORDER_BRIGHT", + "_CORNER", + "_ERROR", + "_FONT_LABEL", + "_FONT_MONO", + "_FONT_SMALL", + "_FONT_UI", + "_NEON_AMBER", + "_NEON_BLUE", + "_NEON_CYAN", + "_NEON_GREEN", + "_NEON_PINK", + "_NEON_PURPLE", + "_PANEL_ACCENT", + "_SIDEBAR_W", + "_SUCCESS", + "_TEXT_MUTED", + "_TEXT_PRIMARY", + "_TEXT_SECONDARY", + "_WARNING", +] diff --git a/mkpfs/gui/widgets.py b/mkpfs/gui/widgets.py new file mode 100644 index 0000000..22b526a --- /dev/null +++ b/mkpfs/gui/widgets.py @@ -0,0 +1,25 @@ +"""mkpfs.gui.widgets — re-export of widget classes from the shim module.""" + +from importlib import import_module as _import_module + +_shim = _import_module("mkpfs.gui") + +GlassCard = _shim.GlassCard +SectionLabel = _shim.SectionLabel +PathRow = _shim.PathRow +LogPane = _shim.LogPane +NeonButton = _shim.NeonButton +OptionRow = _shim.OptionRow +NeonCheckbox = _shim.NeonCheckbox +NavButton = _shim.NavButton + +__all__ = [ + "GlassCard", + "LogPane", + "NavButton", + "NeonButton", + "NeonCheckbox", + "OptionRow", + "PathRow", + "SectionLabel", +] diff --git a/mkpfs/pbar.py b/mkpfs/pbar.py index f007200..0d48ac0 100644 --- a/mkpfs/pbar.py +++ b/mkpfs/pbar.py @@ -5,6 +5,7 @@ from __future__ import annotations +import contextlib import sys import time @@ -93,6 +94,20 @@ def step(self, phase: str, done: int, total: int, bytes_processed: int = 0) -> N sys.stderr.flush() self.last_phase = phase + # Emit additive global progress events to any registered handlers. + with contextlib.suppress(Exception): + # Import the discovery module lazily to avoid import-time cycles. + from . import discovery as _discovery + + with contextlib.suppress(Exception): + _discovery._emit_progress_event_raw( + phase=phase, + done=done, + total=total, + bytes_processed=bytes_processed if bytes_processed > 0 else None, + timestamp=time.time(), + ) + def status(self, message: str) -> None: """Print a status message without progress bar. @@ -103,3 +118,16 @@ def status(self, message: str) -> None: return sys.stderr.write(message + "\n") sys.stderr.flush() + + # Emit a lightweight status event to registered handlers (non-breaking) + with contextlib.suppress(Exception): + from . import discovery as _discovery + + with contextlib.suppress(Exception): + _discovery._emit_progress_event_raw( + phase="status", + done=0, + total=0, + bytes_processed=None, + timestamp=time.time(), + ) diff --git a/mkpfs/pfs.py b/mkpfs/pfs.py index 86e4a83..6d12a09 100755 --- a/mkpfs/pfs.py +++ b/mkpfs/pfs.py @@ -1106,6 +1106,18 @@ def encode_pfsc_payload( encoded_blocks.append(chosen_block) if progress_callback is not None: progress_callback(len(block)) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=len(block), + timestamp=time.time(), + ) + except Exception: + pass header_size: int = _pfsc_header_size(block_count=block_count, logical_block_size=logical_block_size) offsets: list[int] = [header_size] @@ -1351,6 +1363,18 @@ def _analyze_pfsc_file_storage( chosen_payload_size += len(padded_chunk) if progress_callback is not None: progress_callback(len(chunk)) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=len(chunk), + timestamp=time.time(), + ) + except Exception: + pass else: worker_args_iter: Iterator[tuple[int, int, int]] = _iter_pfsc_block_worker_args( block_count=block_count, @@ -1381,6 +1405,18 @@ def _analyze_pfsc_file_storage( chosen_payload_size += padded_block_len if progress_callback is not None: progress_callback(raw_block_len) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=raw_block_len, + timestamp=time.time(), + ) + except Exception: + pass encoded_payload_size: int = header_size + chosen_payload_size hypothetical_all_compressed_size: int = header_size + all_compressed_size @@ -1495,6 +1531,18 @@ def _write(result: tuple[int, bytes, bytes]) -> None: emitted_blocks += 1 if progress_callback is not None: progress_callback(raw_len) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=raw_len, + timestamp=time.time(), + ) + except Exception: + pass def _iter_logical_blocks() -> Iterator[bytes]: buffer: bytearray = bytearray() @@ -1605,6 +1653,18 @@ def _encode_pfsc_into_handle( offsets.append(offsets[-1] + len(selected_chunk)) if progress_callback is not None: progress_callback(len(chunk)) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=len(chunk), + timestamp=time.time(), + ) + except Exception: + pass else: worker_args_iter: Iterator[tuple[int, int, int]] = _iter_pfsc_block_worker_args( block_count=block_count, @@ -1631,6 +1691,18 @@ def _write_block_result(result: tuple[bytes, bytes]) -> None: offsets.append(offsets[-1] + len(selected_chunk)) if progress_callback is not None: progress_callback(len(raw_chunk)) + try: + from . import discovery as _discovery + + _discovery._emit_progress_event_raw( + phase="compress", + done=0, + total=0, + bytes_processed=len(raw_chunk), + timestamp=time.time(), + ) + except Exception: + pass # Bound in-flight work to ``max_in_flight`` blocks and write results in # submit order. An unbounded ``imap`` lets a fast compressor pool outrun