The film list bug gave no failure anywhere: the data was right, the markup compiled, and the list rendered. Two guards now check that the list can be walked to its end, both by driving input rather than by reading markup. tests/film_list_reaches_every_stock.rs runs in CI and needs no display. It builds the real AppWindow on Slint's testing backend and gives it 28 stocks. It dispatches window events through the same routing a window uses: popup, Flickables, arbitration. It then checks that the last stock is on screen, that is, not clipped away: - after Down past the end, and that Enter chooses it; - after drags on the list; - after a run of wheel events with a still pointer; - after dragging the scrollbar thumb. Element queries need the Slint compiler's debug tables, which build.rs emitted only for the `automation` feature. It now emits them for every debug build too. Release builds, the ones that ship, are unchanged. The testing backend is a dev-dependency at the same pinned version the automation feature already uses, so no new crate enters the lockfile. The test is compiled out of release test runs. film_reach in tools/manual/scenes.py is the same check on the recording rig: a real X pointer from xdotool, the release build, and the demo library. It makes no picture, so it adds nothing to the manual. It runs with every recording, or alone with `record.sh LIBRARY film_reach`, and fails the run if the last stock (Ilford HP5 Plus) is out of reach by the wheel, a drag, the scrollbar or the keys. Both have to add the popup's position back. The testing backend reports anything inside a popup relative to the popup, and so does the automation hook built on it. They take the popup's position from the Film row and Slint's clamp into the window.
440 lines
18 KiB
Rust
440 lines
18 KiB
Rust
//! 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/<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/<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), and so do the tests that
|
|
// run the interface on Slint's testing backend
|
|
// (tests/film_list_reaches_every_stock.rs). So that build and every debug
|
|
// build ask; a release build is the one the extra tables are dead weight
|
|
// to.
|
|
if std::env::var_os("CARGO_FEATURE_AUTOMATION").is_some()
|
|
|| std::env::var("PROFILE").as_deref() == Ok("debug")
|
|
{
|
|
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<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
|
|
/// 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<String, String> {
|
|
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.<name>` 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<String>,
|
|
parse: fn(&Value) -> Result<String, String>,
|
|
) -> 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<String, String>,
|
|
) -> Result<String, String> {
|
|
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<Option<String>, 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<String, String> {
|
|
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<String, String> {
|
|
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
|
|
}
|