//! Generates `theme.slint` from `style.yaml`, then compiles the UI. //! //! The generated file lands in `OUT_DIR`, not beside the hand-written Slint. //! That is the whole point: a generated file sitting in `ui/` looks exactly //! like the five files around it that *are* meant to be edited, and an edit //! to it survives until the next `touch style.yaml` — a bug that hides for //! weeks. In `OUT_DIR` it cannot be edited by accident and cannot be //! committed by accident, so no `.gitignore` entry is needed either. //! //! Slint resolves `import ... from "theme.slint"` against the importing //! file's directory first and the compiler's include paths after, so adding //! `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 _; use std::path::{Path, PathBuf}; 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}"); println!("cargo:rerun-if-changed=ui"); println!("cargo:rustc-check-cfg=cfg(live_style)"); // Under `live-style` the tokens become `in-out` so Rust can write them at // startup. Call sites are unaffected — `Theme.ground` reads the same // either way — but the properties stop being constant-folded, which is // why this is a feature and not the default. let live = std::env::var_os("CARGO_FEATURE_LIVE_STYLE").is_some(); if live { println!("cargo:rustc-cfg=live_style"); } let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); // A hand-written `ui/theme.slint` would shadow the generated one silently // — the importing file's own directory wins over the include path — and // every palette change would then be ignored with no error anywhere. let shadow = manifest_dir.join("ui").join(GENERATED); if shadow.exists() { fail(format!( "{} exists and would shadow the generated theme.\n\ Tokens now come from {STYLE_YAML}; delete the stale file.", shadow.display() )); } let source = manifest_dir.join(STYLE_YAML); let generated = out_dir.join(GENERATED); match generate(&source, live) { Ok(slint) => std::fs::write(&generated, slint) .unwrap_or_else(|e| fail(format!("writing {}: {e}", generated.display()))), Err(e) => fail(format!("{STYLE_YAML}: {e}")), } // The live-reload path parses the same YAML at runtime, so the crate has // to be able to find it from an installed binary too. println!("cargo:rustc-env=DR_STYLE_YAML={}", source.display()); // `EmbedFiles` rather than the default. Slint's default for a hosted // target is to compile an `@image-url` down to the absolute path it had // on the build machine and open it at runtime, which is fine while the // binary never leaves the tree it was built in and wrong the moment it // does — and it is *already* wrong for Android, where the build happens // inside a container under /work and the device has no such directory. // 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 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); } // The recording hook's element queries walk a tree the compiler only // describes when asked to (src/automation.rs). Only that build asks: the // extra tables are dead weight to everyone else. if std::env::var_os("CARGO_FEATURE_AUTOMATION").is_some() { config = config.with_debug_info(true); } 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 /// shim that re-exports `app.slint` *and* `Theme`, because Slint emits Rust /// accessors only for globals exported from the entry document — and /// `app.slint` has no business carrying a line that exists to serve a debug /// feature. Everything the crate names (`AppWindow`, `ParamRow`, the cell /// and row structs) comes through the wildcard, so `include_modules!` sees /// exactly what it saw before plus the theme. fn entry(out_dir: &Path, live: bool) -> PathBuf { let app = PathBuf::from("ui/app.slint"); if !live { return app; } let shim = out_dir.join("live-entry.slint"); std::fs::write( &shim, "// GENERATED — see ui/dr-ui/build.rs. Entry point for `live-style` only.\n\ export * from \"app.slint\";\n\ import { Theme } from \"theme.slint\";\n\ export { Theme }\n", ) .unwrap_or_else(|e| fail(format!("writing {}: {e}", shim.display()))); shim } /// Build scripts report failure through stderr and a non-zero exit; a panic /// buries the message under a backtrace and the "process didn't exit /// successfully" boilerplate, which is exactly the wrong thing when the /// message is the name of the key the author got wrong. fn fail(message: String) -> ! { eprintln!("\nerror: {message}\n"); std::process::exit(1); } // --- codegen ------------------------------------------------------------- fn generate(source: &Path, live: bool) -> Result { let text = std::fs::read_to_string(source).map_err(|e| format!("cannot read: {e}"))?; let doc: Value = serde_norway::from_str(&text).map_err(|e| format!("not valid YAML: {e}"))?; let doc = doc.as_mapping().ok_or("top level must be a mapping")?; let mut out = String::new(); let banner = source .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| STYLE_YAML.into()); writeln!( out, "// GENERATED FILE — DO NOT EDIT.\n\ //\n\ // Written by ui/dr-ui/build.rs from ui/dr-ui/{banner}. Edits here are\n\ // discarded the next time that file changes. Change a token there.\n" ) .unwrap(); if let Some(preamble) = doc.get("preamble") { let preamble = preamble .as_str() .ok_or("`preamble` must be a block string")?; out.push_str(&comment(preamble, "//", 0)); out.push('\n'); } // `out` normally, `in-out` under live-style so the reloader can write // them. Reading a token is `Theme.` under both, which is the whole // reason live reload is possible without touching a single call site. let direction = if live { out.push_str( "// Built with `live-style`: tokens are `in-out` so style.yaml can be\n\ // re-read at startup. Release builds generate `out` and fold these\n\ // to constants.\n", ); "in-out" } else { "out" }; out.push_str("export global Theme {\n"); let mut names = BTreeSet::new(); emit_group( &mut out, doc, "colors", "color", direction, &mut names, parse_color, )?; emit_group( &mut out, doc, "lengths", "length", direction, &mut names, parse_length, )?; if names.is_empty() { return Err("defines no tokens; expected `colors:` and `lengths:` maps".into()); } out.push_str("}\n"); Ok(out) } /// Emits one YAML map as a run of `out property` declarations. /// /// `parse` turns a scalar into Slint syntax; everything else about a token — /// its prose, its aliasing, the section headings between groups — is shared /// between colours and lengths and lives here. fn emit_group( out: &mut String, doc: &serde_norway::Mapping, key: &str, slint_type: &str, direction: &str, names: &mut BTreeSet, parse: fn(&Value) -> Result, ) -> Result<(), String> { let Some(group) = doc.get(key) else { return Err(format!("missing `{key}:` map")); }; let group = group .as_mapping() .ok_or_else(|| format!("`{key}` must be a mapping of token name to value"))?; let mut first = true; for (name, spec) in group { let name = name .as_str() .ok_or_else(|| format!("`{key}` has a non-string token name"))?; // A divider is not a token. `section:` opens a named run with the // prose that introduces it; `break: true` is a bare blank line, which // is how the file groups related tokens (the three inks, the four // text sizes) without a heading each time. YAML discards the author's // blank lines, so the grouping has to be said rather than shown. if let Some(map) = spec.as_mapping() { if let Some(title) = map.get("section") { let title = title .as_str() .ok_or_else(|| format!("`{key}.{name}.section` must be a string"))?; let rule = "-".repeat(64usize.saturating_sub(title.len()).max(3)); writeln!(out, "\n // --- {title} {rule}").unwrap(); if let Some(note) = prose(spec, "note", &format!("{key}.{name}"))? { out.push_str(" //\n"); out.push_str(&comment(¬e, "//", 4)); } first = false; continue; } if map.contains_key("break") { out.push('\n'); first = true; continue; } } if !names.insert(name.to_string()) { return Err(format!("`{name}` is defined twice")); } // Blank line between prose-carrying tokens, so a comment attaches // visibly to the token below it rather than the run above. let doc_comment = prose(spec, "doc", &format!("{key}.{name}"))?; let note = prose(spec, "note", &format!("{key}.{name}"))?; if !first && (doc_comment.is_some() || note.is_some()) { out.push('\n'); } if let Some(note) = ¬e { out.push_str(&comment(note, "//", 4)); } if let Some(doc_comment) = &doc_comment { out.push_str(&comment(doc_comment, "///", 4)); } let value = token_value(spec, name, key, parse)?; writeln!( out, " {direction} property <{slint_type}> {name}: {value};" ) .unwrap(); first = false; } Ok(()) } /// A token is either a bare scalar or a mapping carrying `value:`/`alias:` /// alongside its prose. fn token_value( spec: &Value, name: &str, key: &str, parse: fn(&Value) -> Result, ) -> Result { let Some(map) = spec.as_mapping() else { return parse(spec).map_err(|e| format!("`{key}.{name}`: {e}")); }; if let Some(alias) = map.get("alias") { let alias = alias .as_str() .ok_or_else(|| format!("`{key}.{name}.alias` must name another token"))?; // `root.` rather than a bare name: inside a global, an unqualified // reference to a sibling property does not resolve. return Ok(format!("root.{alias}")); } let value = map .get("value") .ok_or_else(|| format!("`{key}.{name}` has neither `value:` nor `alias:`"))?; parse(value).map_err(|e| format!("`{key}.{name}`: {e}")) } fn prose(spec: &Value, field: &str, path: &str) -> Result, String> { let Some(map) = spec.as_mapping() else { return Ok(None); }; match map.get(field) { None => Ok(None), Some(v) => v .as_str() .map(|s| Some(s.trim_end().to_string())) .ok_or_else(|| format!("`{path}.{field}` must be a string")), } } fn parse_color(value: &Value) -> Result { let hex = value .as_str() .ok_or("must be a quoted hex colour such as \"#1B1C1E\"")?; let digits = hex .strip_prefix('#') .ok_or_else(|| format!("`{hex}` is not a hex colour; expected a leading `#`"))?; if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!( "`{hex}` is not a hex colour; expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA" )); } Ok(hex.to_string()) } fn parse_length(value: &Value) -> Result { let px = value .as_f64() .ok_or("must be a number of pixels, such as 12")?; if px.fract() == 0.0 { Ok(format!("{}px", px as i64)) } else { Ok(format!("{px}px")) } } /// Wraps a block of prose as Slint comments at a given indent, keeping the /// author's own line breaks — the paragraphs in `style.yaml` are already /// wrapped to the width the codebase reads at. fn comment(text: &str, marker: &str, indent: usize) -> String { let pad = " ".repeat(indent); let mut out = String::new(); for line in text.trim_end().lines() { if line.trim().is_empty() { writeln!(out, "{pad}{marker}").unwrap(); } else { writeln!(out, "{pad}{marker} {line}").unwrap(); } } out }