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}