Skip to main content

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}