9eead719b0
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
8.4 KiB
Rust
218 lines
8.4 KiB
Rust
//! 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<String, VisualStyle>,
|
|
}
|
|
|
|
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<String>, 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<String, ron::Error> {
|
|
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
|
}
|
|
|
|
/// Parse a theme from a RON string.
|
|
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
|
|
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"));
|
|
}
|
|
}
|