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 match styles.get(*name) {
242 Some(style) => result = style.merge(&result),
243 None => return None,
244 }
245 }
246
247 Some(result)
248 }
249
250 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 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 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 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 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 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 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 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}