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
318 lines
12 KiB
Rust
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(¬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
|
|
}
|