1use ratatui::style::{Color, Style};
23use std::collections::HashMap;
24
25use crate::model::marker::{MarkerId, MarkerList};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum VirtualTextPosition {
30 BeforeChar,
33 AfterChar,
35
36 LineAbove,
41 LineBelow,
45}
46
47impl VirtualTextPosition {
48 pub fn is_line(&self) -> bool {
50 matches!(self, Self::LineAbove | Self::LineBelow)
51 }
52
53 pub fn is_inline(&self) -> bool {
55 matches!(self, Self::BeforeChar | Self::AfterChar)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62pub struct VirtualTextNamespace(pub String);
63
64impl VirtualTextNamespace {
65 pub fn from_string(s: String) -> Self {
67 Self(s)
68 }
69
70 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct VirtualText {
79 pub marker_id: MarkerId,
81 pub text: String,
83 pub style: Style,
88 pub fg_theme_key: Option<String>,
92 pub bg_theme_key: Option<String>,
94 pub position: VirtualTextPosition,
96 pub priority: i32,
98 pub string_id: Option<String>,
100 pub namespace: Option<VirtualTextNamespace>,
102 pub gutter_glyph: Option<String>,
109 pub gutter_color: Option<Color>,
112 pub text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay>,
116}
117
118impl VirtualText {
119 pub fn resolved_style(&self, theme: &crate::view::theme::Theme) -> Style {
126 let mut style = self.style;
127 if let Some(ref key) = self.fg_theme_key {
128 if let Some(color) = theme.resolve_theme_key(key) {
129 style = style.fg(color);
130 }
131 }
132 if let Some(ref key) = self.bg_theme_key {
133 if let Some(color) = theme.resolve_theme_key(key) {
134 style = style.bg(color);
135 }
136 }
137 style
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143pub struct VirtualTextId(pub u64);
144
145pub struct VirtualTextManager {
150 texts: HashMap<VirtualTextId, VirtualText>,
152 next_id: u64,
154 version: u32,
160}
161
162impl VirtualTextManager {
163 pub fn new() -> Self {
165 Self {
166 texts: HashMap::new(),
167 next_id: 0,
168 version: 0,
169 }
170 }
171
172 #[inline]
176 pub fn version(&self) -> u32 {
177 self.version
178 }
179
180 #[inline]
181 fn bump_version(&mut self) {
182 self.version = self.version.wrapping_add(1);
183 }
184
185 pub fn add(
198 &mut self,
199 marker_list: &mut MarkerList,
200 position: usize,
201 text: String,
202 style: Style,
203 vtext_position: VirtualTextPosition,
204 priority: i32,
205 ) -> VirtualTextId {
206 let marker_id = marker_list.create(position, false);
209
210 let id = VirtualTextId(self.next_id);
211 self.next_id += 1;
212
213 self.texts.insert(
214 id,
215 VirtualText {
216 marker_id,
217 text,
218 style,
219 fg_theme_key: None,
220 bg_theme_key: None,
221 position: vtext_position,
222 priority,
223 string_id: None,
224 namespace: None,
225 gutter_glyph: None,
226 gutter_color: None,
227 text_overlays: Vec::new(),
228 },
229 );
230 self.bump_version();
231
232 id
233 }
234
235 #[allow(clippy::too_many_arguments)]
243 pub fn add_with_theme_keys(
244 &mut self,
245 marker_list: &mut MarkerList,
246 position: usize,
247 text: String,
248 style: Style,
249 fg_theme_key: Option<String>,
250 bg_theme_key: Option<String>,
251 vtext_position: VirtualTextPosition,
252 priority: i32,
253 ) -> VirtualTextId {
254 debug_assert!(
255 vtext_position.is_inline(),
256 "add_with_theme_keys requires BeforeChar or AfterChar"
257 );
258
259 let marker_id = marker_list.create(position, false);
260
261 let id = VirtualTextId(self.next_id);
262 self.next_id += 1;
263
264 self.texts.insert(
265 id,
266 VirtualText {
267 marker_id,
268 text,
269 style,
270 fg_theme_key,
271 bg_theme_key,
272 position: vtext_position,
273 priority,
274 string_id: None,
275 namespace: None,
276 gutter_glyph: None,
277 gutter_color: None,
278 text_overlays: Vec::new(),
279 },
280 );
281 self.bump_version();
282
283 id
284 }
285
286 #[allow(clippy::too_many_arguments)]
290 pub fn add_with_id(
291 &mut self,
292 marker_list: &mut MarkerList,
293 position: usize,
294 text: String,
295 style: Style,
296 vtext_position: VirtualTextPosition,
297 priority: i32,
298 string_id: String,
299 ) -> VirtualTextId {
300 let marker_id = marker_list.create(position, false);
301
302 let id = VirtualTextId(self.next_id);
303 self.next_id += 1;
304
305 self.texts.insert(
306 id,
307 VirtualText {
308 marker_id,
309 text,
310 style,
311 fg_theme_key: None,
312 bg_theme_key: None,
313 position: vtext_position,
314 priority,
315 string_id: Some(string_id),
316 namespace: None,
317 gutter_glyph: None,
318 gutter_color: None,
319 text_overlays: Vec::new(),
320 },
321 );
322 self.bump_version();
323
324 id
325 }
326
327 #[allow(clippy::too_many_arguments)]
330 pub fn add_with_id_and_theme_keys(
331 &mut self,
332 marker_list: &mut MarkerList,
333 position: usize,
334 text: String,
335 style: Style,
336 fg_theme_key: Option<String>,
337 bg_theme_key: Option<String>,
338 vtext_position: VirtualTextPosition,
339 priority: i32,
340 string_id: String,
341 ) -> VirtualTextId {
342 debug_assert!(
343 vtext_position.is_inline(),
344 "add_with_id_and_theme_keys requires BeforeChar or AfterChar"
345 );
346
347 let marker_id = marker_list.create(position, false);
348
349 let id = VirtualTextId(self.next_id);
350 self.next_id += 1;
351
352 self.texts.insert(
353 id,
354 VirtualText {
355 marker_id,
356 text,
357 style,
358 fg_theme_key,
359 bg_theme_key,
360 position: vtext_position,
361 priority,
362 string_id: Some(string_id),
363 namespace: None,
364 gutter_glyph: None,
365 gutter_color: None,
366 text_overlays: Vec::new(),
367 },
368 );
369
370 id
371 }
372
373 #[allow(clippy::too_many_arguments)]
386 pub fn add_line(
387 &mut self,
388 marker_list: &mut MarkerList,
389 position: usize,
390 text: String,
391 style: Style,
392 placement: VirtualTextPosition,
393 namespace: VirtualTextNamespace,
394 priority: i32,
395 ) -> VirtualTextId {
396 self.add_line_with_theme_keys(
397 marker_list,
398 position,
399 text,
400 style,
401 None,
402 None,
403 placement,
404 namespace,
405 priority,
406 None,
407 None,
408 Vec::new(),
409 )
410 }
411
412 #[allow(clippy::too_many_arguments)]
420 pub fn add_line_with_theme_keys(
421 &mut self,
422 marker_list: &mut MarkerList,
423 position: usize,
424 text: String,
425 style: Style,
426 fg_theme_key: Option<String>,
427 bg_theme_key: Option<String>,
428 placement: VirtualTextPosition,
429 namespace: VirtualTextNamespace,
430 priority: i32,
431 gutter_glyph: Option<String>,
432 gutter_color: Option<Color>,
433 text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay>,
434 ) -> VirtualTextId {
435 debug_assert!(
436 placement.is_line(),
437 "add_line requires LineAbove or LineBelow"
438 );
439
440 let marker_id = marker_list.create(position, false);
441
442 let id = VirtualTextId(self.next_id);
443 self.next_id += 1;
444
445 self.texts.insert(
446 id,
447 VirtualText {
448 marker_id,
449 text,
450 style,
451 fg_theme_key,
452 bg_theme_key,
453 position: placement,
454 priority,
455 string_id: None,
456 namespace: Some(namespace),
457 gutter_glyph,
458 gutter_color,
459 text_overlays,
460 },
461 );
462 self.bump_version();
463
464 id
465 }
466
467 pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
469 let to_remove: Vec<VirtualTextId> = self
471 .texts
472 .iter()
473 .filter_map(|(id, vtext)| {
474 if vtext.string_id.as_deref() == Some(string_id) {
475 Some(*id)
476 } else {
477 None
478 }
479 })
480 .collect();
481
482 let mut removed = false;
483 for id in to_remove {
484 if let Some(vtext) = self.texts.remove(&id) {
485 marker_list.delete(vtext.marker_id);
486 removed = true;
487 }
488 }
489 if removed {
490 self.bump_version();
491 }
492 removed
493 }
494
495 pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
497 let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
499 .texts
500 .iter()
501 .filter_map(|(id, vtext)| {
502 if let Some(ref sid) = vtext.string_id {
503 if sid.starts_with(prefix) {
504 return Some((*id, vtext.marker_id));
505 }
506 }
507 None
508 })
509 .collect();
510
511 let removed = !markers_to_delete.is_empty();
513 for (id, marker_id) in markers_to_delete {
514 marker_list.delete(marker_id);
515 self.texts.remove(&id);
516 }
517 if removed {
518 self.bump_version();
519 }
520 }
521
522 pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
524 if let Some(vtext) = self.texts.remove(&id) {
525 marker_list.delete(vtext.marker_id);
526 self.bump_version();
527 true
528 } else {
529 false
530 }
531 }
532
533 pub fn clear(&mut self, marker_list: &mut MarkerList) {
535 let was_non_empty = !self.texts.is_empty();
536 for vtext in self.texts.values() {
537 marker_list.delete(vtext.marker_id);
538 }
539 self.texts.clear();
540 if was_non_empty {
541 self.bump_version();
542 }
543 }
544
545 pub fn remove_in_range(
557 &mut self,
558 marker_list: &mut MarkerList,
559 start: usize,
560 end: usize,
561 ) -> usize {
562 if start >= end {
563 return 0;
564 }
565
566 let to_remove: Vec<VirtualTextId> = self
567 .texts
568 .iter()
569 .filter_map(|(id, vtext)| {
570 let pos = marker_list.get_position(vtext.marker_id)?;
571 if pos >= start && pos < end {
572 Some(*id)
573 } else {
574 None
575 }
576 })
577 .collect();
578
579 let count = to_remove.len();
580 for id in to_remove {
581 if let Some(vtext) = self.texts.remove(&id) {
582 marker_list.delete(vtext.marker_id);
583 }
584 }
585 if count > 0 {
586 self.bump_version();
587 }
588 count
589 }
590
591 pub fn len(&self) -> usize {
593 self.texts.len()
594 }
595
596 pub fn is_empty(&self) -> bool {
598 self.texts.is_empty()
599 }
600
601 pub fn query_range(
612 &self,
613 marker_list: &MarkerList,
614 start: usize,
615 end: usize,
616 ) -> Vec<(usize, &VirtualText)> {
617 let mut results: Vec<(usize, &VirtualText)> = self
618 .texts
619 .values()
620 .filter_map(|vtext| {
621 let pos = marker_list.get_position(vtext.marker_id)?;
622 if pos >= start && pos < end {
623 Some((pos, vtext))
624 } else {
625 None
626 }
627 })
628 .collect();
629
630 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
632
633 results
634 }
635
636 pub fn build_lookup(
641 &self,
642 marker_list: &MarkerList,
643 start: usize,
644 end: usize,
645 ) -> HashMap<usize, Vec<&VirtualText>> {
646 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
647
648 for vtext in self.texts.values() {
649 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
650 if pos >= start && pos < end {
651 lookup.entry(pos).or_default().push(vtext);
652 }
653 }
654 }
655
656 for texts in lookup.values_mut() {
658 texts.sort_by_key(|vt| vt.priority);
659 }
660
661 lookup
662 }
663
664 pub fn clear_namespace(
668 &mut self,
669 marker_list: &mut MarkerList,
670 namespace: &VirtualTextNamespace,
671 ) {
672 let to_remove: Vec<VirtualTextId> = self
673 .texts
674 .iter()
675 .filter_map(|(id, vtext)| {
676 if vtext.namespace.as_ref() == Some(namespace) {
677 Some(*id)
678 } else {
679 None
680 }
681 })
682 .collect();
683
684 let removed = !to_remove.is_empty();
685 for id in to_remove {
686 if let Some(vtext) = self.texts.remove(&id) {
687 marker_list.delete(vtext.marker_id);
688 }
689 }
690 if removed {
691 self.bump_version();
692 }
693 }
694
695 pub fn query_lines_in_range(
700 &self,
701 marker_list: &MarkerList,
702 start: usize,
703 end: usize,
704 ) -> Vec<(usize, &VirtualText)> {
705 let mut results: Vec<(usize, &VirtualText)> = self
706 .texts
707 .values()
708 .filter(|vtext| vtext.position.is_line())
709 .filter_map(|vtext| {
710 let pos = marker_list.get_position(vtext.marker_id)?;
711 if pos >= start && pos < end {
712 Some((pos, vtext))
713 } else {
714 None
715 }
716 })
717 .collect();
718
719 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
721
722 results
723 }
724
725 pub fn query_inline_in_range(
729 &self,
730 marker_list: &MarkerList,
731 start: usize,
732 end: usize,
733 ) -> Vec<(usize, &VirtualText)> {
734 let mut results: Vec<(usize, &VirtualText)> = self
735 .texts
736 .values()
737 .filter(|vtext| vtext.position.is_inline())
738 .filter_map(|vtext| {
739 let pos = marker_list.get_position(vtext.marker_id)?;
740 if pos >= start && pos < end {
741 Some((pos, vtext))
742 } else {
743 None
744 }
745 })
746 .collect();
747
748 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
750
751 results
752 }
753
754 pub fn build_lines_lookup(
759 &self,
760 marker_list: &MarkerList,
761 start: usize,
762 end: usize,
763 ) -> HashMap<usize, Vec<&VirtualText>> {
764 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
765
766 for vtext in self.texts.values() {
767 if !vtext.position.is_line() {
768 continue;
769 }
770 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
771 if pos >= start && pos < end {
772 lookup.entry(pos).or_default().push(vtext);
773 }
774 }
775 }
776
777 for texts in lookup.values_mut() {
779 texts.sort_by_key(|vt| vt.priority);
780 }
781
782 lookup
783 }
784}
785
786impl Default for VirtualTextManager {
787 fn default() -> Self {
788 Self::new()
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use ratatui::style::Color;
796
797 fn hint_style() -> Style {
798 Style::default().fg(Color::DarkGray)
799 }
800
801 #[test]
802 fn test_new_manager() {
803 let manager = VirtualTextManager::new();
804 assert_eq!(manager.len(), 0);
805 assert!(manager.is_empty());
806 }
807
808 #[test]
809 fn test_add_virtual_text() {
810 let mut marker_list = MarkerList::new();
811 let mut manager = VirtualTextManager::new();
812
813 let id = manager.add(
814 &mut marker_list,
815 10,
816 ": i32".to_string(),
817 hint_style(),
818 VirtualTextPosition::AfterChar,
819 0,
820 );
821
822 assert_eq!(manager.len(), 1);
823 assert!(!manager.is_empty());
824 assert_eq!(id.0, 0);
825 }
826
827 #[test]
828 fn test_remove_virtual_text() {
829 let mut marker_list = MarkerList::new();
830 let mut manager = VirtualTextManager::new();
831
832 let id = manager.add(
833 &mut marker_list,
834 10,
835 ": i32".to_string(),
836 hint_style(),
837 VirtualTextPosition::AfterChar,
838 0,
839 );
840
841 assert_eq!(manager.len(), 1);
842
843 let removed = manager.remove(&mut marker_list, id);
844 assert!(removed);
845 assert_eq!(manager.len(), 0);
846
847 assert_eq!(marker_list.marker_count(), 0);
849 }
850
851 #[test]
852 fn test_remove_nonexistent() {
853 let mut marker_list = MarkerList::new();
854 let mut manager = VirtualTextManager::new();
855
856 let removed = manager.remove(&mut marker_list, VirtualTextId(999));
857 assert!(!removed);
858 }
859
860 #[test]
861 fn test_clear() {
862 let mut marker_list = MarkerList::new();
863 let mut manager = VirtualTextManager::new();
864
865 manager.add(
866 &mut marker_list,
867 10,
868 ": i32".to_string(),
869 hint_style(),
870 VirtualTextPosition::AfterChar,
871 0,
872 );
873 manager.add(
874 &mut marker_list,
875 20,
876 ": String".to_string(),
877 hint_style(),
878 VirtualTextPosition::AfterChar,
879 0,
880 );
881
882 assert_eq!(manager.len(), 2);
883 assert_eq!(marker_list.marker_count(), 2);
884
885 manager.clear(&mut marker_list);
886
887 assert_eq!(manager.len(), 0);
888 assert_eq!(marker_list.marker_count(), 0);
889 }
890
891 #[test]
892 fn test_query_range() {
893 let mut marker_list = MarkerList::new();
894 let mut manager = VirtualTextManager::new();
895
896 manager.add(
898 &mut marker_list,
899 10,
900 ": i32".to_string(),
901 hint_style(),
902 VirtualTextPosition::AfterChar,
903 0,
904 );
905 manager.add(
906 &mut marker_list,
907 20,
908 ": String".to_string(),
909 hint_style(),
910 VirtualTextPosition::AfterChar,
911 0,
912 );
913 manager.add(
914 &mut marker_list,
915 30,
916 ": bool".to_string(),
917 hint_style(),
918 VirtualTextPosition::AfterChar,
919 0,
920 );
921
922 let results = manager.query_range(&marker_list, 15, 35);
924 assert_eq!(results.len(), 2);
925 assert_eq!(results[0].0, 20);
926 assert_eq!(results[0].1.text, ": String");
927 assert_eq!(results[1].0, 30);
928 assert_eq!(results[1].1.text, ": bool");
929
930 let results = manager.query_range(&marker_list, 0, 15);
932 assert_eq!(results.len(), 1);
933 assert_eq!(results[0].0, 10);
934 assert_eq!(results[0].1.text, ": i32");
935 }
936
937 #[test]
938 fn test_query_empty_range() {
939 let mut marker_list = MarkerList::new();
940 let mut manager = VirtualTextManager::new();
941
942 manager.add(
943 &mut marker_list,
944 10,
945 ": i32".to_string(),
946 hint_style(),
947 VirtualTextPosition::AfterChar,
948 0,
949 );
950
951 let results = manager.query_range(&marker_list, 100, 200);
953 assert!(results.is_empty());
954 }
955
956 #[test]
957 fn test_priority_ordering() {
958 let mut marker_list = MarkerList::new();
959 let mut manager = VirtualTextManager::new();
960
961 manager.add(
963 &mut marker_list,
964 10,
965 "low".to_string(),
966 hint_style(),
967 VirtualTextPosition::AfterChar,
968 0,
969 );
970 manager.add(
971 &mut marker_list,
972 10,
973 "high".to_string(),
974 hint_style(),
975 VirtualTextPosition::AfterChar,
976 10,
977 );
978 manager.add(
979 &mut marker_list,
980 10,
981 "medium".to_string(),
982 hint_style(),
983 VirtualTextPosition::AfterChar,
984 5,
985 );
986
987 let results = manager.query_range(&marker_list, 0, 20);
988 assert_eq!(results.len(), 3);
989 assert_eq!(results[0].1.text, "low");
991 assert_eq!(results[1].1.text, "medium");
992 assert_eq!(results[2].1.text, "high");
993 }
994
995 #[test]
996 fn test_build_lookup() {
997 let mut marker_list = MarkerList::new();
998 let mut manager = VirtualTextManager::new();
999
1000 manager.add(
1001 &mut marker_list,
1002 10,
1003 ": i32".to_string(),
1004 hint_style(),
1005 VirtualTextPosition::AfterChar,
1006 0,
1007 );
1008 manager.add(
1009 &mut marker_list,
1010 10,
1011 " = 5".to_string(),
1012 hint_style(),
1013 VirtualTextPosition::AfterChar,
1014 1,
1015 );
1016 manager.add(
1017 &mut marker_list,
1018 20,
1019 ": String".to_string(),
1020 hint_style(),
1021 VirtualTextPosition::AfterChar,
1022 0,
1023 );
1024
1025 let lookup = manager.build_lookup(&marker_list, 0, 30);
1026
1027 assert_eq!(lookup.len(), 2); let at_10 = lookup.get(&10).unwrap();
1030 assert_eq!(at_10.len(), 2);
1031 assert_eq!(at_10[0].text, ": i32"); assert_eq!(at_10[1].text, " = 5"); let at_20 = lookup.get(&20).unwrap();
1035 assert_eq!(at_20.len(), 1);
1036 assert_eq!(at_20[0].text, ": String");
1037 }
1038
1039 #[test]
1040 fn test_position_tracking_after_insert() {
1041 let mut marker_list = MarkerList::new();
1042 let mut manager = VirtualTextManager::new();
1043
1044 manager.add(
1045 &mut marker_list,
1046 10,
1047 ": i32".to_string(),
1048 hint_style(),
1049 VirtualTextPosition::AfterChar,
1050 0,
1051 );
1052
1053 marker_list.adjust_for_insert(5, 5);
1055
1056 let results = manager.query_range(&marker_list, 0, 20);
1058 assert_eq!(results.len(), 1);
1059 assert_eq!(results[0].0, 15);
1060 }
1061
1062 #[test]
1063 fn test_position_tracking_after_delete() {
1064 let mut marker_list = MarkerList::new();
1065 let mut manager = VirtualTextManager::new();
1066
1067 manager.add(
1068 &mut marker_list,
1069 20,
1070 ": i32".to_string(),
1071 hint_style(),
1072 VirtualTextPosition::AfterChar,
1073 0,
1074 );
1075
1076 marker_list.adjust_for_delete(10, 5);
1078
1079 let results = manager.query_range(&marker_list, 0, 20);
1081 assert_eq!(results.len(), 1);
1082 assert_eq!(results[0].0, 15);
1083 }
1084
1085 #[test]
1086 fn test_before_and_after_positions() {
1087 let mut marker_list = MarkerList::new();
1088 let mut manager = VirtualTextManager::new();
1089
1090 manager.add(
1091 &mut marker_list,
1092 10,
1093 "/*param=*/".to_string(),
1094 hint_style(),
1095 VirtualTextPosition::BeforeChar,
1096 0,
1097 );
1098 manager.add(
1099 &mut marker_list,
1100 10,
1101 ": Type".to_string(),
1102 hint_style(),
1103 VirtualTextPosition::AfterChar,
1104 0,
1105 );
1106
1107 let lookup = manager.build_lookup(&marker_list, 0, 20);
1108 let at_10 = lookup.get(&10).unwrap();
1109
1110 assert_eq!(at_10.len(), 2);
1111 let before = at_10
1113 .iter()
1114 .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
1115 let after = at_10
1116 .iter()
1117 .find(|vt| vt.position == VirtualTextPosition::AfterChar);
1118
1119 assert!(before.is_some());
1120 assert!(after.is_some());
1121 assert_eq!(before.unwrap().text, "/*param=*/");
1122 assert_eq!(after.unwrap().text, ": Type");
1123 }
1124}