Skip to main content

smelt_style/
theme.rs

1//! Theme registry: nvim-style highlight groups interned to stable [`HlGroup`] ids.
2//! Unknown names resolve to `Style::default()` without panicking.
3//!
4//! A `Theme` is a flat `HlGroup → Style` map plus an `is_light` hint. There
5//! is no aliasing layer - a colorscheme that wants two names to share a
6//! color either sets both directly, or expresses the relationship in its
7//! source spec (e.g. `Comment = "SmeltMuted"` resolved at compile time in
8//! `smelt_tui::theme::compile`). The runtime sees only resolved styles.
9
10use crate::style::Style;
11use std::collections::HashMap;
12use std::sync::{Arc, OnceLock, RwLock};
13
14/// Interned highlight-group id. Stable for the process lifetime.
15#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct HlGroup(pub u32);
17
18/// Process-global name → id interner. Decoupled from `Theme` so ids stay stable across
19/// theme switches and multiple `Theme` instances.
20struct HlGroupRegistry {
21    name_to_id: HashMap<String, HlGroup>,
22    id_to_name: Vec<String>,
23}
24
25impl HlGroupRegistry {
26    fn new() -> Self {
27        Self {
28            name_to_id: HashMap::new(),
29            id_to_name: Vec::new(),
30        }
31    }
32
33    fn intern(&mut self, name: &str) -> HlGroup {
34        if let Some(id) = self.name_to_id.get(name) {
35            return *id;
36        }
37        let id = HlGroup(self.id_to_name.len() as u32);
38        self.name_to_id.insert(name.to_string(), id);
39        self.id_to_name.push(name.to_string());
40        id
41    }
42}
43
44fn registry() -> &'static RwLock<HlGroupRegistry> {
45    static REG: OnceLock<RwLock<HlGroupRegistry>> = OnceLock::new();
46    REG.get_or_init(|| RwLock::new(HlGroupRegistry::new()))
47}
48
49/// Get-or-mint the [`HlGroup`] id for `name`.
50pub fn intern(name: &str) -> HlGroup {
51    if let Some(id) = registry().read().unwrap().name_to_id.get(name).copied() {
52        return id;
53    }
54    registry().write().unwrap().intern(name)
55}
56
57/// Reverse the interner: id → name.
58pub fn name_of(g: HlGroup) -> Option<String> {
59    registry()
60        .read()
61        .unwrap()
62        .id_to_name
63        .get(g.0 as usize)
64        .cloned()
65}
66
67/// Intern a `Style` as an anonymous group keyed by content hash.
68/// Anonymous groups bypass theme switches; use [`intern`] with a stable name for theme-reactive styling.
69///
70/// Called per span by syntect/markdown renderers; the read-locked fast path keeps
71/// parallel block workers from serializing on the anon-styles write lock.
72pub fn intern_anonymous_style(style: Style) -> HlGroup {
73    use std::collections::hash_map::DefaultHasher;
74    use std::hash::{Hash, Hasher};
75    let mut h = DefaultHasher::new();
76    style.hash(&mut h);
77    let style_hash = h.finish();
78
79    if let Some(&id) = anon_hash_to_group().read().unwrap().get(&style_hash) {
80        return id;
81    }
82
83    let key = format!("__anon__/{:016x}", style_hash);
84    let id = intern(&key);
85    anon_hash_to_group().write().unwrap().insert(style_hash, id);
86    anon_styles().write().unwrap().insert(id, style);
87    id
88}
89
90fn anon_styles() -> &'static RwLock<HashMap<HlGroup, Style>> {
91    static MAP: OnceLock<RwLock<HashMap<HlGroup, Style>>> = OnceLock::new();
92    MAP.get_or_init(|| RwLock::new(HashMap::new()))
93}
94
95/// Style-hash → interned id. Backs the read-locked fast path of `intern_anonymous_style`.
96fn anon_hash_to_group() -> &'static RwLock<HashMap<u64, HlGroup>> {
97    static MAP: OnceLock<RwLock<HashMap<u64, HlGroup>>> = OnceLock::new();
98    MAP.get_or_init(|| RwLock::new(HashMap::new()))
99}
100
101fn anon_resolve(id: HlGroup) -> Option<Style> {
102    anon_styles().read().unwrap().get(&id).copied()
103}
104
105/// Reset all process-global interners. Intended for deterministic-simulation
106/// tests and fuzz harnesses that reuse one process across scenarios; in
107/// production these maps grow monotonically and are never reset.
108///
109/// Not safe to call while other threads hold writes through `intern` or
110/// `intern_anonymous_style`.
111pub fn reset_for_test() {
112    let mut r = registry().write().unwrap();
113    r.name_to_id.clear();
114    r.id_to_name.clear();
115    anon_styles().write().unwrap().clear();
116    anon_hash_to_group().write().unwrap().clear();
117}
118
119/// Total distinct interned entries. Anonymous styles share the named
120/// registry under `__anon__/<hash>` keys (see `intern_anonymous_style`),
121/// so the named map's length already includes them - no separate count
122/// is added. Used by leak invariants to confirm registries don't grow
123/// across scenario repeats.
124pub fn registry_len() -> usize {
125    registry().read().unwrap().id_to_name.len()
126}
127
128/// `(named, anon)` interner sizes for diagnostics. `named` is the full
129/// `id_to_name` length (which already contains every anon entry);
130/// `anon` is the subset that has a resolved style in the `anon_styles`
131/// map. Their difference equals the count of "real" named groups
132/// registered by themes or callsites.
133pub fn registry_counts() -> (usize, usize) {
134    let named = registry().read().unwrap().id_to_name.len();
135    let anon = anon_styles().read().unwrap().len();
136    (named, anon)
137}
138
139/// Named groups registered at or after `from_idx`. Lets leak invariants
140/// list the *newly-added* names rather than print a count alone.
141pub fn names_since(from_idx: usize) -> Vec<String> {
142    let r = registry().read().unwrap();
143    r.id_to_name.get(from_idx..).unwrap_or(&[]).to_vec()
144}
145
146/// Resolved highlight-group → style map. `Theme` is materialized state:
147/// every group has its final `Style` baked in. Construct via
148/// `smelt_tui::theme::compile` or hand-build with [`Theme::set`].
149#[derive(Debug, Clone, Default)]
150pub struct Theme {
151    styles: HashMap<HlGroup, Style>,
152    is_light: bool,
153}
154
155impl Theme {
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Set `name` to `style`. Overwrites any prior value.
161    pub fn set(&mut self, name: impl Into<String>, style: Style) {
162        let id = intern(&name.into());
163        self.styles.insert(id, style);
164    }
165
166    /// Resolve a name to its current Style. Unknown names return `Style::default()`.
167    pub fn get(&self, name: &str) -> Style {
168        self.resolve(intern(name))
169    }
170
171    /// Resolve a [`HlGroup`] to its current Style. Anonymous style ids fall
172    /// through to the global anon registry; everything else returns
173    /// `Style::default()`.
174    pub fn resolve(&self, hl: HlGroup) -> Style {
175        if let Some(style) = self.styles.get(&hl).copied() {
176            return style;
177        }
178        anon_resolve(hl).unwrap_or_default()
179    }
180
181    /// Get-or-mint the HlGroup id for `name`.
182    pub fn id_for(&self, name: &str) -> HlGroup {
183        intern(name)
184    }
185
186    /// True iff this Theme has a Style registered for `hl`. False means
187    /// `resolve` will fall back to the anon registry or `Style::default()`.
188    pub fn contains(&self, hl: HlGroup) -> bool {
189        self.styles.contains_key(&hl)
190    }
191
192    pub fn is_light(&self) -> bool {
193        self.is_light
194    }
195
196    pub fn set_light(&mut self, light: bool) {
197        self.is_light = light;
198    }
199
200    /// Iterator over `(HlGroup, &Style)` for every group set on this theme.
201    /// Order is unspecified.
202    pub fn iter(&self) -> impl Iterator<Item = (HlGroup, &Style)> {
203        self.styles.iter().map(|(k, v)| (*k, v))
204    }
205
206    /// Number of groups set on this theme.
207    pub fn len(&self) -> usize {
208        self.styles.len()
209    }
210
211    pub fn is_empty(&self) -> bool {
212        self.styles.is_empty()
213    }
214}
215
216// ── Process-wide active theme ───────────────────────────────────────────
217//
218// Deep renderers (the diff renderer in `smelt_core`, future syntax
219// theming) can't reasonably thread `&Theme` through every signature -
220// they're called from worker threads that have no live app context. So
221// the runtime publishes the current `Theme` to one process-wide slot
222// that anyone can read with a single locked Arc clone.
223//
224// `smelt_tui::theme::compile` (and `smelt.theme.set` overrides) push
225// new themes here. Callers that hold their own `Arc<Theme>` (e.g. the
226// TUI `Surface`) should mirror it into the slot via `set_active` so
227// downstream readers see the same state.
228
229fn active_slot() -> &'static RwLock<Arc<Theme>> {
230    static SLOT: OnceLock<RwLock<Arc<Theme>>> = OnceLock::new();
231    SLOT.get_or_init(|| RwLock::new(Arc::new(Theme::new())))
232}
233
234/// Snapshot the active process-wide theme. Reads are uncontended in the
235/// steady state - the lock is only held during a theme swap.
236pub fn active() -> Arc<Theme> {
237    active_slot().read().unwrap().clone()
238}
239
240/// Install `theme` as the process-wide active theme.
241pub fn set_active(theme: Arc<Theme>) {
242    *active_slot().write().unwrap() = theme;
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::style::Color;
249
250    #[test]
251    fn unknown_name_returns_default() {
252        let t = Theme::new();
253        assert_eq!(t.get("Nonexistent"), Style::default());
254    }
255
256    #[test]
257    fn set_and_get_round_trip() {
258        let mut t = Theme::new();
259        let s = Style {
260            fg: Some(Color::Red),
261            bold: true,
262            ..Style::default()
263        };
264        t.set("Error", s);
265        assert_eq!(t.get("Error"), s);
266    }
267
268    #[test]
269    fn set_overwrites_existing_set() {
270        let mut t = Theme::new();
271        t.set("X", Style::new().bg(Color::AnsiValue(1)));
272        let direct = Style::new().bg(Color::AnsiValue(2));
273        t.set("X", direct);
274        assert_eq!(t.get("X"), direct);
275    }
276
277    #[test]
278    fn light_flag_round_trips() {
279        let mut t = Theme::new();
280        assert!(!t.is_light());
281        t.set_light(true);
282        assert!(t.is_light());
283    }
284
285    // ── Interner ──────────────────────────────────────────────────────────
286    // The interner is process-global; tests use unique prefixed names so they
287    // don't collide with each other or with sibling tests.
288
289    #[test]
290    fn intern_returns_same_id_for_same_name() {
291        assert_eq!(
292            intern("style_audit_intern_a"),
293            intern("style_audit_intern_a")
294        );
295    }
296
297    #[test]
298    fn intern_returns_different_ids_for_different_names() {
299        assert_ne!(
300            intern("style_audit_intern_b"),
301            intern("style_audit_intern_c")
302        );
303    }
304
305    #[test]
306    fn name_of_round_trips_interned_id() {
307        let id = intern("style_audit_name_of");
308        assert_eq!(name_of(id), Some("style_audit_name_of".to_string()));
309    }
310
311    #[test]
312    fn name_of_returns_none_for_unminted_id() {
313        // u32::MAX is far past any id the test process would have minted.
314        assert_eq!(name_of(HlGroup(u32::MAX)), None);
315    }
316
317    #[test]
318    fn intern_anonymous_style_returns_same_id_for_equal_styles() {
319        let s = Style::new().fg(Color::Red).bold();
320        assert_eq!(intern_anonymous_style(s), intern_anonymous_style(s));
321    }
322
323    #[test]
324    fn intern_anonymous_style_returns_different_ids_for_distinct_styles() {
325        let s1 = Style::new().fg(Color::Red).bold();
326        let s2 = Style::new().fg(Color::Blue).bold();
327        assert_ne!(intern_anonymous_style(s1), intern_anonymous_style(s2));
328    }
329
330    #[test]
331    fn theme_resolves_anonymous_style_via_fallthrough() {
332        // A Theme with no entry for the anon id must still resolve the style
333        // by falling through to the global anon registry.
334        let t = Theme::new();
335        let style = Style::new().fg(Color::Cyan).italic();
336        let id = intern_anonymous_style(style);
337        assert_eq!(t.resolve(id), style);
338    }
339
340    #[test]
341    fn contains_reports_set_names_only() {
342        let mut t = Theme::new();
343        t.set("style_audit_contains_a", Style::new().bold());
344        assert!(t.contains(t.id_for("style_audit_contains_a")));
345        assert!(!t.contains(t.id_for("style_audit_contains_unknown")));
346    }
347}