diff --git a/ui/dr-ui/build.rs b/ui/dr-ui/build.rs index 8d6d2d7..642a9f3 100644 --- a/ui/dr-ui/build.rs +++ b/ui/dr-ui/build.rs @@ -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//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//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 { + 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 diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 1a05090..cae83d0 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -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(); } } }