//! 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 { 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, 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 { Some(spec.as_mapping()?.get(field)?.as_str()?.to_string()) } fn parse_hex(hex: &str) -> Option { 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::(); 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 } }