Skip to main content

teksilo_core/styles/
theme_extension.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Typed extension registry attached to a [`Theme`](crate::styles::Theme).
5//!
6//! Apps and downstream crates use this to attach typed values that the
7//! core theme struct doesn't know about — a syntax-color palette for a
8//! code editor, a data-viz palette for a dashboard, per-app default
9//! variants — without modifying [`Theme`](crate::styles::Theme) or any
10//! of its token sub-structs.
11//!
12//! Lookup is by Rust type (`TypeId`); each `T` has at most one slot.
13//!
14//! ```
15//! pub struct MySyntaxPalette { pub keyword_color: u32 }
16//!
17//! let theme = teksilo_core::presets::intui::light()
18//!     .with_extension(MySyntaxPalette { keyword_color: 0xFF6600 });
19//!
20//! // Later, anywhere with a &Theme:
21//! if let Some(syntax) = theme.extension::<MySyntaxPalette>() {
22//!     let _ = syntax.keyword_color;
23//! }
24//! ```
25//!
26//! Extensions are skipped during serde round-trips — they re-attach at
27//! runtime from app code, not from theme JSON.
28
29use std::any::{Any, TypeId};
30use std::collections::HashMap;
31use std::fmt;
32use std::sync::Arc;
33
34#[derive(Clone, Default)]
35pub struct ThemeExtensions {
36    map: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
37}
38
39impl ThemeExtensions {
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
45        self.map
46            .get(&TypeId::of::<T>())
47            .and_then(|a| a.downcast_ref::<T>())
48    }
49
50    pub fn insert<T: Any + Send + Sync>(&mut self, value: T) {
51        self.map.insert(TypeId::of::<T>(), Arc::new(value));
52    }
53
54    pub fn remove<T: Any + Send + Sync>(&mut self) -> bool {
55        self.map.remove(&TypeId::of::<T>()).is_some()
56    }
57
58    pub fn contains<T: Any + Send + Sync>(&self) -> bool {
59        self.map.contains_key(&TypeId::of::<T>())
60    }
61
62    pub fn len(&self) -> usize {
63        self.map.len()
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.map.is_empty()
68    }
69}
70
71impl fmt::Debug for ThemeExtensions {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "ThemeExtensions({} entries)", self.map.len())
74    }
75}
76
77impl PartialEq for ThemeExtensions {
78    /// Equality compares only which extension types are registered, not
79    /// the inner values (`dyn Any` has no `PartialEq`).
80    fn eq(&self, other: &Self) -> bool {
81        if self.map.len() != other.map.len() {
82            return false;
83        }
84        self.map.keys().all(|k| other.map.contains_key(k))
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[derive(Debug, Clone, PartialEq)]
93    struct Marker(u32);
94
95    #[derive(Debug, Clone, PartialEq)]
96    struct Other(&'static str);
97
98    #[test]
99    fn insert_and_get_round_trip() {
100        let mut ext = ThemeExtensions::new();
101        ext.insert(Marker(7));
102        assert_eq!(ext.get::<Marker>(), Some(&Marker(7)));
103        assert!(ext.contains::<Marker>());
104        assert_eq!(ext.len(), 1);
105    }
106
107    #[test]
108    fn distinct_types_share_the_registry() {
109        let mut ext = ThemeExtensions::new();
110        ext.insert(Marker(1));
111        ext.insert(Other("hi"));
112        assert_eq!(ext.len(), 2);
113        assert_eq!(ext.get::<Marker>(), Some(&Marker(1)));
114        assert_eq!(ext.get::<Other>(), Some(&Other("hi")));
115    }
116
117    #[test]
118    fn remove_drops_the_slot() {
119        let mut ext = ThemeExtensions::new();
120        ext.insert(Marker(1));
121        assert!(ext.remove::<Marker>());
122        assert!(!ext.contains::<Marker>());
123        assert!(!ext.remove::<Marker>());
124    }
125
126    #[test]
127    fn debug_format_is_short() {
128        let mut ext = ThemeExtensions::new();
129        ext.insert(Marker(1));
130        let s = format!("{ext:?}");
131        assert!(s.contains("1 entries"), "got: {s}");
132    }
133
134    #[test]
135    fn equality_ignores_inner_values() {
136        let mut a = ThemeExtensions::new();
137        let mut b = ThemeExtensions::new();
138        a.insert(Marker(1));
139        b.insert(Marker(2));
140        // Same TypeId, different value — still equal under our definition.
141        assert_eq!(a, b);
142    }
143}