frust_theme/extensions.rs
1//! [`ThemeExtensions`]: the no-lock-in typed extension slot [`crate::theme::Theme`]
2//! carries (a Flutter `ThemeExtension` analog).
3//!
4//! `Theme` is a fixed 9(+1)-field struct — every widget in this repo reads a
5//! named field, and that stays true. But a third-party design system (or an
6//! app) that wants to carry its own token types alongside the built-in ones
7//! (without forking `frust-theme` to add a field `Theme` doesn't otherwise
8//! need) has nowhere to put them. `ThemeExtensions` is that seam: a small
9//! persistent type-map keyed by [`TypeId`], `Arc`-backed so a `Theme` clone
10//! (required at both delivery paths — the process-global `set_app_theme`
11//! `Mutex` slot and the reactive `provide_context` copy, see
12//! `docs/ARCHITECTURE.md`'s Theme delivery) stays cheap regardless of how
13//! many extension types are attached.
14//!
15//! [`StatusPalette`](crate::status::StatusPalette) is the first consumer —
16//! see that module.
17//!
18//! # Examples
19//!
20//! ```
21//! use frust_theme::Theme;
22//!
23//! // A third-party (or app-local) extension type — nothing `frust-theme`
24//! // needs to know about ahead of time.
25//! #[derive(Debug, Clone, PartialEq)]
26//! struct BrandTokens {
27//! logo_glow: bool,
28//! }
29//!
30//! let mut theme = Theme::neutral();
31//! theme.extensions.insert(BrandTokens { logo_glow: true });
32//!
33//! let tokens = theme.extension::<BrandTokens>().expect("inserted above");
34//! assert!(tokens.logo_glow);
35//!
36//! // A type that was never inserted comes back `None`, never a panic.
37//! #[derive(Debug)]
38//! struct NeverInserted;
39//! assert!(theme.extension::<NeverInserted>().is_none());
40//! ```
41
42use std::any::{Any, TypeId};
43use std::collections::HashMap;
44use std::fmt;
45use std::sync::Arc;
46
47/// A persistent, typed extension map: any `'static + Send + Sync` type can be
48/// attached once (a later [`insert`](Self::insert) of the same type replaces
49/// it) and recovered by type via [`get`](Self::get).
50///
51/// `Send + Sync` bounds match [`crate::theme::Theme`]'s own crossing of
52/// `frust-shell-common`'s process-global `Mutex<OverrideSlot>` (see that
53/// module's thread contract) — an extension type that isn't `Send + Sync`
54/// simply can't be inserted, a compile-time guarantee rather than a runtime
55/// one.
56#[derive(Clone, Default)]
57pub struct ThemeExtensions {
58 map: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
59}
60
61impl ThemeExtensions {
62 /// An empty extension map.
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 /// Attach `ext`, keyed by its concrete type `T`. A later `insert::<T>`
68 /// call replaces whatever `T` was previously attached (last write wins,
69 /// mirroring `HashMap::insert`).
70 pub fn insert<T: Any + Send + Sync>(&mut self, ext: T) {
71 self.map.insert(TypeId::of::<T>(), Arc::new(ext));
72 }
73
74 /// Recover the attached `T`, or `None` if nothing of that type was ever
75 /// [`insert`](Self::insert)ed — never a panic on a type that isn't there.
76 pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
77 self.map.get(&TypeId::of::<T>())?.downcast_ref::<T>()
78 }
79
80 /// Whether any extension is attached at all — mostly useful for tests.
81 pub fn is_empty(&self) -> bool {
82 self.map.is_empty()
83 }
84
85 /// How many distinct extension types are attached.
86 pub fn len(&self) -> usize {
87 self.map.len()
88 }
89}
90
91impl fmt::Debug for ThemeExtensions {
92 // `Arc<dyn Any + Send + Sync>` carries no `Debug` impl (trait objects
93 // can't require one without foreclosing arbitrary extension types), so
94 // this reports the attached type count rather than any value.
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.debug_struct("ThemeExtensions")
97 .field("len", &self.map.len())
98 .finish()
99 }
100}
101
102impl PartialEq for ThemeExtensions {
103 // Trait objects behind `Arc<dyn Any>` aren't structurally comparable (no
104 // blanket `PartialEq` for arbitrary `T`), so this compares the *set of
105 // attached types* rather than their values — good enough for `Theme`'s
106 // existing identity-style comparisons (`frust-shell-common`'s override
107 // watcher tests `assert_eq!` a polled `Theme` against a clone of the
108 // exact value that was set, which trivially shares the same type set)
109 // without requiring every future extension type to implement `Eq`.
110 fn eq(&self, other: &Self) -> bool {
111 self.map.len() == other.map.len() && self.map.keys().all(|k| other.map.contains_key(k))
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[derive(Debug, PartialEq)]
120 struct Foo(u32);
121
122 #[derive(Debug, PartialEq)]
123 struct Bar(&'static str);
124
125 #[test]
126 fn insert_then_get_round_trips() {
127 let mut ext = ThemeExtensions::new();
128 ext.insert(Foo(42));
129 assert_eq!(ext.get::<Foo>(), Some(&Foo(42)));
130 }
131
132 #[test]
133 fn missing_type_is_none_not_a_panic() {
134 let ext = ThemeExtensions::new();
135 assert!(ext.get::<Foo>().is_none());
136 }
137
138 #[test]
139 fn distinct_types_coexist() {
140 let mut ext = ThemeExtensions::new();
141 ext.insert(Foo(1));
142 ext.insert(Bar("hi"));
143 assert_eq!(ext.get::<Foo>(), Some(&Foo(1)));
144 assert_eq!(ext.get::<Bar>(), Some(&Bar("hi")));
145 }
146
147 #[test]
148 fn re_insert_of_same_type_replaces() {
149 let mut ext = ThemeExtensions::new();
150 ext.insert(Foo(1));
151 ext.insert(Foo(2));
152 assert_eq!(ext.get::<Foo>(), Some(&Foo(2)));
153 assert_eq!(ext.len(), 1);
154 }
155
156 #[test]
157 fn empty_map_reports_empty() {
158 let ext = ThemeExtensions::new();
159 assert!(ext.is_empty());
160 assert_eq!(ext.len(), 0);
161 }
162
163 #[test]
164 fn clone_is_independent_and_shares_attached_values() {
165 let mut ext = ThemeExtensions::new();
166 ext.insert(Foo(7));
167 let cloned = ext.clone();
168 assert_eq!(cloned.get::<Foo>(), Some(&Foo(7)));
169
170 // Mutating the original after cloning doesn't affect the clone —
171 // `Clone` on the map itself, not a shared `Arc<Mutex<..>>`.
172 ext.insert(Foo(99));
173 assert_eq!(ext.get::<Foo>(), Some(&Foo(99)));
174 assert_eq!(cloned.get::<Foo>(), Some(&Foo(7)));
175 }
176
177 #[test]
178 fn equality_compares_attached_type_set() {
179 let mut a = ThemeExtensions::new();
180 a.insert(Foo(1));
181 let mut b = ThemeExtensions::new();
182 b.insert(Foo(999)); // different value, same type set
183 assert_eq!(a, b);
184
185 let mut c = ThemeExtensions::new();
186 c.insert(Bar("x")); // different type set
187 assert_ne!(a, c);
188 }
189
190 #[test]
191 fn debug_reports_len_not_values() {
192 let mut ext = ThemeExtensions::new();
193 ext.insert(Foo(1));
194 let s = format!("{ext:?}");
195 assert!(s.contains("ThemeExtensions"));
196 assert!(s.contains('1'));
197 }
198}