There was no icon anywhere, and on Android that was not a missing line in the manifest. `aapt2 link` was being handed a manifest and nothing else, so the APK carried no res/ and no resources.arsc — there was no table for an `@mipmap/...` reference to resolve against even if one had been written. Packaging now compiles the resource tree first and links the result in, which is the two steps aapt2 insists on: link reads compiled input only, never a directory. That absent table is also why the launcher caption was blank, which had looked like a second, separate bug. `android:label="DarkRoom"` was there and correct the whole time, and Settings' App info read it fine; the launcher could not, because resolving a label goes through the package's Resources and there were none to open. Nothing about the label changed here. It came back with the table under it. `android:icon` then names one drawable for both icon generations, because the `anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive icon and its three layers; the third of those, monochrome, is what lets Android 13 recolour it rather than drop the app out of the themed set. Below 26 the same name lands on a density-matched PNG. `roundIcon` is deliberately absent — a launcher old enough to read it is one that would ignore the adaptive XML, and minSdk is 28. The desktop icon is one `@image-url` on the window, and the only raster asset in a UI that is otherwise entirely Path. The reasoning at the top of icons.slint does not reach it: that is about glyphs a font might not carry, and this image is never drawn by us at all. It goes to the window manager, which wants pixels and composites them unmasked, so it is pre-shaped with rounded corners rather than square the way the Android layers are. Which exposed Slint's resource default. An `@image-url` compiles down to the absolute path it had on the build machine, to be opened at runtime — already wrong for Android, where the build happens under /work inside a container and no such directory exists on the device, and wrong silently, as an image that loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches nothing else, since every glyph is a Path. Verified on a device: the APK installs and the home screen draws both the icon and "DarkRoom" under it, where before it had neither. In the link step the adaptive icon resolves at all six densities and resources.arsc lands uncompressed, which API 30 requires and the existing zipalign preserves. On the desktop by reading _NET_WM_ICON off the running window — 256x256, as handed over. Where that actually shows is narrower than it sounds, and the comment says so: Wayland ignores the property in favour of matching app_id against an installed .desktop file, which this repo does not install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
348 lines
13 KiB
Rust
348 lines
13 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());
|
|
|
|
// `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 config = slint_build::CompilerConfiguration::new()
|
|
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
|
|
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
|
|
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
|
|
}
|