Files
DarkRoom/ui/dr-ui/build.rs
T
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
2026-08-09 21:11:38 +02:00

318 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
}