//! Theme — reusable named [`VisualStyle`]s plus a default fallback. //! //! A [`Theme`] is what a project ships to give every UI document a consistent //! look without hand-styling every widget. The resolution rule is a strict //! left-to-right cascade: //! //! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the //! project-wide defaults — body text color, border weight, …). //! 2. If the widget specifies `theme_style: Some("button")` and the theme //! contains a `"button"` entry, merge that on top. //! 3. Merge the widget's per-instance `visual` on top. //! //! Each merge is field-by-field: a `Some` on the right replaces the field; //! a `None` keeps what was there. The result is a single [`VisualStyle`] //! where any field that's still `None` means "the renderer's own hard-coded //! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass). //! //! Why named styles instead of CSS-like selectors: it makes per-widget //! attribution explicit in the UI document (`theme_style: "button-primary"`) //! and keeps theme resolution constant-time per widget. CSS selectors and //! cascading rules are a richer model but their authoring cost dwarfs what //! Stage-8 game UIs actually need. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use super::visual::VisualStyle; /// A named-style theme. Holds a `default` style applied to every widget plus /// a map of named styles widgets can opt into by their `theme_style` field. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Theme { /// Project-wide defaults — applied first to every widget before its /// `theme_style` and per-instance overrides. #[serde(default, skip_serializing_if = "VisualStyle::is_empty")] pub default: VisualStyle, /// Named style buckets — `theme_style: "button"` on a widget pulls the /// `"button"` entry here on top of `default`. /// /// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a /// deterministic order — important for diff-friendly UI documents and /// reproducible RON snapshots in tests. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub styles: BTreeMap, } impl Theme { /// Empty theme — no default fields, no named styles. Every widget under /// this theme inherits only the renderer's hard-coded fallback. pub const fn new() -> Self { Self { default: VisualStyle::EMPTY, styles: BTreeMap::new(), } } /// Insert (or replace) a named style. Chainable for builder-style theme /// construction in tests and examples. pub fn with_style(mut self, name: impl Into, style: VisualStyle) -> Self { self.styles.insert(name.into(), style); self } /// Replace the project-wide default style. pub fn with_default(mut self, default: VisualStyle) -> Self { self.default = default; self } /// Resolve the effective visual style for a widget that opts into /// `style_ref` (if any) and provides its own `override_with` per-instance /// fields. /// /// Cascade: `self.default` → (`self.styles[style_ref]` if present) → /// `override_with`. A missing named style is treated as empty (no /// contribution) rather than an error — UI documents stay valid when a /// theme is swapped for a smaller one mid-development. pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle { let mut resolved = self.default.clone(); if let Some(name) = style_ref { if let Some(named) = self.styles.get(name) { resolved = resolved.merged(named); } } resolved.merged(override_with) } /// Serialize this theme to a pretty-printed RON string. pub fn to_ron(&self) -> Result { ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) } /// Parse a theme from a RON string. pub fn from_ron(text: &str) -> Result { ron::de::from_str(text) } } #[cfg(test)] mod tests { use super::*; use crate::math::Color; use crate::ui::visual::{Border, FontRef}; fn theme_with_three_styles() -> Theme { Theme::new() .with_default(VisualStyle { foreground: Some(Color::BLACK), background: Some(Color::WHITE), font: Some(FontRef::regular("Inter")), font_size: Some(14.0), ..VisualStyle::EMPTY }) .with_style( "button", VisualStyle { background: Some(Color::rgb(0.85, 0.85, 0.9)), border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)), corner_radius: Some(4.0), ..VisualStyle::EMPTY }, ) .with_style( "button-primary", VisualStyle { background: Some(Color::rgb(0.2, 0.4, 0.8)), foreground: Some(Color::WHITE), ..VisualStyle::EMPTY }, ) .with_style( "label", VisualStyle { foreground: Some(Color::rgb(0.2, 0.2, 0.2)), ..VisualStyle::EMPTY }, ) } #[test] fn resolve_returns_default_for_no_style_or_overrides() { let theme = theme_with_three_styles(); let resolved = theme.resolve(None, &VisualStyle::EMPTY); assert_eq!(resolved.foreground, Some(Color::BLACK)); assert_eq!(resolved.background, Some(Color::WHITE)); assert_eq!(resolved.font_size, Some(14.0)); } #[test] fn named_style_overrides_default() { let theme = theme_with_three_styles(); let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY); assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // Foreground not set on "button" → kept from default. assert_eq!(resolved.foreground, Some(Color::BLACK)); assert_eq!(resolved.corner_radius, Some(4.0)); } #[test] fn per_instance_override_takes_final_precedence() { let theme = theme_with_three_styles(); let overlay = VisualStyle { background: Some(Color::RED), ..VisualStyle::EMPTY }; let resolved = theme.resolve(Some("button-primary"), &overlay); // Per-instance background wins over the named style. assert_eq!(resolved.background, Some(Color::RED)); // The named style's foreground (WHITE) still beats the default (BLACK). assert_eq!(resolved.foreground, Some(Color::WHITE)); } #[test] fn unknown_named_style_falls_back_to_default() { let theme = theme_with_three_styles(); let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY); // Same as resolve(None, &EMPTY). assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY)); } #[test] fn theme_round_trips_through_ron() { let theme = theme_with_three_styles(); let text = theme.to_ron().unwrap(); let decoded = Theme::from_ron(&text).unwrap(); assert_eq!(theme, decoded); // Named styles are alphabetised by BTreeMap, so "button" precedes // "button-primary" precedes "label" in the serialized form. let button_pos = text.find("\"button\"").unwrap(); let primary_pos = text.find("\"button-primary\"").unwrap(); let label_pos = text.find("\"label\"").unwrap(); assert!(button_pos < primary_pos); assert!(primary_pos < label_pos); } #[test] fn empty_theme_round_trips_to_empty_ron() { let empty = Theme::new(); let text = empty.to_ron().unwrap(); let decoded = Theme::from_ron(&text).unwrap(); assert_eq!(empty, decoded); // The empty theme should not mention either field. assert!(!text.contains("default:")); assert!(!text.contains("styles:")); } #[test] fn builder_chaining_inserts_styles_in_order() { let t = Theme::new() .with_style("a", VisualStyle::EMPTY) .with_style("b", VisualStyle::EMPTY); assert_eq!(t.styles.len(), 2); assert!(t.styles.contains_key("a")); assert!(t.styles.contains_key("b")); } }