Skip to main content

ftui_style/
stylesheet.rs

1#![forbid(unsafe_code)]
2
3//! StyleSheet registry for named styles.
4//!
5//! StyleSheet provides named style registration similar to CSS classes.
6//! This enables themeable applications and consistent style reuse without
7//! hardcoding colors.
8//!
9//! # Example
10//! ```
11//! use ftui_style::{Style, StyleSheet, StyleId};
12//! use ftui_render::cell::PackedRgba;
13//!
14//! let mut sheet = StyleSheet::new();
15//!
16//! // Define named styles
17//! sheet.define("error", Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold());
18//! sheet.define("warning", Style::new().fg(PackedRgba::rgb(255, 165, 0)));
19//!
20//! // Look up by name
21//! let error_style = sheet.get("error").unwrap();
22//!
23//! // Compose multiple styles (later ones take precedence)
24//! let composed = sheet.compose(&["base", "error"]);
25//! ```
26
27use crate::style::Style;
28use ahash::AHashMap;
29use ftui_render::cell::PackedRgba;
30use std::sync::RwLock;
31
32/// Identifier for a named style in a StyleSheet.
33///
34/// StyleId is a simple string-based identifier for named styles.
35/// We use `&str` for lookups and `String` for storage to balance
36/// ergonomics and performance.
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct StyleId(pub String);
39
40impl StyleId {
41    /// Create a new StyleId from a string.
42    #[inline]
43    pub fn new(name: impl Into<String>) -> Self {
44        Self(name.into())
45    }
46
47    /// Get the name as a string slice.
48    #[inline]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl From<&str> for StyleId {
55    fn from(s: &str) -> Self {
56        Self(s.to_string())
57    }
58}
59
60impl From<String> for StyleId {
61    fn from(s: String) -> Self {
62        Self(s)
63    }
64}
65
66impl AsRef<str> for StyleId {
67    fn as_ref(&self) -> &str {
68        &self.0
69    }
70}
71
72/// A registry of named styles for consistent theming.
73///
74/// StyleSheet allows defining styles by name and looking them up later.
75/// This decouples visual appearance from widget logic, allowing themes
76/// to override the stylesheet without changing widget code.
77///
78/// # Thread Safety
79///
80/// StyleSheet uses an internal RwLock for thread-safe read access
81/// after initialization. Multiple readers can access styles concurrently.
82#[derive(Debug, Default)]
83pub struct StyleSheet {
84    styles: RwLock<AHashMap<String, Style>>,
85}
86
87impl StyleSheet {
88    /// Create a new empty StyleSheet.
89    #[inline]
90    pub fn new() -> Self {
91        Self {
92            styles: RwLock::new(AHashMap::new()),
93        }
94    }
95
96    /// Create a StyleSheet with default semantic styles.
97    ///
98    /// This provides a base set of commonly-used style names:
99    /// - `error`: Red, bold
100    /// - `warning`: Orange/yellow
101    /// - `info`: Blue
102    /// - `success`: Green
103    /// - `muted`: Gray/dim
104    /// - `highlight`: Yellow background
105    /// - `link`: Blue, underline
106    #[must_use]
107    pub fn with_defaults() -> Self {
108        let sheet = Self::new();
109
110        // Error: Red, bold
111        sheet.define(
112            "error",
113            Style::new().fg(PackedRgba::rgb(255, 85, 85)).bold(),
114        );
115
116        // Warning: Orange/yellow
117        sheet.define("warning", Style::new().fg(PackedRgba::rgb(255, 170, 0)));
118
119        // Info: Blue
120        sheet.define("info", Style::new().fg(PackedRgba::rgb(85, 170, 255)));
121
122        // Success: Green
123        sheet.define("success", Style::new().fg(PackedRgba::rgb(85, 255, 85)));
124
125        // Muted: Gray, dim
126        sheet.define(
127            "muted",
128            Style::new().fg(PackedRgba::rgb(128, 128, 128)).dim(),
129        );
130
131        // Highlight: Yellow background
132        sheet.define(
133            "highlight",
134            Style::new()
135                .bg(PackedRgba::rgb(255, 255, 0))
136                .fg(PackedRgba::rgb(0, 0, 0)),
137        );
138
139        // Link: Blue, underline
140        sheet.define(
141            "link",
142            Style::new().fg(PackedRgba::rgb(85, 170, 255)).underline(),
143        );
144
145        sheet
146    }
147
148    /// Define a named style.
149    ///
150    /// If a style with this name already exists, it is replaced.
151    pub fn define(&self, name: impl Into<String>, style: Style) {
152        let name = name.into();
153        let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
154        styles.insert(name, style);
155    }
156
157    /// Remove a named style.
158    ///
159    /// Returns the removed style if it existed.
160    pub fn remove(&self, name: &str) -> Option<Style> {
161        let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
162        styles.remove(name)
163    }
164
165    /// Get a named style.
166    ///
167    /// Returns `None` if the style is not defined.
168    pub fn get(&self, name: &str) -> Option<Style> {
169        let styles = self.styles.read().expect("StyleSheet lock poisoned");
170        styles.get(name).copied()
171    }
172
173    /// Get a named style, returning a default if not found.
174    pub fn get_or_default(&self, name: &str) -> Style {
175        self.get(name).unwrap_or_default()
176    }
177
178    /// Check if a style with the given name exists.
179    pub fn contains(&self, name: &str) -> bool {
180        let styles = self.styles.read().expect("StyleSheet lock poisoned");
181        styles.contains_key(name)
182    }
183
184    /// Get the number of defined styles.
185    pub fn len(&self) -> usize {
186        let styles = self.styles.read().expect("StyleSheet lock poisoned");
187        styles.len()
188    }
189
190    /// Check if the stylesheet is empty.
191    pub fn is_empty(&self) -> bool {
192        self.len() == 0
193    }
194
195    /// Get all style names.
196    pub fn names(&self) -> Vec<String> {
197        let styles = self.styles.read().expect("StyleSheet lock poisoned");
198        styles.keys().cloned().collect()
199    }
200
201    /// Compose multiple styles by name, merging them in order.
202    ///
203    /// Styles are merged left-to-right, with later styles taking
204    /// precedence over earlier ones for conflicting properties.
205    ///
206    /// Missing style names are silently ignored.
207    ///
208    /// # Example
209    /// ```
210    /// use ftui_style::{Style, StyleSheet};
211    /// use ftui_render::cell::PackedRgba;
212    ///
213    /// let sheet = StyleSheet::new();
214    /// sheet.define("base", Style::new().fg(PackedRgba::WHITE));
215    /// sheet.define("bold", Style::new().bold());
216    ///
217    /// // Compose: base + bold = white text that's bold
218    /// let composed = sheet.compose(&["base", "bold"]);
219    /// ```
220    pub fn compose(&self, names: &[&str]) -> Style {
221        let styles = self.styles.read().expect("StyleSheet lock poisoned");
222        let mut result = Style::default();
223
224        for name in names {
225            if let Some(style) = styles.get(*name) {
226                result = style.merge(&result);
227            }
228        }
229
230        result
231    }
232
233    /// Compose styles with fallback for missing names.
234    ///
235    /// Like `compose`, but returns `None` if any named style is missing.
236    pub fn compose_strict(&self, names: &[&str]) -> Option<Style> {
237        let styles = self.styles.read().expect("StyleSheet lock poisoned");
238        let mut result = Style::default();
239
240        for name in names {
241            match styles.get(*name) {
242                Some(style) => result = style.merge(&result),
243                None => return None,
244            }
245        }
246
247        Some(result)
248    }
249
250    /// Extend this stylesheet with styles from another.
251    ///
252    /// Styles from `other` override styles with the same name in `self`.
253    pub fn extend(&self, other: &StyleSheet) {
254        if std::ptr::eq(self, other) {
255            return;
256        }
257        let other_styles = {
258            let other_styles = other.styles.read().expect("StyleSheet lock poisoned");
259            other_styles.clone()
260        };
261        let mut self_styles = self.styles.write().expect("StyleSheet lock poisoned");
262        self_styles.extend(other_styles);
263    }
264
265    /// Clear all styles from the stylesheet.
266    pub fn clear(&self) {
267        let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
268        styles.clear();
269    }
270}
271
272impl Clone for StyleSheet {
273    fn clone(&self) -> Self {
274        let styles = self.styles.read().expect("StyleSheet lock poisoned");
275        Self {
276            styles: RwLock::new(styles.clone()),
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::style::StyleFlags;
285
286    #[test]
287    fn new_stylesheet_is_empty() {
288        let sheet = StyleSheet::new();
289        assert!(sheet.is_empty());
290        assert_eq!(sheet.len(), 0);
291    }
292
293    #[test]
294    fn define_and_get_style() {
295        let sheet = StyleSheet::new();
296        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
297
298        sheet.define("error", style);
299
300        assert!(!sheet.is_empty());
301        assert_eq!(sheet.len(), 1);
302        assert!(sheet.contains("error"));
303
304        let retrieved = sheet.get("error").unwrap();
305        assert_eq!(retrieved, style);
306    }
307
308    #[test]
309    fn get_nonexistent_returns_none() {
310        let sheet = StyleSheet::new();
311        assert!(sheet.get("nonexistent").is_none());
312    }
313
314    #[test]
315    fn get_or_default_returns_default_for_missing() {
316        let sheet = StyleSheet::new();
317        let style = sheet.get_or_default("missing");
318        assert!(style.is_empty());
319    }
320
321    #[test]
322    fn define_replaces_existing() {
323        let sheet = StyleSheet::new();
324
325        sheet.define("test", Style::new().bold());
326        assert!(sheet.get("test").unwrap().has_attr(StyleFlags::BOLD));
327
328        sheet.define("test", Style::new().italic());
329        let style = sheet.get("test").unwrap();
330        assert!(!style.has_attr(StyleFlags::BOLD));
331        assert!(style.has_attr(StyleFlags::ITALIC));
332    }
333
334    #[test]
335    fn remove_style() {
336        let sheet = StyleSheet::new();
337        sheet.define("test", Style::new().bold());
338
339        let removed = sheet.remove("test");
340        assert!(removed.is_some());
341        assert!(!sheet.contains("test"));
342
343        let removed_again = sheet.remove("test");
344        assert!(removed_again.is_none());
345    }
346
347    #[test]
348    fn names_returns_all_style_names() {
349        let sheet = StyleSheet::new();
350        sheet.define("a", Style::new());
351        sheet.define("b", Style::new());
352        sheet.define("c", Style::new());
353
354        let names = sheet.names();
355        assert_eq!(names.len(), 3);
356        assert!(names.contains(&"a".to_string()));
357        assert!(names.contains(&"b".to_string()));
358        assert!(names.contains(&"c".to_string()));
359    }
360
361    #[test]
362    fn compose_merges_styles() {
363        let sheet = StyleSheet::new();
364        sheet.define("base", Style::new().fg(PackedRgba::WHITE));
365        sheet.define("bold", Style::new().bold());
366
367        let composed = sheet.compose(&["base", "bold"]);
368
369        assert_eq!(composed.fg, Some(PackedRgba::WHITE));
370        assert!(composed.has_attr(StyleFlags::BOLD));
371    }
372
373    #[test]
374    fn compose_later_wins_on_conflict() {
375        let sheet = StyleSheet::new();
376        let red = PackedRgba::rgb(255, 0, 0);
377        let blue = PackedRgba::rgb(0, 0, 255);
378
379        sheet.define("red", Style::new().fg(red));
380        sheet.define("blue", Style::new().fg(blue));
381
382        let composed = sheet.compose(&["red", "blue"]);
383        assert_eq!(composed.fg, Some(blue));
384    }
385
386    #[test]
387    fn compose_ignores_missing() {
388        let sheet = StyleSheet::new();
389        sheet.define("exists", Style::new().bold());
390
391        let composed = sheet.compose(&["missing", "exists"]);
392        assert!(composed.has_attr(StyleFlags::BOLD));
393    }
394
395    #[test]
396    fn compose_strict_fails_on_missing() {
397        let sheet = StyleSheet::new();
398        sheet.define("exists", Style::new().bold());
399
400        let result = sheet.compose_strict(&["exists", "missing"]);
401        assert!(result.is_none());
402    }
403
404    #[test]
405    fn compose_strict_succeeds_when_all_present() {
406        let sheet = StyleSheet::new();
407        sheet.define("a", Style::new().bold());
408        sheet.define("b", Style::new().italic());
409
410        let result = sheet.compose_strict(&["a", "b"]);
411        assert!(result.is_some());
412
413        let style = result.unwrap();
414        assert!(style.has_attr(StyleFlags::BOLD));
415        assert!(style.has_attr(StyleFlags::ITALIC));
416    }
417
418    #[test]
419    fn with_defaults_has_semantic_styles() {
420        let sheet = StyleSheet::with_defaults();
421
422        assert!(sheet.contains("error"));
423        assert!(sheet.contains("warning"));
424        assert!(sheet.contains("info"));
425        assert!(sheet.contains("success"));
426        assert!(sheet.contains("muted"));
427        assert!(sheet.contains("highlight"));
428        assert!(sheet.contains("link"));
429
430        // Check error is red and bold
431        let error = sheet.get("error").unwrap();
432        assert!(error.has_attr(StyleFlags::BOLD));
433        assert!(error.fg.is_some());
434    }
435
436    #[test]
437    fn extend_merges_stylesheets() {
438        let sheet1 = StyleSheet::new();
439        sheet1.define("a", Style::new().bold());
440
441        let sheet2 = StyleSheet::new();
442        sheet2.define("b", Style::new().italic());
443
444        sheet1.extend(&sheet2);
445
446        assert!(sheet1.contains("a"));
447        assert!(sheet1.contains("b"));
448    }
449
450    #[test]
451    fn extend_overrides_existing() {
452        let sheet1 = StyleSheet::new();
453        sheet1.define("test", Style::new().bold());
454
455        let sheet2 = StyleSheet::new();
456        sheet2.define("test", Style::new().italic());
457
458        sheet1.extend(&sheet2);
459
460        let style = sheet1.get("test").unwrap();
461        assert!(!style.has_attr(StyleFlags::BOLD));
462        assert!(style.has_attr(StyleFlags::ITALIC));
463    }
464
465    #[test]
466    fn concurrent_bidirectional_extend_completes() {
467        use std::sync::{Arc, Barrier, mpsc};
468        use std::time::Duration;
469
470        let sheet1 = Arc::new(StyleSheet::new());
471        sheet1.define("a", Style::new().bold());
472
473        let sheet2 = Arc::new(StyleSheet::new());
474        sheet2.define("b", Style::new().italic());
475
476        let barrier = Arc::new(Barrier::new(3));
477        let (done_tx, done_rx) = mpsc::channel();
478
479        let sheet1_to_sheet2 = {
480            let barrier = Arc::clone(&barrier);
481            let done_tx = done_tx.clone();
482            let sheet1 = Arc::clone(&sheet1);
483            let sheet2 = Arc::clone(&sheet2);
484            std::thread::spawn(move || {
485                barrier.wait();
486                sheet1.extend(&sheet2);
487                done_tx.send(()).expect("completion signal");
488            })
489        };
490
491        let sheet2_to_sheet1 = {
492            let barrier = Arc::clone(&barrier);
493            let done_tx = done_tx.clone();
494            let sheet1 = Arc::clone(&sheet1);
495            let sheet2 = Arc::clone(&sheet2);
496            std::thread::spawn(move || {
497                barrier.wait();
498                sheet2.extend(&sheet1);
499                done_tx.send(()).expect("completion signal");
500            })
501        };
502
503        barrier.wait();
504
505        for _ in 0..2 {
506            done_rx
507                .recv_timeout(Duration::from_secs(1))
508                .expect("cross-extend should complete without deadlocking");
509        }
510
511        sheet1_to_sheet2.join().expect("sheet1 extend thread");
512        sheet2_to_sheet1.join().expect("sheet2 extend thread");
513
514        assert!(sheet1.contains("b"));
515        assert!(sheet2.contains("a"));
516    }
517
518    #[test]
519    fn clear_removes_all_styles() {
520        let sheet = StyleSheet::with_defaults();
521        assert!(!sheet.is_empty());
522
523        sheet.clear();
524        assert!(sheet.is_empty());
525    }
526
527    #[test]
528    fn clone_creates_independent_copy() {
529        let sheet1 = StyleSheet::new();
530        sheet1.define("test", Style::new().bold());
531
532        let sheet2 = sheet1.clone();
533        sheet1.define("other", Style::new());
534
535        assert!(sheet1.contains("other"));
536        assert!(!sheet2.contains("other"));
537    }
538
539    #[test]
540    fn style_id_from_str() {
541        let id: StyleId = "error".into();
542        assert_eq!(id.as_str(), "error");
543    }
544
545    #[test]
546    fn style_id_from_string() {
547        let id: StyleId = String::from("error").into();
548        assert_eq!(id.as_str(), "error");
549    }
550
551    #[test]
552    fn style_id_equality() {
553        let id1 = StyleId::new("error");
554        let id2 = StyleId::new("error");
555        let id3 = StyleId::new("warning");
556
557        assert_eq!(id1, id2);
558        assert_ne!(id1, id3);
559    }
560
561    #[test]
562    fn stylesheet_thread_safe_reads() {
563        use std::sync::Arc;
564        use std::thread;
565
566        let sheet = Arc::new(StyleSheet::new());
567        sheet.define("test", Style::new().bold());
568
569        let handles: Vec<_> = (0..4)
570            .map(|_| {
571                let sheet = Arc::clone(&sheet);
572                thread::spawn(move || {
573                    for _ in 0..100 {
574                        let _ = sheet.get("test");
575                    }
576                })
577            })
578            .collect();
579
580        for handle in handles {
581            handle.join().unwrap();
582        }
583    }
584
585    #[test]
586    fn stylesheet_thread_safe_writes() {
587        use std::sync::Arc;
588        use std::thread;
589
590        let sheet = Arc::new(StyleSheet::new());
591
592        let handles: Vec<_> = (0..4)
593            .map(|i| {
594                let sheet = Arc::clone(&sheet);
595                thread::spawn(move || {
596                    for j in 0..25 {
597                        let name = format!("style_{}_{}", i, j);
598                        sheet.define(&name, Style::new().bold());
599                    }
600                })
601            })
602            .collect();
603
604        for handle in handles {
605            handle.join().unwrap();
606        }
607
608        // Should have 100 styles (4 threads * 25 each)
609        assert_eq!(sheet.len(), 100);
610    }
611
612    #[test]
613    fn compose_empty_list_returns_default() {
614        let sheet = StyleSheet::new();
615        sheet.define("test", Style::new().bold());
616
617        let composed = sheet.compose(&[]);
618        assert!(composed.is_empty());
619    }
620
621    #[test]
622    fn compose_strict_empty_list_returns_some_default() {
623        let sheet = StyleSheet::new();
624        sheet.define("test", Style::new().bold());
625
626        let result = sheet.compose_strict(&[]);
627        assert!(result.is_some());
628        assert!(result.unwrap().is_empty());
629    }
630
631    #[test]
632    fn extend_self_is_noop() {
633        let sheet = StyleSheet::new();
634        sheet.define("test", Style::new().bold());
635
636        // Extending self should not panic or change anything
637        sheet.extend(&sheet);
638
639        assert_eq!(sheet.len(), 1);
640        assert!(sheet.contains("test"));
641    }
642
643    #[test]
644    fn stylesheet_default_is_empty() {
645        let sheet = StyleSheet::default();
646        assert!(sheet.is_empty());
647    }
648
649    #[test]
650    fn define_with_empty_name() {
651        let sheet = StyleSheet::new();
652        sheet.define("", Style::new().bold());
653
654        assert!(sheet.contains(""));
655        assert!(sheet.get("").unwrap().has_attr(StyleFlags::BOLD));
656    }
657
658    #[test]
659    fn style_id_as_ref_str() {
660        let id = StyleId::new("test");
661        let s: &str = id.as_ref();
662        assert_eq!(s, "test");
663    }
664
665    #[test]
666    fn style_id_clone() {
667        let id1 = StyleId::new("test");
668        let id2 = id1.clone();
669        assert_eq!(id1, id2);
670    }
671
672    #[test]
673    fn style_id_debug_impl() {
674        let id = StyleId::new("test");
675        let debug = format!("{:?}", id);
676        assert!(debug.contains("test"));
677    }
678
679    #[test]
680    fn stylesheet_debug_impl() {
681        let sheet = StyleSheet::new();
682        sheet.define("test", Style::new());
683        let debug = format!("{:?}", sheet);
684        assert!(debug.contains("StyleSheet"));
685    }
686
687    #[test]
688    fn with_defaults_error_style_is_red() {
689        let sheet = StyleSheet::with_defaults();
690        let error = sheet.get("error").unwrap();
691        if let Some(fg) = error.fg {
692            // Red component should be high
693            assert!(fg.r() > 200, "error style should have red foreground");
694        }
695    }
696
697    #[test]
698    fn with_defaults_link_style_is_underlined() {
699        let sheet = StyleSheet::with_defaults();
700        let link = sheet.get("link").unwrap();
701        assert!(
702            link.has_attr(StyleFlags::UNDERLINE),
703            "link style should be underlined"
704        );
705    }
706
707    #[test]
708    fn with_defaults_muted_style_is_dim() {
709        let sheet = StyleSheet::with_defaults();
710        let muted = sheet.get("muted").unwrap();
711        assert!(muted.has_attr(StyleFlags::DIM), "muted style should be dim");
712    }
713
714    #[test]
715    fn with_defaults_highlight_has_background() {
716        let sheet = StyleSheet::with_defaults();
717        let highlight = sheet.get("highlight").unwrap();
718        assert!(
719            highlight.bg.is_some(),
720            "highlight style should have background"
721        );
722    }
723
724    #[test]
725    fn compose_three_styles_in_order() {
726        let sheet = StyleSheet::new();
727        sheet.define("base", Style::new().fg(PackedRgba::WHITE));
728        sheet.define("bold", Style::new().bold());
729        sheet.define("red", Style::new().fg(PackedRgba::rgb(255, 0, 0)));
730
731        // red should override base's fg, bold should add attribute
732        let composed = sheet.compose(&["base", "bold", "red"]);
733
734        assert_eq!(composed.fg, Some(PackedRgba::rgb(255, 0, 0)));
735        assert!(composed.has_attr(StyleFlags::BOLD));
736    }
737
738    #[test]
739    fn compose_layered_precedence_preserves_unset_fields() {
740        let sheet = StyleSheet::new();
741        let base_bg = PackedRgba::rgb(10, 10, 10);
742        let theme_fg = PackedRgba::rgb(200, 50, 50);
743
744        sheet.define("base", Style::new().fg(PackedRgba::WHITE).bg(base_bg));
745        sheet.define("theme", Style::new().fg(theme_fg));
746        sheet.define("widget", Style::new().underline());
747
748        let composed = sheet.compose(&["base", "theme", "widget"]);
749
750        // Later theme overrides base fg, base bg remains, widget adds attrs.
751        assert_eq!(composed.fg, Some(theme_fg));
752        assert_eq!(composed.bg, Some(base_bg));
753        assert!(composed.has_attr(StyleFlags::UNDERLINE));
754    }
755
756    #[test]
757    fn get_or_default_returns_defined_style() {
758        let sheet = StyleSheet::new();
759        let style = Style::new().bold();
760        sheet.define("test", style);
761
762        let retrieved = sheet.get_or_default("test");
763        assert!(retrieved.has_attr(StyleFlags::BOLD));
764    }
765
766    #[test]
767    fn names_returns_empty_for_empty_sheet() {
768        let sheet = StyleSheet::new();
769        let names = sheet.names();
770        assert!(names.is_empty());
771    }
772
773    #[test]
774    fn style_id_hash_consistency() {
775        use std::collections::HashSet;
776
777        let id1 = StyleId::new("test");
778        let id2 = StyleId::new("test");
779        let id3 = StyleId::new("other");
780
781        let mut set = HashSet::new();
782        set.insert(id1.clone());
783
784        assert!(set.contains(&id2));
785        assert!(!set.contains(&id3));
786    }
787}