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
182 lines
6.6 KiB
Rust
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
|
|
}
|
|
}
|