Skip to content

Theme server, translation editor, instant theme/language switching, and port links - #361

Merged
Cebion merged 29 commits into
PortsMaster:mainfrom
kloptops:theme-server-pr
Oct 24, 2026
Merged

Cebion merged 29 commits into
PortsMaster:mainfrom
kloptops:theme-server-pr

Conversation

@kloptops

Copy link
Copy Markdown
Member

Theme preview server (tools/theme_server/serve.py, pylibs/theme_server/):

  • Renders every PortMaster screen with any theme, at any resolution, colour scheme and language, in a browser page.
  • --export writes the screens as PNGs, for checking a theme at every size.
  • Renderers are forked from a single "zygote" process (each 60–90 MB), and how many run is set by the free memory.

Translations as JSON instead of gettext:

  • Each language has locales/<lang>.json and <lang>.themes.json. strings.json lists every string and where it's used, and tools/i18n.py extract / check keep them up to date.
  • The .po/.mo files and the Crowdin sync are gone.
  • The server's page has a translation editor, which saves into the language files.
  • Language names show in the current language.

Theme Editor on the device (Options → Theme Editor):

  • Runs the same server and shows its address as text and as a QR code.
  • The port is random from the dynamic range (49152–65535) the first time and is then kept, so the address stays the same between sessions.
  • Themes can show any text as a QR code with "image": "qr:{tag}".

Instant switching: changing the theme, colour scheme or language applies straight away, with no restart.

Port links: X on a port's info screen shows the port's PortMaster website page and its store pages as QR codes. Themes made before these screens get them built from their themes list.

Safety:

  • A translation with a {placeholder} the English doesn't have, or a stray brace, would raise in the code's .format(). These are skipped when a language loads (the English shows instead), and the editor refuses to save them.
  • Saving a translation needs the page's own header, a JSON body, and a Host that's an IP address or localhost.
  • Bad requests get a 400 or 404.

Default theme: Chinese, Japanese and Korean lines are 0.8 of the font's height, so the main menu fits at 640×480.

Testing:

  • The full suite passes on Python 3.7 and 3.11.
  • Every screen exported at 12 resolutions in six languages, and with 10 real themes.
  • The server tested over HTTP with parallel renders and malformed requests.
  • On muOS (RG35XX-H): the Theme Editor started, rendered and saved a translation from another machine, with the GUI holding 27–30 fps meanwhile. Language, theme and scheme switches were instant with no restart. Port link QR codes decode correctly. All 436 GUI tests of the PortMaster Test Suite pass on the device's real screen.

kloptops and others added 29 commits October 18, 2026 09:13
tools/theme_server/serve.py renders every PortMaster screen with a
theme and shows them in a web page that refreshes when a theme file is
saved: any resolution, colour scheme and language, a view of one screen
at every common size, and region boxes showing each region's area and
its final theme values. --export writes the screens as PNGs instead.

Each render process (worker.py) is a headless pugwash for one theme
version, scheme, resolution and language, since SDL allows one GUI per
process and those are fixed when pugwash starts. Screens use the real
ports list and screenshots, downloaded once into a cache.

pugwash:
- PM_PRETEND_RESOLUTION=WxH sets the screen size in HM_TESTING mode,
  in place of the commented-out pretend_resolution.
- create_screenshot() takes a file name, and reads the pixels before
  switching back to the window: it saved the window's last frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The unsupported-system warning, as a TrimUI-based OS gets it, and with
the longest set of warnings (spruce on a device with under 1 GB of RAM).
The renderer pretends to be such a device for get_startup_warnings().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
PortMaster's translations move from gettext .po/.mo files (synced with
Crowdin) to plain JSON in PortMaster/pylibs/locales, two per language
like messages.po and themes.po were:

  <lang>.json          {"English": "translation"}  the code's _("...")
  <lang>.themes.json   {"English": "translation"}  themes' "text" values

