Skip to main content

rosace_theme/
theme.rs

1//! Core theme types: `ThemeData` and the `RosaceTheme` trait.
2
3use std::any::{Any, TypeId};
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use crate::color::ColorScheme;
8use crate::radius::BorderRadius;
9use crate::spacing::Spacing;
10use crate::typography::Typography;
11
12/// Global animation policy (theme-level). Toggle-style widgets
13/// (Switch, Checkbox, Radio) and other transitions read this: when
14/// `enabled` is false everything snaps; otherwise they ease over
15/// `duration_ms`. Set it once on the theme and every widget follows.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct AnimationConfig {
18    pub enabled: bool,
19    pub duration_ms: f32,
20}
21
22impl Default for AnimationConfig {
23    fn default() -> Self { Self { enabled: true, duration_ms: 110.0 } }
24}
25
26/// Where an [`AppBar`](rosace_widgets equivalent — see `rosace-widgets`)
27/// positions its title relative to the bar, per D105.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TitleAlign {
30    /// Centered within the space between the leading widget and the actions
31    /// (falls back to left-aligned if the title doesn't fit) — today's
32    /// existing behavior, kept as the default so converting AppBar to read
33    /// this doesn't change any app that hasn't opted into a platform theme.
34    Leading,
35    /// Centered in the FULL bar width regardless of leading/actions — the
36    /// iOS convention.
37    Center,
38}
39
40/// Per-widget platform styling for `AppBar` (D105 Phase 23 Step 3 — the
41/// proof-of-concept widget for the whole per-widget-Style-struct model).
42/// Per-instance builder calls on the widget itself (`.height(..)`,
43/// `.traffic_lights()`) override these theme defaults; a widget that
44/// doesn't set them falls back to whatever the active theme says.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct AppBarStyle {
47    pub title_align: TitleAlign,
48    /// Draw faux macOS traffic-light dots. Stays `false` on every built-in
49    /// theme, including macOS — a real window already has real OS traffic
50    /// lights; this is decorative mockup chrome for docs/screenshots only
51    /// (see the widget's own doc comment), never something a theme should
52    /// silently turn on for real apps.
53    pub show_traffic_lights: bool,
54    pub height: f32,
55    /// Values above `0.0` draw the bar's separating edge (today's flat
56    /// bottom border); `0.0` omits it. Not yet a real elevation/shadow
57    /// effect — a coarser "on/off" proxy for the proof, real elevation
58    /// rendering is later work.
59    pub elevation: f32,
60}
61
62impl Default for AppBarStyle {
63    fn default() -> Self {
64        Self { title_align: TitleAlign::Leading, show_traffic_lights: false, height: 44.0, elevation: 1.0 }
65    }
66}
67
68/// All design tokens bundled together as a single snapshot.
69///
70/// `ThemeData` is `Clone` so it can be cheaply shared via the global atom.
71#[derive(Clone)]
72pub struct ThemeData {
73    pub colors: ColorScheme,
74    /// Global animation policy — see [`AnimationConfig`].
75    pub animation: AnimationConfig,
76    pub typography: Typography,
77    pub spacing: Spacing,
78    pub radius: BorderRadius,
79    /// `true` for dark themes; `false` for light themes.
80    pub is_dark: bool,
81    /// Platform-adaptive AppBar defaults (D105). The first of what will
82    /// become several per-widget Style fields — see Phase 23.
83    pub app_bar: AppBarStyle,
84    /// Type-keyed extension map (D105 Phase 23 Step 4): lets a custom widget
85    /// stash and read its own theme-style struct without editing this type.
86    /// Populate via [`ThemeData::with_ext`], read via [`ThemeData::ext`].
87    pub ext: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
88}
89
90impl std::fmt::Debug for ThemeData {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("ThemeData")
93            .field("colors", &self.colors)
94            .field("animation", &self.animation)
95            .field("typography", &self.typography)
96            .field("spacing", &self.spacing)
97            .field("radius", &self.radius)
98            .field("is_dark", &self.is_dark)
99            .field("app_bar", &self.app_bar)
100            .field("ext", &format_args!("{} extension(s)", self.ext.len()))
101            .finish()
102    }
103}
104
105/// Implement this trait to supply a custom theme to the framework.
106///
107/// The bound `Send + Sync + 'static` ensures that theme objects can be stored
108/// in global statics and shared across threads.
109pub trait RosaceTheme: Send + Sync + 'static {
110    fn theme_data(&self) -> &ThemeData;
111}
112
113impl ThemeData {
114    /// Enable or disable global animation (theme-level).
115    pub fn animations(mut self, enabled: bool) -> Self {
116        self.animation.enabled = enabled; self
117    }
118    /// Set the global animation duration in milliseconds.
119    pub fn animation_ms(mut self, ms: f32) -> Self {
120        self.animation.duration_ms = ms; self
121    }
122
123    /// Stash a custom per-widget style struct in the theme, keyed by its own
124    /// type (D105 Phase 23 Step 4). Lets a new/custom widget theme itself
125    /// without any change to `ThemeData`'s fields.
126    pub fn with_ext<T: Any + Send + Sync + 'static>(mut self, ext: T) -> Self {
127        self.ext.insert(TypeId::of::<T>(), Arc::new(ext));
128        self
129    }
130
131    /// Read a previously-stashed extension struct by type, if the theme set
132    /// one. Falls back to `None` so callers typically pair this with
133    /// `.unwrap_or_default()` or a widget-local default.
134    pub fn ext<T: Any + Send + Sync + 'static>(&self) -> Option<&T> {
135        self.ext.get(&TypeId::of::<T>()).and_then(|a| a.downcast_ref::<T>())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use crate::built_in::light_theme;
142
143    /// A style struct for a hypothetical custom widget (not part of core
144    /// `rosace-theme`), proving Step 4's exit criteria: it themes itself
145    /// purely via `with_ext`/`ext`, no edit to `ThemeData`'s fields needed.
146    #[derive(Debug, Clone, Copy, PartialEq)]
147    struct BadgeStyle {
148        corner_radius: f32,
149    }
150
151    #[test]
152    fn ext_round_trips_a_custom_style() {
153        let theme = light_theme().with_ext(BadgeStyle { corner_radius: 6.0 });
154        let badge = theme.ext::<BadgeStyle>().expect("BadgeStyle should be present");
155        assert_eq!(badge.corner_radius, 6.0);
156    }
157
158    #[test]
159    fn ext_is_none_when_never_set() {
160        let theme = light_theme();
161        assert!(theme.ext::<BadgeStyle>().is_none());
162    }
163
164    #[test]
165    fn ext_distinguishes_by_type() {
166        #[derive(Debug, Clone, Copy, PartialEq)]
167        struct OtherStyle {
168            weight: f32,
169        }
170        let theme = light_theme().with_ext(BadgeStyle { corner_radius: 6.0 });
171        assert!(theme.ext::<OtherStyle>().is_none());
172        assert!(theme.ext::<BadgeStyle>().is_some());
173    }
174}