//! `theme.toml` deserialization, validation, and resolution into //! [`crate::shell::ShellTheme`]. //! //! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every //! field optional, `deny_unknown_fields` everywhere so a typo'd key is a //! hard error naming that key rather than a silent no-op) and know nothing //! about defaults; [`RawManifest::resolve`] is the one place defaults get //! filled and string enums get validated, producing the fully-resolved //! types in `types.rs`. use anyhow::{anyhow, bail, Context}; use std::collections::{BTreeMap, HashMap}; use super::types::*; /// Module names a slot entry may reference without recompiling anything — /// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch /// (tier 3, validated separately since its suffix is open-ended). This is /// intentionally the set the *current* theme and the plan's own schema /// example use; Phase 3 (breadbar's module registry) is the place a new /// built-in module name gets added for real. const KNOWN_MODULES: &[&str] = &[ "workspaces", "media", "clock", "volume", "wifi", "battery", "control", "launcher_entry", "launcher_results", // `02-glass-workbench` (plan §11 phase 5): plain right-side stat chips, // reusing the same `AppInput::StatsUpdate` data the control panel's // sys-grid already receives — see breadbar's `bar::slots` module docs. "cpu", "ram", ]; pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> { if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) { return Ok(()); } bail!( "theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \ (known modules: {}, or widget:)", KNOWN_MODULES.join(", ") ); } pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> { let Some(bar) = &raw.bar else { return Ok(()) }; let Some(slots) = &bar.slots else { return Ok(()); }; for (slot_name, list) in [ ("left", &slots.left), ("centre", &slots.centre), ("right", &slots.right), ("drawer", &slots.drawer), ] { for module in list { validate_module_name(theme_id, slot_name, module)?; } } Ok(()) } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawManifest { pub(super) name: Option, pub(super) id: Option, /// Present only so `extends` deserializes as a *known* field (otherwise /// `deny_unknown_fields` would reject every theme that sets it). The /// value itself is read straight off the raw `toml::Value` in /// `mod.rs::resolve_theme` — before this struct exists — since the /// merge has to happen ahead of (and separately from) deserialization. #[allow(dead_code)] pub(super) extends: Option, #[serde(default)] pub(super) tokens: HashMap, pub(super) bar: Option, pub(super) modules: Option, pub(super) launcher: Option, pub(super) surfaces: Option>, pub(super) compositor: Option>, /// Overlay CSS path, resolved relative to the theme file's own /// directory, appended last by `ShellTheme::css`. (Schema note: plan §4 /// shows `css = "extra.css"` textually after the `[compositor]` table /// with no table header of its own between them, which in real TOML /// would nest it *inside* `[compositor]`. Treated here as a top-level /// field per §5's `css()` doc — see this crate's implementation notes.) pub(super) css: Option, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawBar { pub(super) window: Option, pub(super) slots: Option, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawWindow { pub(super) anchors: Option>, pub(super) width: Option, pub(super) height: Option, pub(super) margin: Option, pub(super) exclusive: Option, pub(super) keyboard: Option, pub(super) layer: Option, } #[derive(Debug, serde::Deserialize)] #[serde(untagged)] pub(super) enum RawSize { Named(String), Px(i64), } #[derive(Debug, serde::Deserialize)] #[serde(untagged)] pub(super) enum RawExclusive { Named(String), Px(i64), } #[derive(Debug, Default, serde::Deserialize)] #[serde(deny_unknown_fields, default)] pub(super) struct RawMargin { pub(super) top: i64, pub(super) left: i64, pub(super) right: i64, pub(super) bottom: i64, } #[derive(Debug, Default, serde::Deserialize)] #[serde(deny_unknown_fields, default)] pub(super) struct RawSlots { pub(super) left: Vec, pub(super) centre: Vec, pub(super) right: Vec, pub(super) drawer: Vec, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawModules { pub(super) workspaces: Option, pub(super) clock: Option, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawWorkspacesModule { pub(super) style: Option, pub(super) show_empty: Option, /// `[6, 10, 14, 18]`-shaped — see [`DotWidths`]. A length other than 4 /// is a hard error (validated in [`resolve_modules`]) rather than /// silently truncated/padded, matching this file's "typo'd key is a /// hard error" policy for enum-ish values. pub(super) dot_widths: Option>, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawClockModule { pub(super) style: Option, pub(super) format: Option, pub(super) show_date: Option, pub(super) placeholder_clock: Option, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawLauncher { pub(super) mode: Option, pub(super) width: Option, pub(super) top: Option, pub(super) radius: Option, pub(super) icon_px: Option, pub(super) row_anim: Option, pub(super) rule: Option, pub(super) footer: Option, pub(super) sections: Option, pub(super) modes: Option>, pub(super) search_width: Option, pub(super) search_radius: Option, } #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawSurface { pub(super) anchor: Option, pub(super) offset: Option, pub(super) width: Option, pub(super) layer: Option, } #[derive(Debug, serde::Deserialize)] #[serde(untagged)] pub(super) enum RawOffset { Single(f64), Pair([f64; 2]), } #[derive(Debug, serde::Deserialize)] #[serde(untagged)] pub(super) enum RawSurfaceWidth { Named(String), Px(i64), } #[derive(Debug, Default, serde::Deserialize)] #[serde(deny_unknown_fields, default)] pub(super) struct RawLayerRule { pub(super) blur: Option, pub(super) ignore_alpha: Option, pub(super) blur_popups: Option, pub(super) animation: Option, pub(super) no_anim: Option, } fn token_value(v: &toml::Value) -> anyhow::Result { match v { toml::Value::String(s) => Ok(TokenValue::Str(s.clone())), toml::Value::Integer(i) => Ok(TokenValue::Int(*i)), toml::Value::Float(f) => Ok(TokenValue::Float(*f)), toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)), other => Err(anyhow!("must be a string, number, or bool, got {other:?}")), } } fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result { let default = WindowSpec::default(); let anchors = match &w.anchors { Some(list) => { for a in list { if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") { bail!( "theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \ (expected top|bottom|left|right)" ); } } list.clone() } None => default.anchors, }; let width = match &w.width { Some(RawSize::Named(s)) if s == "fill" => Width::Fill, Some(RawSize::Named(other)) => bail!( "theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \ (use a bare number for a fixed width)" ), Some(RawSize::Px(n)) => Width::Px(*n as i32), None => default.width, }; let height = w.height.map(|h| h as i32).unwrap_or(default.height); let margin = w .margin .as_ref() .map(|m| Margin { top: m.top as i32, left: m.left as i32, right: m.right as i32, bottom: m.bottom as i32, }) .unwrap_or(default.margin); let exclusive = match &w.exclusive { Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto, Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None, Some(RawExclusive::Named(other)) => bail!( "theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \ (use a bare number for a fixed exclusive zone)" ), Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32), None => default.exclusive, }; let keyboard = match w.keyboard.as_deref() { None => default.keyboard, Some("none") => Keyboard::None, Some("on_demand") => Keyboard::OnDemand, Some("exclusive") => Keyboard::Exclusive, Some(other) => bail!( "theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive" ), }; let layer = match w.layer.as_deref() { None => default.layer, Some("top") => "top".to_string(), Some("overlay") => "overlay".to_string(), Some(other) => { bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay") } }; Ok(WindowSpec { anchors, width, height, margin, exclusive, keyboard, layer, }) } fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result { let ws = m.and_then(|m| m.workspaces.as_ref()); let style = match ws.and_then(|w| w.style.as_deref()) { None => WorkspaceStyle::Trail, Some("trail") => WorkspaceStyle::Trail, Some("pill") => WorkspaceStyle::Pill, Some("dots") => WorkspaceStyle::Dots, Some(other) => bail!( "theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots" ), }; let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true); let dot_widths = match ws.and_then(|w| w.dot_widths.as_ref()) { None => DEFAULT_DOT_WIDTHS, Some(v) if v.len() == 4 => [v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32], Some(v) => bail!( "theme '{theme_id}': modules.workspaces.dot_widths has {} entries, expected 4 \ (0/1/2/3-or-more open windows)", v.len() ), }; let ck = m.and_then(|m| m.clock.as_ref()); let cstyle = match ck.and_then(|c| c.style.as_deref()) { None => ClockStyle::Flip, Some("flip") => ClockStyle::Flip, Some("plain") => ClockStyle::Plain, Some("none") => ClockStyle::None, Some(other) => { bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none") } }; let format = ck .and_then(|c| c.format.clone()) .unwrap_or_else(|| "%H:%M".to_string()); let show_date = ck.and_then(|c| c.show_date).unwrap_or(false); let placeholder_clock = ck.and_then(|c| c.placeholder_clock).unwrap_or(false); Ok(Modules { workspaces: WorkspacesModule { style, show_empty, dot_widths, }, clock: ClockModule { style: cstyle, format, show_date, placeholder_clock, }, }) } fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result { let mode = match l.and_then(|l| l.mode.as_deref()) { None => LauncherMode::Overlay, Some("overlay") => LauncherMode::Overlay, Some("embedded") => LauncherMode::Embedded, Some(other) => { bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded") } }; let width = l.and_then(|l| l.width).unwrap_or(540) as i32; let radius = l.and_then(|l| l.radius).unwrap_or(20) as i32; Ok(Launcher { mode, width, top: l .and_then(|l| l.top.clone()) .unwrap_or_else(|| "16%".to_string()), radius, icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32, row_anim: l .and_then(|l| l.row_anim.clone()) .unwrap_or_else(|| "flip".to_string()), rule: l .and_then(|l| l.rule.clone()) .unwrap_or_else(|| "gradient".to_string()), footer: l .and_then(|l| l.footer.clone()) .unwrap_or_else(|| "count_apps".to_string()), sections: l.and_then(|l| l.sections).unwrap_or(false), modes: l .and_then(|l| l.modes.clone()) .unwrap_or_else(|| vec!["apps".to_string()]), // Default to the idle value — a theme that never sets these gets // "no change while searching", not a hardcoded widen/shrink it // never asked for (see the field docs on `Launcher`). search_width: l.and_then(|l| l.search_width).map(|v| v as i32).unwrap_or(width), search_radius: l.and_then(|l| l.search_radius).map(|v| v as i32).unwrap_or(radius), }) } fn resolve_surfaces( theme_id: &str, raw: Option<&HashMap>, ) -> anyhow::Result> { let mut out = BTreeMap::new(); let Some(raw) = raw else { return Ok(out) }; for (namespace, s) in raw { let anchor = match s.anchor.as_deref() { Some(a @ ("top_right" | "bottom_centre" | "fill")) => a.to_string(), Some(other) => bail!( "theme '{theme_id}': surfaces.{namespace}.anchor = \"{other}\" is not \ top_right|bottom_centre|fill (the only shapes breadbar's satellite \ windows implement — see breadbar/src/surface.rs)" ), None => bail!( "theme '{theme_id}': surfaces.{namespace} has no anchor set \ (expected one of top_right|bottom_centre|fill)" ), }; let offset = match &s.offset { None => vec![], Some(RawOffset::Single(v)) => vec![*v], Some(RawOffset::Pair(v)) => v.to_vec(), }; let width = match &s.width { None => SurfaceWidth::Auto, Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill, Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto, Some(RawSurfaceWidth::Named(other)) => bail!( "theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \ (use a bare number for a fixed width)" ), Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32), }; let layer = match s.layer.as_deref() { None | Some("overlay") => "overlay".to_string(), Some("top") => "top".to_string(), Some(other) => bail!( "theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay" ), }; out.insert( namespace.clone(), Surface { anchor, offset, width, layer, }, ); } Ok(out) } fn resolve_compositor(raw: Option<&HashMap>) -> BTreeMap { let mut out = BTreeMap::new(); let Some(raw) = raw else { return out }; for (namespace, r) in raw { out.insert( namespace.clone(), LayerRule { blur: r.blur.unwrap_or(false), ignore_alpha: r.ignore_alpha, blur_popups: r.blur_popups.unwrap_or(false), animation: r.animation.clone(), no_anim: r.no_anim.unwrap_or(false), }, ); } out } impl RawManifest { /// Fill every default and validate every enum-ish string, producing a /// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this /// manifest was looked up under (used as the id/name fallback when the /// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded /// in by the discovery/extends logic in `mod.rs` since neither is a /// plain TOML field (extra_css is *read from* a TOML field, `css`, but /// resolving that path against the theme's own directory happens in the /// caller, which is the only place that still has the directory handy). pub(super) fn resolve( &self, requested_id: &str, css_template: String, extra_css: Option, ) -> anyhow::Result { let id = self.id.clone().unwrap_or_else(|| requested_id.to_string()); let name = self.name.clone().unwrap_or_else(|| id.clone()); let mut tokens_map = BTreeMap::new(); for (k, v) in &self.tokens { let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?; tokens_map.insert(k.clone(), tv); } let tokens = Tokens::from_map(tokens_map); let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) { Some(w) => resolve_window(&id, w)?, None => WindowSpec::default(), }; let slots = self .bar .as_ref() .and_then(|b| b.slots.as_ref()) .map(|s| Slots { left: s.left.clone(), centre: s.centre.clone(), right: s.right.clone(), drawer: s.drawer.clone(), }) .unwrap_or_default(); let modules = resolve_modules(&id, self.modules.as_ref())?; let launcher = resolve_launcher(&id, self.launcher.as_ref())?; let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?; let compositor = resolve_compositor(self.compositor.as_ref()); Ok(super::ShellTheme { name, id, tokens, window, slots, modules, launcher, surfaces, compositor, css_template, extra_css, }) } } /// Deep-merge `over` onto `base`: tables merge key-by-key recursively; /// anything else (scalars, arrays — including slot lists) is a full /// replacement. This is `extends`'s one-level merge (plan §4/§11): /// `mod.rs` calls this exactly once per `load_named`, with the base's own /// `extends` key already stripped by the caller so a chain can't go deeper /// than one level. pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value { match (base, over) { (toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => { for (k, v) in over_t { let merged = match base_t.remove(&k) { Some(existing) => merge_values(existing, v), None => v, }; base_t.insert(k, merged); } toml::Value::Table(base_t) } (_, over) => over, } }