Make the launch screen translatable, and say how the rest follows

`@tr(` appeared zero times in 14,482 lines of markup. Every string the user
reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there
was nothing to extract and nothing a translator could have been given.

The mechanism turns out to cost almost nothing, and the reason is worth
stating because it decides the order of the work: a string written `@tr("Sign
in")` is already correct in a build with no translation at all. Slint's
`translate()` formats the original and hands it back when neither delivery
path is active, so a converted string and a literal are the same string until
someone writes a `.po`. That means the interface can be converted a screen at
a time rather than in one 14,000-line commit that nobody can review, and every
intermediate state is shippable.

So the delivery half is wired and left inert. `build.rs` asks for bundled
translations only once `lang/<lang>/LC_MESSAGES/dr-ui.po` exists — the first
catalogue anyone commits turns it on with no build-system change, and until
then a checkout with no `lang/` builds exactly as it did. Bundling rather than
the `gettext` feature because of Android: under ARCH §6.9's storage model
there is no path a `.mo` could sit at that the app can reach, and no C library
to link it against. The extraction command and the one flag that must not be
passed to it are recorded in the module docs.

The launch screen is converted whole: 32 calls covering every heading, button,
caption and placeholder. It goes first because it is the screen a user cannot
get past — an unreadable preferences page can be ignored, an unreadable sign-in
cannot. Four kinds of literal are deliberately left alone and the file says
which: the product name, example values whose shape is the message, `..`, and
the path separator.

