1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13use std::sync::{OnceLock, RwLock};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct Color {
21 pub r: u8,
22 pub g: u8,
23 pub b: u8,
24 pub a: u8,
25}
26
27impl Color {
28 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
29 Self { r, g, b, a: 255 }
30 }
31
32 pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
33 Self { r, g, b, a }
34 }
35
36 pub fn from_hex(s: &str) -> Option<Self> {
38 let s = s.strip_prefix('#')?;
39 if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
46 return None;
47 }
48 let (r, g, b, a) = match s.len() {
49 6 => (
50 u8::from_str_radix(&s[0..2], 16).ok()?,
51 u8::from_str_radix(&s[2..4], 16).ok()?,
52 u8::from_str_radix(&s[4..6], 16).ok()?,
53 255,
54 ),
55 8 => (
56 u8::from_str_radix(&s[0..2], 16).ok()?,
57 u8::from_str_radix(&s[2..4], 16).ok()?,
58 u8::from_str_radix(&s[4..6], 16).ok()?,
59 u8::from_str_radix(&s[6..8], 16).ok()?,
60 ),
61 _ => return None,
62 };
63 Some(Self { r, g, b, a })
64 }
65
66 pub fn try_from_hex_over(s: &str, bg: Self) -> Option<Self> {
82 let stripped = s.strip_prefix('#')?;
83 if stripped.len() != 8 {
84 return Self::from_hex(s);
85 }
86 if !stripped.bytes().all(|b| b.is_ascii_hexdigit()) {
89 return None;
90 }
91 let r = u8::from_str_radix(&stripped[0..2], 16).ok()?;
92 let g = u8::from_str_radix(&stripped[2..4], 16).ok()?;
93 let b = u8::from_str_radix(&stripped[4..6], 16).ok()?;
94 let a = u8::from_str_radix(&stripped[6..8], 16).ok()?;
95 let alpha = a as f64 / 255.0;
96 let blend = |fg: u8, bg: u8| -> u8 {
97 (fg as f64 * alpha + bg as f64 * (1.0 - alpha)).round() as u8
98 };
99 Some(Self {
100 r: blend(r, bg.r),
101 g: blend(g, bg.g),
102 b: blend(b, bg.b),
103 a: 255,
104 })
105 }
106
107 pub fn lighten(self, amount: f64) -> Self {
111 let f = amount.clamp(0.0, 1.0);
112 Self {
113 r: (self.r as f64 + (255.0 - self.r as f64) * f) as u8,
114 g: (self.g as f64 + (255.0 - self.g as f64) * f) as u8,
115 b: (self.b as f64 + (255.0 - self.b as f64) * f) as u8,
116 a: self.a,
117 }
118 }
119
120 pub fn darken(self, amount: f64) -> Self {
123 let f = amount.clamp(0.0, 1.0);
124 Self {
125 r: (self.r as f64 * (1.0 - f)) as u8,
126 g: (self.g as f64 * (1.0 - f)) as u8,
127 b: (self.b as f64 * (1.0 - f)) as u8,
128 a: self.a,
129 }
130 }
131
132 pub fn with_alpha(self, alpha: f64) -> Self {
136 Self {
137 a: (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
138 ..self
139 }
140 }
141
142 pub fn blend(self, over: Self, alpha: f64) -> Self {
151 let alpha = alpha.clamp(0.0, 1.0);
152 let mix =
153 |b: u8, o: u8| -> u8 { (b as f64 * (1.0 - alpha) + o as f64 * alpha).round() as u8 };
154 Self::rgb(
155 mix(self.r, over.r),
156 mix(self.g, over.g),
157 mix(self.b, over.b),
158 )
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct StyledSpan {
165 pub text: String,
166 pub fg: Option<Color>,
167 pub bg: Option<Color>,
168 #[serde(default)]
169 pub bold: bool,
170 #[serde(default)]
171 pub italic: bool,
172 #[serde(default)]
173 pub underline: bool,
174}
175
176impl StyledSpan {
177 pub fn plain(text: impl Into<String>) -> Self {
178 Self {
179 text: text.into(),
180 fg: None,
181 bg: None,
182 bold: false,
183 italic: false,
184 underline: false,
185 }
186 }
187
188 pub fn with_fg(text: impl Into<String>, fg: Color) -> Self {
189 Self {
190 text: text.into(),
191 fg: Some(fg),
192 bg: None,
193 bold: false,
194 italic: false,
195 underline: false,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub struct StyledText {
203 pub spans: Vec<StyledSpan>,
204}
205
206impl StyledText {
207 pub fn plain(text: impl Into<String>) -> Self {
208 Self {
209 spans: vec![StyledSpan::plain(text)],
210 }
211 }
212
213 pub fn colored(text: impl Into<String>, fg: Color) -> Self {
214 Self {
215 spans: vec![StyledSpan::with_fg(text, fg)],
216 }
217 }
218
219 pub fn visible_width(&self) -> usize {
225 self.spans
226 .iter()
227 .map(|s| crate::text_util::display_width(&s.text))
228 .sum()
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Icon {
236 pub glyph: String,
238 pub fallback: String,
240 #[serde(default)]
251 pub color: Option<Color>,
252}
253
254impl Icon {
255 pub fn new(glyph: impl Into<String>, fallback: impl Into<String>) -> Self {
256 Self {
257 glyph: glyph.into(),
258 fallback: fallback.into(),
259 color: None,
260 }
261 }
262
263 pub fn with_color(mut self, color: Color) -> Self {
266 self.color = Some(color);
267 self
268 }
269
270 #[must_use]
276 pub fn resolve(&self, nerd_fonts_enabled: bool) -> &str {
277 if nerd_fonts_enabled {
278 &self.glyph
279 } else {
280 &self.fallback
281 }
282 }
283}
284
285impl From<&str> for Icon {
288 fn from(s: &str) -> Self {
289 Self::new(s, s)
290 }
291}
292
293impl From<String> for Icon {
295 fn from(s: String) -> Self {
296 Self::new(s.clone(), s)
297 }
298}
299
300struct WidgetIdTable {
310 strings: Vec<&'static str>,
311 index: HashMap<&'static str, u32>,
312}
313
314impl WidgetIdTable {
315 fn new() -> Self {
316 Self {
317 strings: Vec::new(),
318 index: HashMap::new(),
319 }
320 }
321}
322
323impl Default for WidgetIdTable {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329fn widget_id_table() -> &'static RwLock<WidgetIdTable> {
330 static TABLE: OnceLock<RwLock<WidgetIdTable>> = OnceLock::new();
331 TABLE.get_or_init(|| RwLock::new(WidgetIdTable::new()))
332}
333
334fn intern_widget_id(label: impl Into<String>) -> u32 {
335 let label = label.into();
336 {
337 let table = widget_id_table()
338 .read()
339 .unwrap_or_else(|poisoned| poisoned.into_inner());
340 if let Some(&idx) = table.index.get(label.as_str()) {
341 return idx;
342 }
343 }
344 let mut table = widget_id_table()
345 .write()
346 .unwrap_or_else(|poisoned| poisoned.into_inner());
347 if let Some(&idx) = table.index.get(label.as_str()) {
350 return idx;
351 }
352 let leaked: &'static str = Box::leak(label.into_boxed_str());
353 let idx = table.strings.len() as u32;
354 table.strings.push(leaked);
355 table.index.insert(leaked, idx);
356 idx
357}
358
359fn resolve_widget_id(idx: u32) -> &'static str {
360 widget_id_table()
361 .read()
362 .unwrap_or_else(|poisoned| poisoned.into_inner())
363 .strings[idx as usize]
364}
365
366#[derive(Clone, Copy, PartialEq, Eq, Hash)]
379pub struct WidgetId(u32);
380
381impl WidgetId {
382 pub fn new(id: impl Into<String>) -> Self {
383 Self(intern_widget_id(id.into()))
384 }
385
386 pub fn as_str(&self) -> &'static str {
388 resolve_widget_id(self.0)
389 }
390}
391
392impl fmt::Debug for WidgetId {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 f.debug_tuple("WidgetId").field(&self.as_str()).finish()
395 }
396}
397
398impl fmt::Display for WidgetId {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 f.write_str(self.as_str())
401 }
402}
403
404impl Serialize for WidgetId {
405 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
406 where
407 S: serde::Serializer,
408 {
409 serializer.serialize_str(self.as_str())
410 }
411}
412
413impl<'de> Deserialize<'de> for WidgetId {
414 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
415 where
416 D: serde::Deserializer<'de>,
417 {
418 String::deserialize(deserializer).map(WidgetId::new)
419 }
420}
421
422impl From<&str> for WidgetId {
423 fn from(s: &str) -> Self {
424 Self::new(s)
425 }
426}
427
428impl From<String> for WidgetId {
429 fn from(s: String) -> Self {
430 Self::new(s)
431 }
432}
433
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
436pub struct Modifiers {
437 #[serde(default)]
438 pub shift: bool,
439 #[serde(default)]
440 pub ctrl: bool,
441 #[serde(default)]
442 pub alt: bool,
443 #[serde(default)]
444 pub cmd: bool,
445}
446
447pub type TreePath = Vec<u16>;
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
457pub enum SelectionMode {
458 None,
460 #[default]
462 Single,
463 Multi,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum SelectionGesture {
478 Replace,
480 Toggle,
483 Range,
486}
487
488impl SelectionGesture {
489 pub fn from_modifiers(modifiers: Modifiers) -> Self {
500 if modifiers.shift {
501 SelectionGesture::Range
502 } else if modifiers.ctrl || modifiers.cmd {
503 SelectionGesture::Toggle
504 } else {
505 SelectionGesture::Replace
506 }
507 }
508}
509
510pub fn apply_selection_gesture(
519 current: &[usize],
520 anchor: usize,
521 index: usize,
522 gesture: SelectionGesture,
523) -> Vec<usize> {
524 let mut next: Vec<usize> = match gesture {
525 SelectionGesture::Replace => vec![index],
526 SelectionGesture::Toggle => {
527 let mut set = current.to_vec();
528 if let Some(pos) = set.iter().position(|&i| i == index) {
529 set.remove(pos);
530 } else {
531 set.push(index);
532 }
533 set
534 }
535 SelectionGesture::Range => {
536 let (lo, hi) = if anchor <= index {
537 (anchor, index)
538 } else {
539 (index, anchor)
540 };
541 (lo..=hi).collect()
542 }
543 };
544 next.sort_unstable();
545 next.dedup();
546 next
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
557pub enum Decoration {
558 #[default]
560 Normal,
561 Header,
567 Muted,
569 Error,
571 Warning,
573 Modified,
575}
576
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct Badge {
582 pub text: String,
583 pub fg: Option<Color>,
584 pub bg: Option<Color>,
585}
586
587impl Badge {
588 pub fn plain(text: impl Into<String>) -> Self {
589 Self {
590 text: text.into(),
591 fg: None,
592 bg: None,
593 }
594 }
595
596 pub fn colored(text: impl Into<String>, fg: Color) -> Self {
597 Self {
598 text: text.into(),
599 fg: Some(fg),
600 bg: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct TreeStyle {
608 pub indent: u16,
610 pub show_chevrons: bool,
612 pub chevron_expanded: String,
619 pub chevron_collapsed: String,
626 #[serde(default = "TreeStyle::default_row_height")]
644 pub row_height: Option<u16>,
645}
646
647impl TreeStyle {
648 fn default_row_height() -> Option<u16> {
649 Some(22)
650 }
651}
652
653impl Default for TreeStyle {
654 fn default() -> Self {
655 Self {
656 indent: 2,
657 show_chevrons: true,
658 chevron_expanded: "▾".into(),
659 chevron_collapsed: "▸".into(),
660 row_height: Self::default_row_height(),
661 }
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn with_alpha_scales_a_leaves_rgb_untouched() {
671 let c = Color::rgb(10, 20, 30).with_alpha(0.5);
672 assert_eq!((c.r, c.g, c.b, c.a), (10, 20, 30, 128));
673 }
674
675 #[test]
676 fn with_alpha_clamps_out_of_range_input() {
677 assert_eq!(Color::rgb(1, 2, 3).with_alpha(-1.0).a, 0);
678 assert_eq!(Color::rgb(1, 2, 3).with_alpha(2.0).a, 255);
679 }
680
681 #[test]
682 fn blend_zero_alpha_is_base_one_alpha_is_over() {
683 let base = Color::rgb(0, 0, 0);
684 let over = Color::rgb(200, 100, 50);
685 assert_eq!(base.blend(over, 0.0), Color::rgb(0, 0, 0));
686 assert_eq!(base.blend(over, 1.0), Color::rgb(200, 100, 50));
687 }
688
689 #[test]
690 fn blend_midpoint_averages_channels_and_is_opaque() {
691 let base = Color::rgba(0, 0, 0, 10);
692 let over = Color::rgba(200, 100, 50, 20);
693 let mixed = base.blend(over, 0.5);
694 assert_eq!((mixed.r, mixed.g, mixed.b, mixed.a), (100, 50, 25, 255));
695 }
696
697 #[test]
698 fn from_hex_parses_rgb() {
699 assert_eq!(
700 Color::from_hex("#ff0080"),
701 Some(Color::rgb(0xff, 0x00, 0x80))
702 );
703 }
704
705 #[test]
706 fn from_hex_parses_rgba() {
707 assert_eq!(
708 Color::from_hex("#ff008040"),
709 Some(Color::rgba(0xff, 0x00, 0x80, 0x40))
710 );
711 }
712
713 #[test]
714 fn from_hex_rejects_missing_prefix() {
715 assert_eq!(Color::from_hex("ff0080"), None);
716 }
717
718 #[test]
719 fn from_hex_rejects_wrong_length() {
720 assert_eq!(Color::from_hex("#ff008"), None);
721 }
722
723 #[test]
724 fn from_hex_rejects_non_hex_ascii() {
725 assert_eq!(Color::from_hex("#zzzzzz"), None);
726 }
727
728 #[test]
732 fn from_hex_multibyte_char_returns_none_instead_of_panicking() {
733 assert_eq!(Color::from_hex("#\u{20ac}abc"), None);
736 }
737
738 #[test]
739 fn visible_width_ascii_matches_char_count() {
740 assert_eq!(StyledText::plain("hello").visible_width(), 5);
741 assert_eq!(StyledText::plain("").visible_width(), 0);
742 }
743
744 #[test]
747 fn visible_width_cjk_counts_double_width() {
748 let text = StyledText::plain("日本語");
749 assert_eq!(text.visible_width(), 6);
750 assert_ne!(text.visible_width(), text.spans[0].text.chars().count());
751 }
752
753 #[test]
754 fn visible_width_sums_across_spans() {
755 let text = StyledText {
756 spans: vec![
757 StyledSpan::plain("中"),
758 StyledSpan::plain("a"),
759 StyledSpan::plain("文"),
760 ],
761 };
762 assert_eq!(text.visible_width(), 5);
764 }
765
766 #[test]
767 fn visible_width_combining_mark_counts_zero() {
768 let text = StyledText::plain("e\u{0301}");
771 assert_eq!(text.visible_width(), 1);
772 }
773
774 #[test]
775 fn color_from_hex_rgb() {
776 let c = Color::from_hex("#1a2b3c").unwrap();
777 assert_eq!(c, Color::rgb(0x1a, 0x2b, 0x3c));
778 }
779
780 #[test]
781 fn color_from_hex_rgba() {
782 let c = Color::from_hex("#1a2b3c80").unwrap();
783 assert_eq!(c, Color::rgba(0x1a, 0x2b, 0x3c, 0x80));
784 }
785
786 #[test]
787 fn color_from_hex_invalid() {
788 assert!(Color::from_hex("not a hex").is_none());
789 assert!(Color::from_hex("#xyz").is_none());
790 assert!(Color::from_hex("#12345").is_none()); }
792
793 #[test]
794 fn try_from_hex_over_blends_alpha_against_background() {
795 let bg = Color::rgb(0, 0, 0);
796 let c = Color::try_from_hex_over("#ff008040", bg).unwrap();
798 let alpha = 0x40 as f64 / 255.0;
800 let blend = |fg: u8| -> u8 { (fg as f64 * alpha).round() as u8 };
801 assert_eq!(c, Color::rgba(blend(0xff), blend(0x00), blend(0x80), 255));
802 }
803
804 #[test]
805 fn try_from_hex_over_full_alpha_ignores_background() {
806 let bg = Color::rgb(10, 20, 30);
807 let c = Color::try_from_hex_over("#ff0080ff", bg).unwrap();
808 assert_eq!(c, Color::rgba(0xff, 0x00, 0x80, 255));
809 }
810
811 #[test]
812 fn try_from_hex_over_zero_alpha_equals_background() {
813 let bg = Color::rgb(10, 20, 30);
814 let c = Color::try_from_hex_over("#ff008000", bg).unwrap();
815 assert_eq!(c, Color::rgba(10, 20, 30, 255));
816 }
817
818 #[test]
819 fn try_from_hex_over_falls_back_to_from_hex_without_alpha() {
820 let bg = Color::rgb(10, 20, 30);
821 assert_eq!(
822 Color::try_from_hex_over("#1a2b3c", bg),
823 Color::from_hex("#1a2b3c")
824 );
825 }
826
827 #[test]
828 fn try_from_hex_over_rejects_malformed_input() {
829 let bg = Color::rgb(0, 0, 0);
830 assert_eq!(Color::try_from_hex_over("not a hex", bg), None);
831 assert_eq!(Color::try_from_hex_over("#zzzzzzzz", bg), None);
832 assert_eq!(Color::try_from_hex_over("#12345", bg), None);
833 }
834
835 #[test]
836 fn widget_id_roundtrip() {
837 let id = WidgetId::new("sc-tree");
838 assert_eq!(id.as_str(), "sc-tree");
839 let id2: WidgetId = "sc-tree".into();
840 assert_eq!(id, id2);
841 }
842
843 #[test]
844 fn widget_id_is_copy_not_just_clone() {
845 let id = WidgetId::new("copy-check");
849 assert_eq!(id.as_str(), "copy-check");
850 assert_eq!(id.as_str(), "copy-check");
851 }
852
853 #[test]
854 fn widget_id_serializes_as_plain_string() {
855 let id = WidgetId::new("wire-format-check");
856 let json = serde_json::to_string(&id).unwrap();
857 assert_eq!(json, "\"wire-format-check\"");
858 let back: WidgetId = serde_json::from_str(&json).unwrap();
859 assert_eq!(back, id);
860 }
861
862 #[test]
863 fn widget_id_interns_equal_labels_to_equal_handles() {
864 let a = WidgetId::new("shared-label");
868 let b = WidgetId::new("shared-label".to_string());
869 assert_eq!(a, b);
870 assert_eq!(a.as_str(), b.as_str());
871 }
872
873 #[test]
874 fn styled_text_visible_width() {
875 let text = StyledText {
876 spans: vec![
877 StyledSpan::plain("hello "),
878 StyledSpan::with_fg("world", Color::rgb(255, 0, 0)),
879 ],
880 };
881 assert_eq!(text.visible_width(), 11);
882 }
883
884 #[test]
885 fn selection_gesture_from_modifiers_prefers_shift_over_ctrl() {
886 let mut m = Modifiers::default();
887 assert_eq!(
888 SelectionGesture::from_modifiers(m),
889 SelectionGesture::Replace
890 );
891 m.ctrl = true;
892 assert_eq!(
893 SelectionGesture::from_modifiers(m),
894 SelectionGesture::Toggle
895 );
896 m.shift = true;
897 assert_eq!(SelectionGesture::from_modifiers(m), SelectionGesture::Range);
898 let cmd_only = Modifiers {
899 cmd: true,
900 ..Default::default()
901 };
902 assert_eq!(
903 SelectionGesture::from_modifiers(cmd_only),
904 SelectionGesture::Toggle
905 );
906 }
907
908 #[test]
909 fn apply_selection_gesture_replace_ignores_current_selection() {
910 let next = apply_selection_gesture(&[1, 2, 3], 1, 5, SelectionGesture::Replace);
911 assert_eq!(next, vec![5]);
912 }
913
914 #[test]
915 fn apply_selection_gesture_toggle_adds_and_removes() {
916 let added = apply_selection_gesture(&[1, 3], 1, 2, SelectionGesture::Toggle);
917 assert_eq!(added, vec![1, 2, 3]);
918
919 let removed = apply_selection_gesture(&[1, 2, 3], 1, 2, SelectionGesture::Toggle);
920 assert_eq!(removed, vec![1, 3]);
921 }
922
923 #[test]
924 fn apply_selection_gesture_range_spans_anchor_to_index_inclusive() {
925 let forward = apply_selection_gesture(&[], 2, 5, SelectionGesture::Range);
926 assert_eq!(forward, vec![2, 3, 4, 5]);
927
928 let backward = apply_selection_gesture(&[9], 5, 2, SelectionGesture::Range);
929 assert_eq!(backward, vec![2, 3, 4, 5]);
930 }
931
932 #[test]
933 fn apply_selection_gesture_result_is_sorted_and_deduped() {
934 let next = apply_selection_gesture(&[5, 5, 1], 1, 1, SelectionGesture::Toggle);
935 assert_eq!(next, vec![5]);
938 }
939}