Files
wayscriber/docs/I18N.md
T
devmobasa 70d2e70f44 feat(configurator): make the language row easy to recognize
Put the language picker first in General UI, with a globe and a title
that includes every supported language. Derive its title and search terms
from shared language metadata.

Add regression coverage for the title, icon and row order, and format the
additions with the pinned Rust toolchain.
2026-10-19 11:39:54 +02:00

12 KiB

Internationalization

wayscriber draws its interface in the language named by [ui] language: the overlay and its toolbars, the About window, the background daemon's tray menu, shortcut descriptions and update notices, and the configurator. English is the source language: UI strings are written in English in the code and looked up in a per-language catalog. A string with no entry draws in English.

Choosing a language

[ui]
# "auto" follows the desktop locale; "en" and "zh-CN" force one.
language = "auto"

auto reads the desktop locale, most specific first: LANGUAGE, LC_ALL, LC_MESSAGES, then LANG. A locale whose language has no catalog — German, today — leaves the slot empty and the search continues; English is the final fallback, so auto never fails.

The configurator's Language row is the first row of the UI tab and has a globe icon. Its title names the setting in every supported language, the interface language first: Language / 语言 in English, 语言 / Language in Chinese. The row lists each language by its own name ("English", "简体中文"), so a language you cannot read can always be switched back. Picking one previews it in the configurator window at once; nothing is written until Save. Each other process reads the saved setting when it starts:

  • the overlay, the next time it opens;
  • the About window, the next time it opens;
  • background mode (the daemon: tray menu, portal shortcut descriptions and update notices), after it restarts.

auto resolves in each process from that process's own environment. Background mode usually runs as a systemd user service, whose environment can differ from a terminal's: systemctl --user show-environment shows what it sees, and a locale set with systemctl --user set-environment LANG=… or in ~/.config/environment.d/ applies after the service restarts.

To use a language for one run without editing config.toml, pass --language; wayscriber --list-languages shows the codes, the configured language, and which language is in effect and why:

wayscriber --list-languages
wayscriber --active --language zh-CN
wayscriber --daemon --language en     # background mode and every overlay it opens

--language applies to --active, --freeze, --daemon and --about. A daemon passes it to the overlays it opens and to the About window, but not to the configurator, whose Language row is the way to preview a language there. It cannot change a daemon that is already running.

Two environment variables help while testing a translation:

  • WAYSCRIBER_LANGUAGE=zh-CN overrides [ui] language in every process that sees it, the configurator included, so a translation can be checked without editing config.toml. --language beats it.
  • WAYSCRIBER_LOCALE_DIR=… names a directory of catalog files to merge over the embedded ones, so a translation can be corrected without a rebuild.

Without that variable, a file in $XDG_DATA_HOME/wayscriber/locales/<code>.toml (such as ~/.local/share/wayscriber/locales/zh-CN.toml) merges over the embedded catalog of the same code. Entries in the file win over the embedded ones; the file may hold a subset. A process reads the file once, so an edit applies to processes started afterwards. The file is ignored, with one log message, and the embedded catalog used instead, when it:

  • is a symbolic link, a FIFO, or anything else that is not a regular file;
  • is larger than 1 MiB (the embedded Chinese catalog is about 140 KB);
  • is not UTF-8, or not valid TOML.

A single entry whose {} placeholders do not match its key is skipped, and the embedded entry kept.

Adding a string

Wrap the literal where it is drawn:

use crate::i18n::{tr, trf};

title: tr("Boards"),
let caption = trf("{} of {} selected", &[&selected, &total]);

trf fills anonymous {} placeholders with its arguments in order: the first {} in the translated text always receives the first argument. A translation can place its placeholders anywhere in the sentence, but it cannot swap two values, so phrase a template so that its values read in the same order in every language. {name} and {0} are not placeholders; they stay literal, and the guard rejects them in a template.

Write the string literal at the call: plain or raw, on one line or several. The guard reads only that, so a constant or an expression as the argument fails it. Code that builds a table for one language, rather than for the active one, binds the lookup under the same name so each literal stays readable:

let tr = |source: &'static str| crate::i18n::lookup(language, source);

Then run the coverage guard:

cargo test --test repository_guards translations

It names every marked string that a catalog lacks, with the call site, for example:

zh-CN: "Switch board slot" has no entry (src/ui/help_overlay/.../sections.rs:120)

Add the reported keys to locales/zh-CN.toml and run the guard again. The catalog is ordered by key, case-insensitively, so reviews stay readable; keys are the English strings and only values are translated.

Rules the guard enforces:

  • every tr(…)/trf(…) call in src/ and configurator/src/ spells a string literal, and that literal has an entry in every catalog. The action accessors in src/config/action_meta/ are the one exemption, because they translate metadata fields centrally;
  • every action's label, short_label and description, and the compact display label action_display_label derives from them (Whiteboard for "Toggle Whiteboard Mode"), has an entry too. The guard reads the metadata table and calls the production function, so a new action or a new derivation is covered without a literal to point at;
  • a trf template spells only {}, and its argument slice holds as many values as the template has placeholders;
  • a value is not identical to its key — an entry that repeats the key is dead weight and fails;
  • a value has the same number of {} placeholders as its key.