strings.json and strings.themes.json list every English string and where
it's used; tools/i18n.py extract keeps them in step with the code (an ast
scan for _("..."), the same 298 strings xgettext found) and the themes,
and tools/i18n.py check finds unused strings and lost {placeholders}.

- harbourmaster/i18n.py: _() and theme_text(), no dependencies; every
  module imports _ from it instead of gettext. A theme can carry its own
  "#translations", which win over PortMaster's.
- All 15 languages converted from the .po files: every one of the 4920
  lookups gives what gettext gave. Fuzzy entries were already none; the
  5 translations of strings the code no longer has are dropped.
- German "Unable to download {runtime}, ..." had lost {runtime} (and had
  a typo): fixed.
- The build no longer needs msgfmt, CI no longer installs gettext, and
  do_i18n.sh, theme_msgfmt.py and the xgettext snapshot test are gone
  (tests/test_i18n.py replaces it and runs everywhere, devices too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
A Translate view next to Preview lists every string of PortMaster and of
the themes with a language's translation, filtered to the untranslated
ones, those with problems or those on the rendered screens, and saves
them into the checkout's language files (in strings.json's order, so
diffs stay small; a new language gets both its files).

Selecting a string shows the screens that use it, in that language, at
the chosen resolution, and a saved translation re-renders them: the
renderer records which strings each screen looked up, and renderers are
keyed by the theme and the language files together.

Saving only works from the page: /api/translate needs an X-Theme-Server
header, which another site can't send without a CORS preflight the
server never answers.

tools/i18n.py check (and the editor) also flag a translation that lost a
space the English starts or ends with. It found "Music: " and the like
in German ("Musik", which showed "MusikDeaktiviert"), French and Korean:
fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
- PM_HEADLESS=Y (testing mode only): pugwash draws onto a surface with SDL's
  software renderer, with no video or controller subsystem. The workers use it,
  so they don't need SDL's dummy driver: muOS's SDL has only "mali". Tried on
  muOS Jacaranda (RG35XX-H): the server ran there, rendering every screen next
  to a pugwash that owned the screen. On the desktop the renders are
  pixel-identical to the dummy driver's.
- Chinese, Japanese and Korean showed as boxes: the theme matches against
  harbourmaster.device_info(), and with PM_PRETEND_RESOLUTION pugwash added
  the language to a copy, so "font[ja_JP]" never matched. pugwash now writes
  the capabilities it works out back into device_info().
- serve.py unpacks the Noto fonts at start, as the PortMaster/pugwash script
  does: on a fresh checkout nothing else had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
An image name starting with "qr:" is a QR code of the rest of the text, made
with the vendored qrcode package (unused until now). Through the image
templates it updates when its tags change. It's black on white with the quiet
zone, one pixel per module and scaled without smoothing, so it stays sharp at
any size: per texture on SDL 2.0.12 and up, the scale quality hint before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The theme server now ships with PortMaster, in pylibs/theme_server (about 100 KB;
tools/theme_server/serve.py and tools/i18n.py run it from a checkout as before).
Options -> Theme Editor starts it and shows its address as text and as a QR
code; a computer or phone on the same network gets the previews and the
translation editor, with the device's themes and ports list. B stops it.

- pugwash/scenes/theme_server.py runs `python3 -m theme_server.serve --managed`
  on port 8090 or the next free one, with as many render processes as
  MemAvailable allows (1-4). --managed reports the port as a JSON line and stops
  when stdin closes, so pugwash dying stops it too. Log: PortMaster/theme_server.txt.
- The default theme has a theme_server section ({theme_server.url},
  {theme_server.status}, "qr:{theme_server.url}"); other themes get a plain
  screen in PortMaster's own font, so the address stays readable. The preview
  catalogue has the screen too.
- Workers run outside testing mode: PM_HEADLESS is honoured anywhere, and
  PM_PRETEND_RESOLUTION when headless. They take the ports from a config folder
  (--ports-config): the cache's or PortMaster's own, copying the files and
  linking the screenshot folders, and never as a first run, so that folder is
  left as it was (tested).
