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.
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-CNoverrides[ui] languagein every process that sees it, the configurator included, so a translation can be checked without editingconfig.toml.--languagebeats 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 insrc/andconfigurator/src/spells a string literal, and that literal has an entry in every catalog. The action accessors insrc/config/action_meta/are the one exemption, because they translate metadata fields centrally; - every action's
label,short_labelanddescription, and the compact display labelaction_display_labelderives from them (Whiteboardfor "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
trftemplate 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:
--helpand--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, andNot boundare printed as they appear on the keyboard; only the prose around them is translated. - capture and export error detail — the
Displaytext ofCaptureError— 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
src/domain/language.rs: add a variant, itscode(), itscatalog_code(), its serde name and aliases, its entry inALL, itsnative_name()andenglish_name(), and itsword_for_language().--list-languagesreads all of these but the last. The configurator's Language row takes its option labels fromnative_name()and its title fromword_for_language(), and its search matches both.src/i18n.rs: the spellingslanguage_from_codeandlanguage_from_localeaccept, the variant'sencode/decodeatom, and the new file inEMBEDDED_CATALOGS.src/config/types/toolbar/items/definitions.rs: a table for the language intoolbar_item_definitions_in, which keeps one per language.configurator/src/models/fields/theme.rs: aLanguageOptionfor the variant. Add its native name and its word for "language" to the aliases ofOpenConfiguratorLanguageinsrc/config/action_meta/entries/ui.rs. The configurator reads both fromLanguage::ALL, but the palette's aliases are static. This lets users find the setting from another interface language.locales/<code>.toml: translatelocales/zh-CN.toml, keeping the keys.cargo test --test repository_guards translationsmust 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 insrc/backend/wayland/backend/state_init/config.rs, the daemon insrc/daemon/language.rsbefore its tray, shortcut and update-notice workers start, and the About window insrc/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.