Only literals can be marked. A string built at runtime — a binding label, a user name, a file name — is never translated. Canvas content (typed text, sticky notes, board and page names) is user data and stays as the user wrote it.

Displayed text is never an identifier. Widget IDs, stack page names, CSS names, config keys, config values such as bold or blue, and action IDs are fixed English spellings derived from typed values, never from a label, and no code compares a label with translated text to decide what to do.

What is not translated

  • command-line output: --help and --version, which print before the configuration is read, and --list-languages, which names each language in itself;
  • log messages and developer diagnostics;
  • keyboard key names. Ctrl+Shift+1, Enter, and Not bound are printed as they appear on the keyboard; only the prose around them is translated.
  • capture and export error detail — the Display text of CaptureError — which names the failing portal, tool, or buffer bound. It is engineering detail, not interface copy; the toast that carries it is translated.
  • the GTK toolbar's health and fallback reasons, which explain a diagnostic handoff to the built-in toolbar rather than address the user.
  • overlay chrome that is not wrapped yet. The guard only sees strings that are already marked, so an unwrapped surface stays English; the config diagnostics in the about window's debug view are one such surface.
  • in the configurator, a message already on the status line, and the result of a session or background-setup action, keep the language they were shown in until the next message replaces them. Captions, option labels and other persistent text switch at once.

Searching the configurator

The configurator's search matches its English terms in every language, so a search that works in English keeps working after a switch. It also matches tab, sub-tab and section titles, and keybinding action names, as they are displayed. A row's translated caption is not searchable unless it is one of those. The Language row is also found by every supported language's native name (English, 简体中文) and its word for "language" (语言), regardless of the active interface language. For example, language, locale, 中文 and 语言 all reveal General UI. The command palette's Interface Language… action opens that setting directly and accepts the same search terms.

Searching the command palette and help

The command palette searches English action labels, descriptions, aliases and shortcut names in every language. It also searches translated labels, descriptions and category headings in the active language, so 撤销 finds Undo and a mixed query such as clear 画布 finds Clear Canvas. English result scores and ordering remain unchanged. Help shares the metadata search and keeps its section order.

Adding a language

  1. src/domain/language.rs: add a variant, its code(), its catalog_code(), its serde name and aliases, its entry in ALL, its native_name() and english_name(), and its word_for_language(). --list-languages reads all of these but the last. The configurator's Language row takes its option labels from native_name() and its title from word_for_language(), and its search matches both.
  2. src/i18n.rs: the spellings language_from_code and language_from_locale accept, the variant's encode/decode atom, and the new file in EMBEDDED_CATALOGS.
  3. src/config/types/toolbar/items/definitions.rs: a table for the language in toolbar_item_definitions_in, which keeps one per language.
  4. configurator/src/models/fields/theme.rs: a LanguageOption for the variant. Add its native name and its word for "language" to the aliases of OpenConfiguratorLanguage in src/config/action_meta/entries/ui.rs. The configurator reads both from Language::ALL, but the palette's aliases are static. This lets users find the setting from another interface language.
  5. locales/<code>.toml: translate locales/zh-CN.toml, keeping the keys.
  6. cargo test --test repository_guards translations must pass, which proves the new catalog covers every marked string.

Design notes

  • Keys are the English source strings, the way gettext uses msgid. Lookup is an exact match with fallback, so marking a string that will never be translated costs nothing and a catalog can grow one surface at a time.
  • Catalogs are embedded with include_str!, so a release binary carries its translations, and the optional user file merges over them.
  • The selected language is process-global. UI chrome text has no owner object able to carry a language, so renderers read the selection as ambient state; this is the one documented exception to the explicit-parameter rule in src/ui/AGENTS.md. Canvas text never reads it.
  • Each process selects its language before it builds any chrome, with i18n::initialize, which also loads the catalog so the first frame does no file I/O: the overlay in src/backend/wayland/backend/state_init/config.rs, the daemon in src/daemon/language.rs before its tray, shortcut and update-notice workers start, and the About window in src/about_window/mod.rs. An overlay is a child process of the daemon and cannot change the daemon's language, so there is no live switching between processes.
  • The configurator opens in English, loads every catalog from its blocking config-load job, and applies the draft's language whenever the draft is replaced (a load, Defaults, a finished save) or the Language row changes it. The shell then rebuilds its pages from the model (configurator/src/app/component/rebuild.rs) and retitles the persistent chrome in place. The model owns everything typed or selected, so nothing the user entered is lost; focus, the caret and the visible page's scroll position are carried across. Text the model stores for display — catalog dates, the shortcut recorder's prompt, conflict claimants, background-setup feedback — is stored as what it reports and rendered when drawn.
  • Layout: the help overlay measures its labels, so a translation changes the column widths and the row count; a language whose words are much longer than English may need layout tuning in fixed-width chrome.