Files
DarkRoom/ui/dr-ui/build.rs
T
dtourolleandClaude Opus 5 03326242a1
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s
Make the CI checks say what they mean, and format the workspace
The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:02:51 +02:00

338 lines
12 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.
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";
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());
let config = slint_build::CompilerConfiguration::new()
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]);
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
}
/// 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(&note, "//", 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) = &note {
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
}