Files
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

244 lines
12 KiB
Markdown

# 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
```toml
[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:
```sh
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:
```rust
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:
```rust
let tr = |source: &'static str| crate::i18n::lookup(language, source);
```
Then run the coverage guard:
```sh
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.