- The theme server tests no longer need a git checkout, so the Test Suite runs
  them on devices.

Tried on muOS Jacaranda (RG35XX-H) with test build 2026-10-18_0069: the server
ran from the installed copy, rendered from the device's own ports, saved and
removed a translation on the exFAT card, and stopped cleanly when its stdin
closed. Every QR code rendered decodes to its address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
- The list of screens is in catalogue.py, so the server listens (and PortMaster
  shows the address) before any renderer starts: that took ~15 seconds on a
  handheld. The first renderer starts in the background meanwhile.
- No scheme means the theme's default scheme by name, so the page (which asks
  for "Light Mode") uses the renderer the server warmed up instead of starting
  a second one.
- --managed logs every request to theme_server.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
Choosing a theme, colour scheme or language used to say "You will have to
restart" and quit PortMaster. Now it applies straight away, and you're back in
the same list with the new look.

- PortMasterGUI.reload_theme(then=...) drops what the old theme loaded (images,
  fonts and rendered text, sounds and music, animations, pallet, overrides,
  element rects, its resource path), loads the configured theme again and
  rebuilds the screens: a new main menu with `then`'s scenes on it.
- reload_language() also switches the language (lang.set_language: the
  translations, CURRENT_LANG, the environment) and swaps the language
  capability, which picks the theme's font for it (font[ja_JP] & co).
- The three "restart" prompts are gone, with their translations.
- tests/test_reload.py: a screen after switching to another theme, scheme or
  language and back is pixel-identical to before, German translates, Japanese
  picks the Noto font, and the themes list applies a choice without quitting.

