This guide explains how the box-gui front end is organized. Run instructions live in the root README.md (Graphical interface section). User-visible behavior comes from the backend through box.api; this file records frontend decisions and conventions.
box-gui is the GTK4/libadwaita front end for box-rpg, shipped as distribution box-rpg-maker with command box-rpg-maker and App ID io.gitlab.christvh.BoxRpgApp.
New modules have an obvious home by dependency direction:
core/: plain logic with no widget toolkit. NoGtkorAdwimports.GdkPixbufdecoding is allowed. Enforced bytest_stable_surface.py.gtk/: toolkit plumbing such as threading, the interaction bridge, and themed icon names.widgets/: reusable GTK widgets shared between pages.pages/: navigation pages and dialogs.
app.py and i18n.py stay top-level. res/ stays at the project root and locale/ stays inside the package.
Key files:
src/box_gui/app.py:Adw.Applicationentry point. OwnsAppPaths,ConfigRepository,LibraryRepository,DefaultsRepository, andGtkInteraction. Holds a singleAdw.NavigationViewand presents the Settings dialog and the Debugging window on demand. Loads frontend and backend catalogs at startup.src/box_gui/core/library.py: game library persistence.LibraryEntrydataclass andLibraryRepositorywithload,save,add,remove,reorder, andupdateoverlibrary.json. No GTK dependency.src/box_gui/core/defaults.py: global defaults overdefaults.json, currently the preferred EasyRPG Player version. Same atomic-write and user-only-permission pattern aslibrary.py. No GTK dependency.src/box_gui/pages/library_page.py: root page with taglibrary. Folder picker, inspection-to-add flow, rows with runtime pill and reorder/remove menu, Settings entry point.src/box_gui/pages/game_detail_page.py: detail page with taggame-detail. Re-inspects on push, free-text display name, persisted runtime, SDK, GameMode, case-insensitive mount, extra-files, and sandbox permission switches, header launch icon, footerDiagnosebutton openingDiagnoseDialog.src/box_gui/pages/settings_dialog.py: modalAdw.PreferencesDialogwith search disabled by design, hostingGeneralPage,NwjsPage,EasyrpgPage, andCleanupPage.src/box_gui/pages/diagnose_dialog.py:Adw.Dialogwith environment and version groups. Requires libadwaita 1.5 or newer.src/box_gui/gtk/threads.py: backend-freerun_in_threadwith daemon thread plusGLib.idle_addmarshaling, re-exported byworkers.pyfor the backend-bound flows.src/box_gui/gtk/workers.py:ProgressReporterforwarding(completed, total)to the main loop, and thinrun_inspect,run_launch, andrun_diagnosewrappers. The backend setup page drives detection and install throughthreadsso startup survives a missing backend.src/box_gui/gtk/interaction.py:GtkInteraction, theInteractionbridge for launch-time prompts. Uses a nestedMainLoopon the main thread withidle_addplusthreading.Eventon workers. Not reused by the runtime manager dialogs.src/box_gui/i18n.py: gettext loading for domainbox-rpg-makerfromsrc/box_gui/locale, exposingconfigure,_, andngettext.src/box_gui/gtk/icons.py: themed icon names. The app icon is full-color, while NW.js and EasyRPG tab icons are-symbolicso GTK recolors them for light and dark themes.src/box_gui/core/game_icon.py: game icon discovery withoutGtk/Adw(GdkPixbufdecoding is allowed). Lists.exefiles throughlist_root_files, converts.icoto PNG withicoextractandGdkPixbuf, and builds the app-owned cache path. Executables are only read, never run. Failures returnFalseinstead of raising.icoextractis optional: without it extraction reports unavailable (setup-page row, Icon-row warning with install hint; exe picks and extraction failures stay silent) and icons fall back to the engine default.src/box_gui/widgets/exe_picker.py: single-choice executable dialog in thechoose_runtimeshape withAdw.AlertDialogplusGtk.DropDown.src/box_gui/widgets/icon_widget.py: shared icon order: cachedicon_pathfirst, then engine icon, then box icon.res/: desktop entry named after the App ID with matchingStartupWMClass, plus app and engine artwork underres/icons/.
The front end is presentation-only. All behavior lives in box-rpg behind box.api: inspect, launch with list_root_files, diagnose, per-engine runtime list, install, remove, and fetch with default_architecture, and CleanupCatalog with CATEGORIES. Catalogs are built per page from AppPaths, as box.cli.runtime does.
Import only box.api, box.models, box.errors, box.paths, box.config.models, and the three blessed extras box.runtime.catalog, box.runtime.easyrpg, and box.games.identity. Never import box.runtime.downloader, box.launch.sandbox, box.runtime.platform, or box.cli. The single exception is from box.utils.i18n import configure, only configure and never _, used once at startup to load the backend catalog next to the frontend one. test_stable_surface.py enforces this by parsing imports.
The live app resolves box-rpg from the installed package, not the checkout. A missing API keeps the x64 initial value silently, while a resolution error still shows a modal. Tests stub box.api.runtime either way.
Adw.NavigationView is the window content. The root page is LibraryPage with tag library. Activating a row pushes GameDetailPage with tag game-detail. The back button comes from Adw.NavigationView.
Settings is not a navigation page. The gear button lazily imports SettingsDialog and calls present(parent) as a modal dialog over the navigation view. DiagnoseDialog is presented from the game detail footer for the current root.
The + button on the library opens a Gtk.FileDialog folder picker, runs run_inspect off the main loop, calls LibraryRepository.add with the detected title as display name, and pushes the detail page for that game.
Extra frontend data lives under ~/.config/box-rpg/ without touching the CLI. AppPaths.config_root is public through box.api, so box_gui manages its own file there.
- File:
paths.config_root / "library.json". JSON, not TOML: this file is app-managed, not hand-edited likeconfig.toml, andjsonneeds no new dependency. - Schema version 7 with entries holding
path,display_name,order,preferred_runtime,preferred_sdk,copy_root_files,engine,allow_network,allow_game_writes,allow_x11,use_gamemode,use_ci_mount,use_devtools,icon_path, andmissing_streak.orderis an explicit integer, not array position.load()returns entries sorted byorder. A missing file yields an empty tuple. A corrupt file raisesLibraryError, shown as a normalAdw.AlertDialogerror, never a crash. Older versions migrate forward with safe defaults. - Writes are atomic: write to a temporary file in the same directory,
fsync,chmod 0o600, thenos.replace. The config root is created withmkdir(parents=True, exist_ok=True, mode=0o700)before the first write. LibraryRepositoryimports nothing fromboxexceptAppPaths.
Each engine has one page: NwjsPage named nwjs and EasyrpgPage named easyrpg. A new engine means a new page, not a restructured dialog.
Each page shows installed runtimes as rows with a remove button, plus a paged available-versions browser with 10 entries per page and clamped Prev/Next controls. The clicked Install button swaps in a Gtk.Spinner until the worker finishes. A real progress reporter is still passed so installs never fall back to terminal output.
The install flow mirrors the CLI: pick a version, confirm in a separate AlertDialog with suggested Install, then install on a worker. Confirmation is never folded into the picker, and GtkInteraction.choose_runtime is never reused for browsing. Confirm before install, never before remove, except cleanup removals which always confirm per item.
A page-level _busy flag guards option, pager, remove, and install-confirm changes while an install or remove runs (lists stay sensitive so spinners keep animating). While any page is busy the dialog vetoes close (can-close); while an engine page is busy it additionally pulls tab navigation back to the first busy engine page (NW.js before EasyRPG), so progress text stays visible until the task finishes. Dead pages never drive either veto. The default architecture is resolved before the view is built so the initial selector value cannot trigger a duplicate fetch.
Every blocking operation shows a spinner plus status label during work and a single-close Adw.AlertDialog on error: "<Op> Failed" for BoxError, "Unexpected Error" otherwise. One exception: a launch failure carrying box.errors.RuntimeError adds a suggested Open Runtimes response that opens Settings. Matching on the error type keeps this working under translated locales.
Dynamic text such as paths and display names always passes through GLib.markup_escape_text, because Adw.ActionRow titles render as Pango markup. Filesystem paths render abbreviated with the same ladder as the CLI prompt abbreviation, keeping the full path in the tooltip and absolute paths in storage. Dialog search stays disabled because libadwaita matches every titled row dialog-wide. Data rows use empty titles with plain child labels in case search ever returns. Long lists stay compact with collapsible summaries.
Every user-facing string uses _() or ngettext from day one. English msgids are the source and fallback. Error-type matching never matches on message text, so headings keep working under locales. Product documentation stays in English. JSON keys and file formats stay untranslated. See translations.md for the extractor workflow.
Artwork uses the Microsoft Fluent UI color set for the application icon and Tabler Icons for symbolic interface and engine-tab icons. Attribution lives in gtk/icons.py and as XML comments in res/icons/. app.py registers res/icons/ on the icon theme search path so checkouts resolve without installing. Sizes follow pack guidance: 24px outline sources as scalable symbolic icons, 48px source for the app icon. Symbolic icons recolor automatically, so no manual theme detection exists.
Game icons are opt-in through the detail Change action. Adding a game never extracts. The first icon of a game .exe is extracted into an app-owned cache file under paths.config_root / "icons" / "<slug>.png", so game folders are never written to. Any executable count opens the picker, whose first row reverts to the engine default. With no executable an image file picker is offered instead. Executable listing goes through box.api.launch.list_root_files, the same trust boundary as the extra-root-files picker.
tools/build_appimage.py (with the tools/appimage_tag.py fallback reader) builds box-rpg-maker.appimage as a Python-pure, CI-reusable step with no shell scripts. The artifact packs only the frontend: the box_gui sources with the in-package locale/, plus the res/ icons, the desktop entry, and the app icon. The box-rpg backend and the Python interpreter are never bundled; the generated AppRun starts on /usr/bin/python3 and, when it is older than 3.14, re-executes the first Python 3.14+ with GTK bindings from $BOX_RPG_MAKER_PYTHON, python3 on PATH, then /usr/local/bin/python3, /usr/local/bin/python3.14, /usr/bin/python3.14, so install the backend first (./install.py --install --target cli) for the direct-to-library path. A missing or stale backend is owned by BackendSetupPage, never by this launcher: the first run without one opens the in-app setup instead of refusing to start. On externally managed Pythons the one-click setup retries once automatically with --break-system-packages after pip refuses with an externally-managed-environment error, streaming both attempts verbatim; --yes alone never implies that consent. Before cloning, a read-only probe reports user-site ancestors violating install.py's ownership rule (wrong owner or group/other write bits) with their exact paths instead of failing after the build.
The payload mirrors the checkout (payload/src/box_gui beside payload/res), which keeps _register_bundled_icons and i18n working with no GUI changes. Conversion uses a recent appimagetool (continuous build) with a modern type 2 runtime: hosts run the artifact with libfuse3, never libfuse2 (already required for --ci-mount). Extraction is the default tool mode so FUSE-less CI containers can still build; only running the artifact needs /dev/fuse.
Releases are tag-driven. The builder computes the year.month.commit-count tag from git history via tools/versioning.py (same source as the version-standard script), creates the annotated tag at HEAD, and then verifies git describe --exact-match plus the tag/HEAD SHAs before staging anything; any mismatch fails closed. Local test builds pass --no-create-tag to embed without creating or verifying tags. The tag is baked into AppRun (BOX_RPG_MAKER_APPIMAGE_TAG) and a generated box_gui/appimage_tag.txt inside the payload (never committed).
Reader contract for the release screen: implement box_gui.appimage_tag.get_appimage_tag() -> str | None, checking the environment variable first and the sibling appimage_tag.txt second.
Builds trigger manually only: workflow_dispatch in .github/workflows/appimage.yml, when: manual in .gitlab-ci.yml. Each manual build also publishes a Release on both platforms with a generic title, notes, and the AppImage (attached binary on GitHub, artifact link on GitLab). Inspect locally without creating tags or downloading tools:
python3 -m tools.build_appimage --check
python3 -m tools.build_appimage --yes --appdir-only --output dist/AppDirDirty-tree test builds use --test-build: the builder fingerprints the real repo (HEAD, status, tag list), copies the tree to /tmp minus caches and artifacts, tmp-commits there, and re-runs itself with --yes --no-create-tag inside the snapshot, resolving --tag beforehand and forwarding it verbatim so the snapshot tmp-commit never shifts the month count, then verifies the real repo is unchanged. The default artifact is tools/target/box-rpg-maker-test.appimage (git-ignored); any other in-repo --output is refused. The snapshot is removed automatically, so the artifact must live outside it. Offline smoke without network:
python3 -m tools.build_appimage --test-build --yes --appdir-onlytests/conftest.pyprovides_install_auto_answerforAlertDialog.presentand_run_from_workeras aMainLoopstand-in with safeguards. Display tests gate on a display check. Import-surface, icon, and gettext coverage checks stay headless-safe.- Backend calls are stubbed with no network: pager and cancel paths, confirm accept, cancel, and close, remove-then-refresh, main-loop progress delivery, architecture-error fallback, and missing-helper fallback with zero dialogs.
LibraryRepositoryis tested standalone withtmp_path: load and save round-trip, missing file as empty tuple, corrupt JSON asLibraryError, reorder semantics, and no partial file on failure.- Run checks with the
.venv-guiinterpreter:pytest,ruff check,ruff format --checkwith line-length 100, and strictpyright. Code, identifiers, and comments stay in English. Noprint()orinput()inbox_gui. Dialogs are exercised headless or with stubs only; flag the missing visual pass in pull requests.
Strict pyright runs from box-gui/ with the project interpreter, mirroring the backend form in ../box-rpg/development.md (Node.js on PATH, no automatic downloads):
../.venv-gui/bin/python -m pyright --pythonpath ../.venv-gui/bin/pythongi/cairo come from system packages with no type information, so typings/ carries minimal explicit-Any stubs (namespaces and symbols limited to what src/ and tests/ actually use; submodules per namespace so Gtk.Widget stays valid in annotations). The .venv-gui interpreter carries the gi/cairo bindings plus pytest, and extraPaths in pyproject.toml covers both src trees. A box/box_gui copy installed in user site shadows the checkouts if it takes precedence; the checkout roots above must win — if stale-version errors (No parameter named ... on fresh APIs) ever appear, check ../.venv-gui/bin/python -c "import box; print(box.__file__)" first.