1#![forbid(unsafe_code)]
2
3use crate::style::Style;
28use ahash::AHashMap;
29use ftui_render::cell::PackedRgba;
30use std::sync::RwLock;
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct StyleId(pub String);
39
40impl StyleId {
41 #[inline]
43 pub fn new(name: impl Into<String>) -> Self {
44 Self(name.into())
45 }
46
47 #[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#[derive(Debug, Default)]
83pub struct StyleSheet {
84 styles: RwLock<AHashMap<String, Style>>,
85}
86
87impl StyleSheet {
88 #[inline]
90 pub fn new() -> Self {
91 Self {
92 styles: RwLock::new(AHashMap::new()),
93 }
94 }
95
96 #[must_use]
107 pub fn with_defaults() -> Self {
108 let sheet = Self::new();
109
110 sheet.define(
112 "error",
113 Style::new().fg(PackedRgba::rgb(255, 85, 85)).bold(),
114 );
115
116 sheet.define("warning", Style::new().fg(PackedRgba::rgb(255, 170, 0)));
118
119 sheet.define("info", Style::new().fg(PackedRgba::rgb(85, 170, 255)));
121
122 sheet.define("success", Style::new().fg(PackedRgba::rgb(85, 255, 85)));
124
125 sheet.define(
127 "muted",
128 Style::new().fg(PackedRgba::rgb(128, 128, 128)).dim(),
129 );
130
131 sheet.define(
133 "highlight",
134 Style::new()
135 .bg(PackedRgba::rgb(255, 255, 0))
136 .fg(PackedRgba::rgb(0, 0, 0)),
137 );
138
139 sheet.define(
141 "link",
142 Style::new().fg(PackedRgba::rgb(85, 170, 255)).underline(),
143 );
144
145 sheet
146 }
147
148 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 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 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 pub fn get_or_default(&self, name: &str) -> Style {
175 self.get(name).unwrap_or_default()
176 }
177
178 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 pub fn len(&self) -> usize {
186 let styles = self.styles.read().expect("StyleSheet lock poisoned");
187 styles.len()
188 }
189
190 pub fn is_empty(&self) -> bool {
192 self.len() == 0
193 }
194
195 pub fn names(&self) -> Vec<String> {
197 let styles = self.styles.read().expect("StyleSheet lock poisoned");
198 styles.keys().cloned().collect()
199 }
200
201 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 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 let style = styles.get(*name)?;
242 result = style.merge(&result);
243 }
244
245 Some(result)
246 }
247
248 pub fn extend(&self, other: &StyleSheet) {
252 if std::ptr::eq(self, other) {
253 return;
254 }
255 let other_styles = {
256 let other_styles = other.styles.read().expect("StyleSheet lock poisoned");
257 other_styles.clone()
258 };
259 let mut self_styles = self.styles.write().expect("StyleSheet lock poisoned");
260 self_styles.extend(other_styles);
261 }
262
263 pub fn clear(&self) {
265 let mut styles = self.styles.write().expect("StyleSheet lock poisoned");
266 styles.clear();
267 }
268}
269
270impl Clone for StyleSheet {
271 fn clone(&self) -> Self {
272 let styles = self.styles.read().expect("StyleSheet lock poisoned");
273 Self {
274 styles: RwLock::new(styles.clone()),
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::style::StyleFlags;
283
284 #[test]
285 fn new_stylesheet_is_empty() {
286 let sheet = StyleSheet::new();
287 assert!(sheet.is_empty());
288 assert_eq!(sheet.len(), 0);
289 }
290
291 #[test]
292 fn define_and_get_style() {
293 let sheet = StyleSheet::new();
294 let style = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
295
296 sheet.define("error", style);
297
298 assert!(!sheet.is_empty());
299 assert_eq!(sheet.len(), 1);
300 assert!(sheet.contains("error"));
301
302 let retrieved = sheet.get("error").unwrap();
303 assert_eq!(retrieved, style);
304 }
305
306 #[test]
307 fn get_nonexistent_returns_none() {
308 let sheet = StyleSheet::new();
309 assert!(sheet.get("nonexistent").is_none());
310 }
311
312 #[test]
313 fn get_or_default_returns_default_for_missing() {
314 let sheet = StyleSheet::new();
315 let style = sheet.get_or_default("missing");
316 assert!(style.is_empty());
317 }
318
319 #[test]
320 fn define_replaces_existing() {
321 let sheet = StyleSheet::new();
322
323 sheet.define("test", Style::new().bold());
324 assert!(sheet.get("test").unwrap().has_attr(StyleFlags::BOLD));
325
326 sheet.define("test", Style::new().italic());
327 let style = sheet.get("test").unwrap();
328 assert!(!style.has_attr(StyleFlags::BOLD));
329 assert!(style.has_attr(StyleFlags::ITALIC));
330 }
331
332 #[test]
333 fn remove_style() {
334 let sheet = StyleSheet::new();
335 sheet.define("test", Style::new().bold());
336
337 let removed = sheet.remove("test");
338 assert!(removed.is_some());
339 assert!(!sheet.contains("test"));
340
341 let removed_again = sheet.remove("test");
342 assert!(removed_again.is_none());
343 }
344
345 #[test]
346 fn names_returns_all_style_names() {
347 let sheet = StyleSheet::new();
348 sheet.define("a", Style::new());
349 sheet.define("b", Style::new());
350 sheet.define("c", Style::new());
351
352 let names = sheet.names();
353 assert_eq!(names.len(), 3);
354 assert!(names.contains(&"a".to_string()));
355 assert!(names.contains(&"b".to_string()));
356 assert!(names.contains(&"c".to_string()));
357 }
358
359 #[test]
360 fn compose_merges_styles() {
361 let sheet = StyleSheet::new();
362 sheet.define("base", Style::new().fg(PackedRgba::WHITE));
363 sheet.define("bold", Style::new().bold());
364
365 let composed = sheet.compose(&["base", "bold"]);
366
367 assert_eq!(composed.fg, Some(PackedRgba::WHITE));
368 assert!(composed.has_attr(StyleFlags::BOLD));
369 }
370
371 #[test]
372 fn compose_later_wins_on_conflict() {
373 let sheet = StyleSheet::new();
374 let red = PackedRgba::rgb(255, 0, 0);
375 let blue = PackedRgba::rgb(0, 0, 255);
376
377 sheet.define("red", Style::new().fg(red));
378 sheet.define("blue", Style::new().fg(blue));
379
380 let composed = sheet.compose(&["red", "blue"]);
381 assert_eq!(composed.fg, Some(blue));
382 }
383
384 #[test]
385 fn compose_ignores_missing() {
386 let sheet = StyleSheet::new();
387 sheet.define("exists", Style::new().bold());
388
389 let composed = sheet.compose(&["missing", "exists"]);
390 assert!(composed.has_attr(StyleFlags::BOLD));
391 }
392
393 #[test]
394 fn compose_strict_fails_on_missing() {
395 let sheet = StyleSheet::new();
396 sheet.define("exists", Style::new().bold());
397
398 let result = sheet.compose_strict(&["exists", "missing"]);
399 assert!(result.is_none());
400 }
401
402 #[test]
403 fn compose_strict_succeeds_when_all_present() {
404 let sheet = StyleSheet::new();
405 sheet.define("a", Style::new().bold());
406 sheet.define("b", Style::new().italic());
407
408 let result = sheet.compose_strict(&["a", "b"]);
409 assert!(result.is_some());
410
411 let style = result.unwrap();
412 assert!(style.has_attr(StyleFlags::BOLD));
413 assert!(style.has_attr(StyleFlags::ITALIC));
414 }
415
416 #[test]
417 fn with_defaults_has_semantic_styles() {
418 let sheet = StyleSheet::with_defaults();
419
420 assert!(sheet.contains("error"));
421 assert!(sheet.contains("warning"));
422 assert!(sheet.contains("info"));
423 assert!(sheet.contains("success"));
424 assert!(sheet.contains("muted"));
425 assert!(sheet.contains("highlight"));
426 assert!(sheet.contains("link"));
427
428 let error = sheet.get("error").unwrap();
430 assert!(error.has_attr(StyleFlags::BOLD));
431 assert!(error.fg.is_some());
432 }
433
434 #[test]
435 fn extend_merges_stylesheets() {
436 let sheet1 = StyleSheet::new();
437 sheet1.define("a", Style::new().bold());
438
439 let sheet2 = StyleSheet::new();
440 sheet2.define("b", Style::new().italic());
441
442 sheet1.extend(&sheet2);
443
444 assert!(sheet1.contains("a"));
445 assert!(sheet1.contains("b"));
446 }
447
448 #[test]
449 fn extend_overrides_existing() {
450 let sheet1 = StyleSheet::new();
451 sheet1.define("test", Style::new().bold());
452
453 let sheet2 = StyleSheet::new();
454 sheet2.define("test", Style::new().italic());
455
456 sheet1.extend(&sheet2);
457
458 let style = sheet1.get("test").unwrap();
459 assert!(!style.has_attr(StyleFlags::BOLD));
460 assert!(style.has_attr(StyleFlags::ITALIC));
461 }
462
463 #[test]
464 fn concurrent_bidirectional_extend_completes() {
465 use std::sync::{Arc, Barrier, mpsc};
466 use std::time::Duration;
467
468 let sheet1 = Arc::new(StyleSheet::new());
469 sheet1.define("a", Style::new().bold());
470
471 let sheet2 = Arc::new(StyleSheet::new());
472 sheet2.define("b", Style::new().italic());
473
474 let barrier = Arc::new(Barrier::new(3));
475 let (done_tx, done_rx) = mpsc::channel();
476
477 let sheet1_to_sheet2 = {
478 let barrier = Arc::clone(&barrier);
479 let done_tx = done_tx.clone();
480 let sheet1 = Arc::clone(&sheet1);
481 let sheet2 = Arc::clone(&sheet2);
482 std::thread::spawn(move || {
483 barrier.wait();
484 sheet1.extend(&sheet2);
485 done_tx.send(()).expect("completion signal");
486 })
487 };
488
489 let sheet2_to_sheet1 = {
490 let barrier = Arc::clone(&barrier);
491 let done_tx = done_tx.clone();
492 let sheet1 = Arc::clone(&sheet1);
493 let sheet2 = Arc::clone(&sheet2);
494 std::thread::spawn(move || {
495 barrier.wait();
496 sheet2.extend(&sheet1);
497 done_tx.send(()).expect("completion signal");
498 })
499 };
500
501 barrier.wait();
502
503 for _ in 0..2 {
504 done_rx
505 .recv_timeout(Duration::from_secs(1))
506 .expect("cross-extend should complete without deadlocking");
507 }
508
509 sheet1_to_sheet2.join().expect("sheet1 extend thread");
510 sheet2_to_sheet1.join().expect("sheet2 extend thread");
511
512 assert!(sheet1.contains("b"));
513 assert!(sheet2.contains("a"));
514 }
515
516 #[test]
517 fn clear_removes_all_styles() {
518 let sheet = StyleSheet::with_defaults();
519 assert!(!sheet.is_empty());
520
521 sheet.clear();
522 assert!(sheet.is_empty());
523 }
524
525 #[test]
526 fn clone_creates_independent_copy() {
527 let sheet1 = StyleSheet::new();
528 sheet1.define("test", Style::new().bold());
529
530 let sheet2 = sheet1.clone();
531 sheet1.define("other", Style::new());
532
533 assert!(sheet1.contains("other"));
534 assert!(!sheet2.contains("other"));
535 }
536
537 #[test]
538 fn style_id_from_str() {
539 let id: StyleId = "error".into();
540 assert_eq!(id.as_str(), "error");
541 }
542
543 #[test]
544 fn style_id_from_string() {
545 let id: StyleId = String::from("error").into();
546 assert_eq!(id.as_str(), "error");
547 }
548
549 #[test]
550 fn style_id_equality() {
551 let id1 = StyleId::new("error");
552 let id2 = StyleId::new("error");
553 let id3 = StyleId::new("warning");
554
555 assert_eq!(id1, id2);
556 assert_ne!(id1, id3);
557 }
558
559 #[test]
560 fn stylesheet_thread_safe_reads() {
561 use std::sync::Arc;
562 use std::thread;
563
564 let sheet = Arc::new(StyleSheet::new());
565 sheet.define("test", Style::new().bold());
566
567 let handles: Vec<_> = (0..4)
568 .map(|_| {
569 let sheet = Arc::clone(&sheet);
570 thread::spawn(move || {
571 for _ in 0..100 {
572 let _ = sheet.get("test");
573 }
574 })
575 })
576 .collect();
577
578 for handle in handles {
579 handle.join().unwrap();
580 }
581 }
582
583 #[test]
584 fn stylesheet_thread_safe_writes() {
585 use std::sync::Arc;
586 use std::thread;
587
588 let sheet = Arc::new(StyleSheet::new());
589
590 let handles: Vec<_> = (0..4)
591 .map(|i| {
592 let sheet = Arc::clone(&sheet);
593 thread::spawn(move || {
594 for j in 0..25 {
595 let name = format!("style_{}_{}", i, j);
596 sheet.define(&name, Style::new().bold());
597 }
598 })
599 })
600 .collect();
601
602 for handle in handles {
603 handle.join().unwrap();
604 }
605
606 assert_eq!(sheet.len(), 100);
608 }
609
610 #[test]
611 fn compose_empty_list_returns_default() {
612 let sheet = StyleSheet::new();
613 sheet.define("test", Style::new().bold());
614
615 let composed = sheet.compose(&[]);
616 assert!(composed.is_empty());
617 }
618
619 #[test]
620 fn compose_strict_empty_list_returns_some_default() {
621 let sheet = StyleSheet::new();
622 sheet.define("test", Style::new().bold());
623
624 let result = sheet.compose_strict(&[]);
625 assert!(result.is_some());
626 assert!(result.unwrap().is_empty());
627 }
628
629 #[test]
630 fn extend_self_is_noop() {
631 let sheet = StyleSheet::new();
632 sheet.define("test", Style::new().bold());
633
634 sheet.extend(&sheet);
636
637 assert_eq!(sheet.len(), 1);
638 assert!(sheet.contains("test"));
639 }
640
641 #[test]
642 fn stylesheet_default_is_empty() {
643 let sheet = StyleSheet::default();
644 assert!(sheet.is_empty());
645 }
646
647 #[test]
648 fn define_with_empty_name() {
649 let sheet = StyleSheet::new();
650 sheet.define("", Style::new().bold());
651
652 assert!(sheet.contains(""));
653 assert!(sheet.get("").unwrap().has_attr(StyleFlags::BOLD));
654 }
655
656 #[test]
657 fn style_id_as_ref_str() {
658 let id = StyleId::new("test");
659 let s: &str = id.as_ref();
660 assert_eq!(s, "test");
661 }
662
663 #[test]
664 fn style_id_clone() {
665 let id1 = StyleId::new("test");
666 let id2 = id1.clone();
667 assert_eq!(id1, id2);
668 }
669
670 #[test]
671 fn style_id_debug_impl() {
672 let id = StyleId::new("test");
673 let debug = format!("{:?}", id);
674 assert!(debug.contains("test"));
675 }
676
677 #[test]
678 fn stylesheet_debug_impl() {
679 let sheet = StyleSheet::new();
680 sheet.define("test", Style::new());
681 let debug = format!("{:?}", sheet);
682 assert!(debug.contains("StyleSheet"));
683 }
684
685 #[test]
686 fn with_defaults_error_style_is_red() {
687 let sheet = StyleSheet::with_defaults();
688 let error = sheet.get("error").unwrap();
689 if let Some(fg) = error.fg {
690 assert!(fg.r() > 200, "error style should have red foreground");
692 }
693 }
694
695 #[test]
696 fn with_defaults_link_style_is_underlined() {
697 let sheet = StyleSheet::with_defaults();
698 let link = sheet.get("link").unwrap();
699 assert!(
700 link.has_attr(StyleFlags::UNDERLINE),
701 "link style should be underlined"
702 );
703 }
704
705 #[test]
706 fn with_defaults_muted_style_is_dim() {
707 let sheet = StyleSheet::with_defaults();
708 let muted = sheet.get("muted").unwrap();
709 assert!(muted.has_attr(StyleFlags::DIM), "muted style should be dim");
710 }
711
712 #[test]
713 fn with_defaults_highlight_has_background() {
714 let sheet = StyleSheet::with_defaults();
715 let highlight = sheet.get("highlight").unwrap();
716 assert!(
717 highlight.bg.is_some(),
718 "highlight style should have background"
719 );
720 }
721
722 #[test]
723 fn compose_three_styles_in_order() {
724 let sheet = StyleSheet::new();
725 sheet.define("base", Style::new().fg(PackedRgba::WHITE));
726 sheet.define("bold", Style::new().bold());
727 sheet.define("red", Style::new().fg(PackedRgba::rgb(255, 0, 0)));
728
729 let composed = sheet.compose(&["base", "bold", "red"]);
731
732 assert_eq!(composed.fg, Some(PackedRgba::rgb(255, 0, 0)));
733 assert!(composed.has_attr(StyleFlags::BOLD));
734 }
735
736 #[test]
737 fn compose_layered_precedence_preserves_unset_fields() {
738 let sheet = StyleSheet::new();
739 let base_bg = PackedRgba::rgb(10, 10, 10);
740 let theme_fg = PackedRgba::rgb(200, 50, 50);
741
742 sheet.define("base", Style::new().fg(PackedRgba::WHITE).bg(base_bg));
743 sheet.define("theme", Style::new().fg(theme_fg));
744 sheet.define("widget", Style::new().underline());
745
746 let composed = sheet.compose(&["base", "theme", "widget"]);
747
748 assert_eq!(composed.fg, Some(theme_fg));
750 assert_eq!(composed.bg, Some(base_bg));
751 assert!(composed.has_attr(StyleFlags::UNDERLINE));
752 }
753
754 #[test]
755 fn get_or_default_returns_defined_style() {
756 let sheet = StyleSheet::new();
757 let style = Style::new().bold();
758 sheet.define("test", style);
759
760 let retrieved = sheet.get_or_default("test");
761 assert!(retrieved.has_attr(StyleFlags::BOLD));
762 }
763
764 #[test]
765 fn names_returns_empty_for_empty_sheet() {
766 let sheet = StyleSheet::new();
767 let names = sheet.names();
768 assert!(names.is_empty());
769 }
770
771 #[test]
772 fn style_id_hash_consistency() {
773 use std::collections::HashSet;
774
775 let id1 = StyleId::new("test");
776 let id2 = StyleId::new("test");
777 let id3 = StyleId::new("other");
778
779 let mut set = HashSet::new();
780 set.insert(id1.clone());
781
782 assert!(set.contains(&id2));
783 assert!(!set.contains(&id3));
784 }
785}