1use crate::style::Style;
11use std::collections::HashMap;
12use std::sync::{Arc, OnceLock, RwLock};
13
14#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct HlGroup(pub u32);
17
18struct 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
49pub 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
57pub 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
67pub 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
95fn 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
105pub 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
119pub fn registry_len() -> usize {
125 registry().read().unwrap().id_to_name.len()
126}
127
128pub 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
139pub 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#[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 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 pub fn get(&self, name: &str) -> Style {
168 self.resolve(intern(name))
169 }
170
171 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 pub fn id_for(&self, name: &str) -> HlGroup {
183 intern(name)
184 }
185
186 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 pub fn iter(&self) -> impl Iterator<Item = (HlGroup, &Style)> {
203 self.styles.iter().map(|(k, v)| (*k, v))
204 }
205
206 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
216fn 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
234pub fn active() -> Arc<Theme> {
237 active_slot().read().unwrap().clone()
238}
239
240pub 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 #[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 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 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}