The theme server can now reuse a renderer instead of starting another for each
change (next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
Renderers were keyed by theme version, scheme, resolution and language, so
every saved theme file, scheme or language change started a new pugwash
(8-10 seconds on a handheld). Now there's one per screen size: the worker's new
"load" command switches theme, scheme and language with reload_language(), and
serve.py sends it when a render needs something else loaded.

On the RG35XX-H (muOS Jacaranda): switching scheme 2.5 s and language 2.8 s,
each including a ~1.1 s render, instead of a new renderer. A new screen size
still starts one.

Tests: a renderer switched to a theme, scheme and language draws the same as a
new one with those settings; a saved theme reloads in the same process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
A renderer spent most of its start importing pugwash and loading the ports.
Now one process, worker.py --zygote, imports pugwash once and forks a renderer
per screen size. serve.py sends it a request with the renderer's end of a
socket pair attached (SCM_RIGHTS); the renderer talks on that socket as a
worker.py does on stdin/stdout. If the zygote can't start, renderers start on
their own as before.

- Each forked renderer's HarbourMaster has its own tools folder
  (Renderer(tools_dir=...)); the zygote's holds the theme links.
- ThemeEngine.force_scheme (and PortMasterGUI(force_scheme=...)), like
  force_theme, so a renderer starts in the scheme asked for.
- The zygote has no threads (select on its control socket and stdin): it forks.

On the RG35XX-H (muOS Jacaranda): the first screen 2.4 s (was 8.4), a new
screen size 2.0 s (was 12.5), switching scheme or language ~1.1 s, and each
screen ~0.45 s (was ~1.1).

Tests: forked renderers draw the same as one of their own, and a renderer
that dies is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
…ning

- The page asked for every screen at once, so after changing a setting the
  server still drew the whole old batch first. Renders now go through a queue,
  two at a time; a change (theme, scheme, size, language, view, a saved file)
  drops what's waiting and aborts what's running.
- A line with a spinner says "Rendering 3 of 25...", or "Starting the renderer
  for 1024x768, a few seconds..." / "Loading the theme and language..." from
  the new /api/status (which renderers are starting, loading or rendering).

Tried in Chromium: changing the scheme mid-render, no more renders of the old
one were asked for and the new set finished 1.4 s later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The language list was made when pugwash.lang was imported, before any
translation loaded, so its names were always English; now that the language
can change without a restart that showed. lang_list() translates them when
it's called, and the language screen asks for it each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The Test Suite runs these on devices now; up to six renderers at 60-90 MB each
is too much for the low-memory ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The Test Suite leaves NotoSans.tar.xz out, so on a device there's no Japanese
font for these to load (found running them on muOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
On GitHub's runners ctypes.util.find_library (pysdl2 looking for SDL) runs gcc,
which can leave its temporary files in TMPDIR, so the folder wasn't always
empty and the test failed now and then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
- pugwash/main.py: setup_proxy from main, _ from harbourmaster.i18n.
- The language screen keeps the instant switch, with main's guard
  for the blank row.
- main's language, theme and colour scheme tests expected the
  restart prompt; they now check the instant reload instead, keeping
  the regressions they covered (choosing the system's language with
  none in the config, a new theme dropping the old scheme).
- strings.json re-extracted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
Port 8090 (and the nine after it) could clash with a CFW's own
services. The on-device editor now picks a random port from
49152-65535, which is never assigned to a service, and keeps it in
config.json (theme_server_port) so the address stays the same between
sessions. If it's taken, the next free one of the 20 after it, or any
free port (serve.py --any-port), is used and kept instead.

listen() stops at 65535. The desktop serve.py keeps --port 8090.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
X on the port info screen opens a port's links: its page on the PortMaster
website (ports from the PortMaster and Multiverse sources) and every store
in its port.json, the game's page and the developer's, each shown as a QR
code and its address, to open on a phone.

port.json "store" names are typed by hand ("Itch", "itch.io", "GoG ",
"Gamejolt", ...), so they're tidied into one name per store; entries that
aren't http(s) links are skipped.

The default theme has a port_links section, with a smaller QR and text at
320x240. Themes without one get a plain built-in screen in PortMaster's own
font, as the theme editor's screen does (plain_font() is shared now). The
theme designer lists the screen, using a port with several links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
…s list

Themes made before the port links and theme editor screens showed a plain
built-in screen for both, and custom themes that ship with CFWs (or are on
people's devices) won't be updated. When a theme has no "port_links" or
"theme_server" section, one is now made from its "themes_list" section as
it loads: its list, panels and fonts, the QR code where the theme
screenshot goes, the address where the description goes (the editor's
instructions where the list goes, a size smaller). A font with capitals
only (Doom's) is swapped for the list's, so addresses read right. A themes
list without a screenshot region (basic_theme) still gets the plain screen.

Checked by rendering the themes as published before (the nine in
PortMaster-Themes, and the MinUI theme PakUI and TRIMUI ship) at 480x320 to
1280x720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
… Host

- The code calls .format() on what _() returns, so a translation with
  a {field} the English doesn't have, or a stray { or }, raised there
  ({lang_name} (Selected) -> {bogus} stopped the language screen with a
  KeyError). harbourmaster.i18n leaves those out when it loads a
  language (the string shows in English), and the translation editor
  refuses to save them, saying why. No shipped translation is affected.
- The editor's POSTs (saving translations) also need a Host header that
  is an IP address or localhost: a DNS rebinding page reaches the
  server under its own domain name, and could otherwise send the
  page's header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
Noto Sans JP/SC/TC/HK/KR have a much taller line height than DejaVu
Sans at the same size, so in Japanese the main menu's six entries ran
into the footer at 640x480 ("終了" over "PortMaster: ..."). The theme
server's export showed it. Lines and list rows are now 0.8 of the
font's height in those languages: everything fits where it does in
English.

The theme editor's preview shows an address with a port from its new
range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
…elds

Found by sending it bad requests: render.png?theme=../../../etc was a
500 (the renderer's "no theme" error), a translation with "lang": 5
dropped the connection (a TypeError nothing caught), and render.png
took any \w\w_\w\w as a language where the rest of the API takes
xx_XX only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
The theme editor screen made from a theme's themes list put the
instructions in the list's area without its border-x, so the text ran
under the panel's edges (Doom, FFVII) or past it (Zelda). Seen
rendering the device's themes with their own sections taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
English:
- "Port X installed failed." -> "Port X failed to install."
- "sucessfully" -> "successfully"; "Unable do download a runtime when
  in offline mode." -> "Unable to download a runtime in offline mode."
- One "Select Color Scheme" (Options said Color, the screen Colour),
  with a full stop on its description; "soundfx" -> "sound effects";
  the move-ports question gets its question mark.
- PortMaster's own update says "PortMaster installed successfully." /
  "PortMaster failed to install." instead of "Port 'PortMaster' ...".
- American spelling ("color") in the theme editor page and the docs;
  pugwash's --no-colour/--force-colour flags are unchanged.
- A theme string from no known theme ("... dance until tomorrow") is
  dropped.

Each language keeps its translations under the corrected English (a
full stop or question mark added where the English gained one).

Translations whose line breaks didn't match the English: Russian ran
"HM: ..." and the free space together on one line, Dutch put three
lines of port info on one, Romanian split a one-line layout in three,
and German/Chinese had extra or missing blank lines in message boxes.
German also: "Möchten Sie", "installierte". tools/i18n.py check is
clean for every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
About 1,080 translations, so every string in strings.json and
strings.themes.json has one in each language (strings made only of
{tags} need none). Most are the strings since the last Crowdin sync:
the theme editor, port links, autoinstall's summary, the startup
warnings, sorting, debug mode. Italian, Japanese, Swedish and Romanian
also get the older ones they never had. Each language keeps the words
it already used (ports are "jogos" in Brazilian Portuguese, the
runtime is "Körtid" in Swedish, and so on). Every one passes
tools/i18n.py check and safe_translation. They're machine-written:
native speakers should look them over in the translation editor.

The startup warnings screen used DejaVu Sans whatever the language, so
Chinese, Japanese and Korean showed as boxes once translated: its
hard-coded section now has the default theme's font for each
language. Its "Wait N seconds" and "Please wait." are translatable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
New: test_region_validation.py (every Region._verify_* check),
test_theme_loading.py (#base, elements, requirement tags, colour
schemes, resources, a theme's #translations, what PortMaster adds),
test_lang.py (picking and switching the language, the language list,
i18n lookups) and test_i18n_tool.py (extract, check, problems).

What they found:
- check_lang("") was German: every language "starts with" "", so an
  empty LANG (or ".UTF-8", "_US") made DEFAULT_LANG German while the
  screen was English, and choosing German "reset" to the system's.
- "font-scale": 2 or "line-height": 1 (whole numbers) were "not an
  float"; a theme's "text": 5 raised a TypeError (it raised a str)
  instead of a theme error.
- theme_load read #base in its loop, so a #base after the sections
  (a colour scheme's is added last) never applied; an unknown colour
  scheme fell back to the first one, not the theme's default-scheme;
  and a section with a requirement ("main_menu[640x480]") merged each
  region with the whole old section and dropped the rest. No shipped
  theme hit these: every render of the device's 10 themes is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY
test_theme_server_http.py runs the web API against a fake render pool
(no SDL): every route, render parameters and cache headers, a missing
section (404) and a renderer error (500), bad sizes, languages, screens
and themes, versions changing with the theme and the translations,
translation saves (problems, removal, theme strings, ten at once, the
ones refused and the requests forbidden), the themes listed and their
info, the languages, and --export.

test_theme_editor_scene.py drives Options -> Theme Editor with a fake
server process: the command it runs, the port it keeps, and what the
screen says when the server can't start, refuses, dies before
answering, stops by itself or the device has no network; B stopping
it, and a server that won't stop being killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY

@kloptops kloptops left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. 😆

@Cebion
Cebion merged commit 919eb52 into PortsMaster:main Oct 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants