Files
DarkRoom/ui/dr-ui/src/live_style.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

182 lines
6.6 KiB
Rust

//! Re-reads `style.yaml` at startup, so a palette can be tuned by editing a
//! data file and restarting rather than rebuilding the tree.
//!
//! Compiled only under the `live-style` feature. Release builds have no
//! parser, no file read, and tokens the Slint compiler has folded to
//! constants — this path exists for the ten minutes somebody spends deciding
//! whether `surface` wants two more points of lift.
//!
//! The trick that makes it cheap: under this feature `build.rs` emits the
//! tokens as `in-out` rather than `out`. `Theme.ground` reads identically
//! either way, so not one call site knows this module exists; the only
//! difference is that Slint now generates Rust setters for the global.
//!
//! Failure here is never fatal. A photographer running a debug build with a
//! half-edited YAML should get their window and a warning, not a crash — the
//! build-time path is where a malformed file is an error.
//!
//! The cost of the feature is the table at the bottom: Slint generates one
//! setter per property and nothing indexed, so adding a token to `style.yaml`
//! means adding a line here too. Tolerable because it is debug-only, and the
//! unmatched-name warning says so out loud rather than reloading a no-op —
//! but it is why this is not the primary path.
use serde_norway::Value;
use slint::{Color, ComponentHandle};
use crate::{AppWindow, Theme};
/// Absolute path baked in by `build.rs`, so the binary finds the file
/// regardless of the directory it is launched from.
const STYLE_YAML: &str = env!("DR_STYLE_YAML");
pub fn apply(window: &AppWindow) {
match load() {
Ok(style) => {
let n = style.apply_to(window);
log::info!("live-style: applied {n} token(s) from {STYLE_YAML}");
}
Err(e) => log::warn!("live-style: keeping compiled tokens; {STYLE_YAML}: {e}"),
}
}
struct Style {
colors: Vec<(String, Color)>,
lengths: Vec<(String, f32)>,
}
fn load() -> Result<Style, String> {
let text = std::fs::read_to_string(STYLE_YAML).map_err(|e| e.to_string())?;
let doc: Value = serde_norway::from_str(&text).map_err(|e| e.to_string())?;
let doc = doc.as_mapping().ok_or("top level is not a mapping")?;
let mut colors = Vec::new();
for (name, spec) in group(doc, "colors")? {
// An alias resolves against what has already been read, matching the
// generated Slint where `modified: root.active` refers upward.
if let Some(target) = scalar(spec, "alias") {
if let Some((_, c)) = colors.iter().find(|(n, _): &&(String, Color)| n == &target) {
colors.push((name, *c));
}
continue;
}
if let Some(hex) = value_of(spec).and_then(|v| v.as_str()) {
if let Some(c) = parse_hex(hex) {
colors.push((name, c));
}
}
}
let mut lengths = Vec::new();
for (name, spec) in group(doc, "lengths")? {
if let Some(px) = value_of(spec).and_then(|v| v.as_f64()) {
lengths.push((name, px as f32));
}
}
Ok(Style { colors, lengths })
}
/// Yields the real tokens of a group, skipping the `section:`/`break:`
/// dividers that exist only to shape the generated file.
fn group<'a>(
doc: &'a serde_norway::Mapping,
key: &str,
) -> Result<Vec<(String, &'a Value)>, String> {
let map = doc
.get(key)
.and_then(Value::as_mapping)
.ok_or_else(|| format!("missing `{key}:` map"))?;
Ok(map
.iter()
.filter(|(_, spec)| {
!spec
.as_mapping()
.is_some_and(|m| m.contains_key("section") || m.contains_key("break"))
})
.filter_map(|(k, v)| Some((k.as_str()?.to_string(), v)))
.collect())
}
fn value_of(spec: &Value) -> Option<&Value> {
match spec.as_mapping() {
Some(map) => map.get("value"),
None => Some(spec),
}
}
fn scalar(spec: &Value, field: &str) -> Option<String> {
Some(spec.as_mapping()?.get(field)?.as_str()?.to_string())
}
fn parse_hex(hex: &str) -> Option<Color> {
let d = hex.strip_prefix('#')?;
let byte = |i: usize| u8::from_str_radix(d.get(i..i + 2)?, 16).ok();
match d.len() {
6 => Some(Color::from_rgb_u8(byte(0)?, byte(2)?, byte(4)?)),
8 => Some(Color::from_argb_u8(byte(6)?, byte(0)?, byte(2)?, byte(4)?)),
_ => None,
}
}
impl Style {
/// Slint generates one setter per property rather than anything indexed,
/// so the mapping from token name to setter has to be spelled out. The
/// `theme_tokens!` macro keeps that to one line per token, and an
/// unmatched name is warned about rather than ignored — a token renamed
/// in the YAML and nowhere else would otherwise reload silently as a
/// no-op.
fn apply_to(&self, window: &AppWindow) -> usize {
let theme = window.global::<Theme>();
let mut applied = 0;
macro_rules! theme_tokens {
($set:ident, $list:expr, $($name:literal => $setter:ident),* $(,)?) => {
for (name, v) in $list {
match name.as_str() {
$($name => { theme.$setter(v.clone().into()); applied += 1; })*
other => log::warn!("live-style: no such token `{other}`"),
}
}
};
}
theme_tokens!(set, &self.colors,
"ground" => set_ground,
"surface" => set_surface,
"surface-raised" => set_surface_raised,
"rule" => set_rule,
"ink" => set_ink,
"ink-dim" => set_ink_dim,
"ink-faint" => set_ink_faint,
"hover" => set_hover,
"pressed" => set_pressed,
"active" => set_active,
"active-dim" => set_active_dim,
"active-pressed" => set_active_pressed,
"modified" => set_modified,
"selected" => set_selected,
"selected-ring" => set_selected_ring,
"warn-ink" => set_warn_ink,
);
theme_tokens!(set, &self.lengths,
"gap-sm" => set_gap_sm,
"gap" => set_gap,
"gap-lg" => set_gap_lg,
"text-sm" => set_text_sm,
"text" => set_text,
"text-lg" => set_text_lg,
"text-xl" => set_text_xl,
"radius-sm" => set_radius_sm,
"radius" => set_radius,
"touch-target" => set_touch_target,
"row-height" => set_row_height,
"indent" => set_indent,
"control-height" => set_control_height,
"control-min-width" => set_control_min_width,
);
applied
}
}