Two things this leaves open, recorded rather than papered over. The headings
carry their own capitals, because `PanelHeading` draws what it is handed — so
a translator supplies "SERVEUR", not "Serveur", and styling in the string is a
real cost now paid rather than a surprise later. And `@tr()` is markup only:
the operation and parameter labels NFR-A11Y-1 names explicitly resolve in
`labels.rs`, in Rust, because the core may not depend on a localisation
library — those need a second mechanism, and it is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:25:16 +02:00
co-authored by Claude Opus 5
parent 8e4befa727
commit 7409cb7767
2 changed files with 136 additions and 32 deletions
+81 -1
View File
@@ -12,6 +12,48 @@
//! `OUT_DIR` as an include path makes the generated file answer every
//! existing `import { Theme } from "theme.slint"` unchanged. This only works
//! while no `ui/theme.slint` exists to shadow it — see the guard below.
//!
//! # Translations (NFR-A11Y-1)
//!
//! A string written `@tr("Sign in")` in the markup is **already correct in a
//! build with no translation at all**: with neither the `gettext` nor the
//! `bundle-translations` path active, Slint's `translate()` formats the
//! original and returns it. That is the property that makes converting the
//! interface a string at a time possible rather than a flag day — the
//! alternative, wiring the machinery first and converting after, means every
//! intermediate commit ships an interface half of which cannot be translated
//! and none of which can be extracted.
//!
//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a
//! `.pot`:
//!
//! ```text
//! cargo install slint-tr-extractor
//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot
//! ```
//!
//! Do **not** pass `--no-default-translation-context`. Slint's default
//! context is the enclosing component's name, which is what keeps the two
//! senses of a word like "or" apart when the same word is a conjunction on one
//! screen and a search operator on another — and the extractor and the
//! compiler have to agree about it or every lookup misses silently.
//!
//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po`
//! per language under `lang/<lang>/LC_MESSAGES/dr-ui.po`, compiled into the
//! binary by the block in `main`. The alternative is Slint's `gettext`
//! feature, which reads `.mo` files off disk at runtime through the C gettext
//! library. Bundling wins here for one reason that outranks the rest:
//! **Android**, where there is no filesystem path a `.mo` could sit at that
//! the app can reach under ARCH §6.9's storage model, and no C library to
//! link. A build with no `lang/` directory takes neither path and keeps every
//! original string, which is exactly the state of this crate today.
//!
//! **What this does not reach.** `@tr()` is markup. The operation and
//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by
//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot
//! depend on a localisation library (ARCH §6.5a), which is the constraint that
//! put the catalogue in the UI crate in the first place. Translating those
//! needs a second mechanism on the Rust side, and it is not built.
use std::collections::BTreeSet;
use std::fmt::Write as _;
@@ -21,6 +63,8 @@ use serde_norway::Value;
const STYLE_YAML: &str = "style.yaml";
const GENERATED: &str = "theme.slint";
/// Where a `.po` goes, relative to this crate: `lang/<lang>/LC_MESSAGES/`.
const LANG_DIR: &str = "lang";
fn main() {
println!("cargo:rerun-if-changed={STYLE_YAML}");
@@ -72,12 +116,48 @@ fn main() {
// The failure is silent either way: a missing image loads as empty.
// Embedding costs the size of ui/app-icon.png, the only asset this
// reaches, since every UI glyph is a Path rather than a file.
let config = slint_build::CompilerConfiguration::new()
let mut config = slint_build::CompilerConfiguration::new()
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
// Bundling is asked for only once a translation exists to bundle.
//
// Enabling it unconditionally would make an empty `lang/` — or a
// missing one, which is every checkout today — into a build failure for
// everyone, in service of a feature nobody is yet using. Asking the
// directory instead means the mechanism is wired and inert: the first
// `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no
// build-system change, which is the point at which a translator can
// actually verify their work.
if let Some(lang) = translations(&manifest_dir) {
println!("cargo:rerun-if-changed={}", lang.display());
config = config.with_bundled_translations(lang);
}
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
}
/// The translation root, if any language has a catalogue in it.
///
/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME`
/// and this reads the same variable, so a rename does not leave the two
/// halves looking for different files. Checking for a `.po` rather than
/// merely for the directory is deliberate: an empty `lang/` left behind by a
/// half-finished translation would otherwise switch bundling on and hand every
/// string to a lookup with nothing behind it.
fn translations(manifest_dir: &Path) -> Option<PathBuf> {
let root = manifest_dir.join(LANG_DIR);
let catalogue = format!("{}.po", env!("CARGO_PKG_NAME"));
let entries = std::fs::read_dir(&root).ok()?;
for entry in entries.flatten() {
if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() {
return Some(root);
}
}
None
}
/// The file handed to the Slint compiler.
///
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated
+55 -31
View File
@@ -7,6 +7,30 @@ import { Check } from "controls.slint";
// Deliberately separate from AppWindow. It is the first thing a user sees
// with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4).
//
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
// because it is the one a user cannot get past: an interface they cannot read
// is unusable here in a way it is not in a preferences page they could ignore.
// The mechanism, the extraction command and the reason a build with no
// translation behaves identically are in `build.rs`.
//
// Four kinds of string are deliberately *not* wrapped, and the distinction is
// worth stating because "wrap every literal" is the obvious rule and the wrong
// one:
//
// - **"DarkRoom".** A product name, the same in every language. Translating
// it invites a translator to answer the question, and there is no answer.
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
// the app-password mask. These are shapes rather than sentences; a
// translated hostname would teach the wrong format.
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
// not a word.
// - **`"/"`**, the path separator.
//
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
// That is a real cost of styling in the string, and it is recorded here rather
// than discovered by the first person to translate the screen.
// This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height
@@ -141,8 +165,8 @@ export component LaunchScreen inherits Rectangle {
}
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account, or open a folder";
? @tr("Connected")
: @tr("Connect a Nextcloud account, or open a folder");
body: true;
}
}
@@ -153,7 +177,7 @@ export component LaunchScreen inherits Rectangle {
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "SERVER"; }
PanelHeading { text: @tr("SERVER"); }
server-input := Field {
text: root.server-url;
@@ -162,20 +186,20 @@ export component LaunchScreen inherits Rectangle {
}
if !root.can-remember: Caption {
text: "No system keyring found — you will need to sign in each time.";
text: @tr("No system keyring found — you will need to sign in each time.");
warn: true;
wrap: word-wrap;
}
FormButton {
text: root.busy ? "Connecting…" : "Sign in";
text: root.busy ? @tr("Connecting…") : @tr("Sign in");
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Caption {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
text: @tr("Sign-in happens in your browser. DarkRoom never sees your password.");
wrap: word-wrap;
}
@@ -193,7 +217,7 @@ export component LaunchScreen inherits Rectangle {
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Caption { text: @tr("or"); }
Rectangle {
height: 1px;
background: Theme.rule;
@@ -201,13 +225,13 @@ export component LaunchScreen inherits Rectangle {
}
}
PanelHeading { text: "USERNAME"; }
PanelHeading { text: @tr("USERNAME"); }
user-input := Field {
text: "";
placeholder: "your Nextcloud username";
placeholder: @tr("your Nextcloud username");
}
PanelHeading { text: "APP PASSWORD"; }
PanelHeading { text: @tr("APP PASSWORD"); }
pass-input := Field {
text: "";
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
@@ -218,7 +242,7 @@ export component LaunchScreen inherits Rectangle {
}
FormButton {
text: root.busy ? "Connecting…" : "Connect directly";
text: root.busy ? @tr("Connecting…") : @tr("Connect directly");
enabled: !root.busy
&& server-input.text != ""
&& user-input.text != ""
@@ -233,7 +257,7 @@ export component LaunchScreen inherits Rectangle {
}
Caption {
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
wrap: word-wrap;
}
@@ -251,7 +275,7 @@ export component LaunchScreen inherits Rectangle {
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Caption { text: @tr("or"); }
Rectangle {
height: 1px;
background: Theme.rule;
@@ -259,7 +283,7 @@ export component LaunchScreen inherits Rectangle {
}
}
PanelHeading { text: "FOLDER"; }
PanelHeading { text: @tr("FOLDER"); }
folder-input := Field {
text: root.folder-path;
placeholder: "/home/you/Pictures";
@@ -267,13 +291,13 @@ export component LaunchScreen inherits Rectangle {
}
FormButton {
text: "Open folder";
text: @tr("Open folder");
enabled: !root.busy && folder-input.text != "";
clicked => { root.use-folder(folder-input.text); }
}
Caption {
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
wrap: word-wrap;
}
}
@@ -282,7 +306,7 @@ export component LaunchScreen inherits Rectangle {
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); }
Panel {
Label {
@@ -294,18 +318,18 @@ export component LaunchScreen inherits Rectangle {
}
FormButton {
text: "Copy link";
text: @tr("Copy link");
clicked => { root.copy-login-url(); }
}
Caption { text: "Waiting for approval…"; }
Caption { text: @tr("Waiting for approval…"); }
}
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
// Current location, so it is always clear what
// "Use this folder" would select.
@@ -326,7 +350,7 @@ export component LaunchScreen inherits Rectangle {
border-color: Theme.rule;
if root.browse-loading: Caption {
text: "Loading…";
text: @tr("Loading…");
horizontal-alignment: center;
width: 100%;
height: 100%;
@@ -357,7 +381,7 @@ export component LaunchScreen inherits Rectangle {
if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Caption {
text: "No subfolders here";
text: @tr("No subfolders here");
horizontal-alignment: center;
width: 100%;
height: 100%;
@@ -367,12 +391,12 @@ export component LaunchScreen inherits Rectangle {
HorizontalLayout {
spacing: Theme.gap;
FormButton {
text: "Cancel";
text: @tr("Cancel");
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
FormButton {
text: "Use this folder";
text: @tr("Use this folder");
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
@@ -384,22 +408,22 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "ACCOUNT"; }
PanelHeading { text: @tr("ACCOUNT"); }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "LIBRARY FOLDER"; }
PanelHeading { text: @tr("LIBRARY FOLDER"); }
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: "Choose…";
text: @tr("Choose…");
width: 110px;
clicked => { root.choose-folder(); }
}
@@ -407,7 +431,7 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "SCAN FOR"; }
PanelHeading { text: @tr("SCAN FOR"); }
for label[i] in root.format-labels: Check {
label: label;
@@ -418,13 +442,13 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap; }
FormButton {
text: root.busy ? "Scanning…" : "Open library";
text: root.busy ? @tr("Scanning…") : @tr("Open library");
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
FormButton {
text: "Sign out";
text: @tr("Sign out");
clicked => { root.sign-out(); }
}
}