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}
113
114impl VirtualText {
115 pub fn resolved_style(&self, theme: &crate::view::theme::Theme) -> Style {
122 let mut style = self.style;
123 if let Some(ref key) = self.fg_theme_key {
124 if let Some(color) = theme.resolve_theme_key(key) {
125 style = style.fg(color);
126 }
127 }
128 if let Some(ref key) = self.bg_theme_key {
129 if let Some(color) = theme.resolve_theme_key(key) {
130 style = style.bg(color);
131 }
132 }
133 style
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub struct VirtualTextId(pub u64);
140
141pub struct VirtualTextManager {
146 texts: HashMap<VirtualTextId, VirtualText>,
148 next_id: u64,
150 version: u32,
156}
157
158impl VirtualTextManager {
159 pub fn new() -> Self {
161 Self {
162 texts: HashMap::new(),
163 next_id: 0,
164 version: 0,
165 }
166 }
167
168 #[inline]
172 pub fn version(&self) -> u32 {
173 self.version
174 }
175
176 #[inline]
177 fn bump_version(&mut self) {
178 self.version = self.version.wrapping_add(1);
179 }
180
181 pub fn add(
194 &mut self,
195 marker_list: &mut MarkerList,
196 position: usize,
197 text: String,
198 style: Style,
199 vtext_position: VirtualTextPosition,
200 priority: i32,
201 ) -> VirtualTextId {
202 let marker_id = marker_list.create(position, false);
205
206 let id = VirtualTextId(self.next_id);
207 self.next_id += 1;
208
209 self.texts.insert(
210 id,
211 VirtualText {
212 marker_id,
213 text,
214 style,
215 fg_theme_key: None,
216 bg_theme_key: None,
217 position: vtext_position,
218 priority,
219 string_id: None,
220 namespace: None,
221 gutter_glyph: None,
222 gutter_color: None,
223 },
224 );
225 self.bump_version();
226
227 id
228 }
229
230 #[allow(clippy::too_many_arguments)]
238 pub fn add_with_theme_keys(
239 &mut self,
240 marker_list: &mut MarkerList,
241 position: usize,
242 text: String,
243 style: Style,
244 fg_theme_key: Option<String>,
245 bg_theme_key: Option<String>,
246 vtext_position: VirtualTextPosition,
247 priority: i32,
248 ) -> VirtualTextId {
249 debug_assert!(
250 vtext_position.is_inline(),
251 "add_with_theme_keys requires BeforeChar or AfterChar"
252 );
253
254 let marker_id = marker_list.create(position, false);
255
256 let id = VirtualTextId(self.next_id);
257 self.next_id += 1;
258
259 self.texts.insert(
260 id,
261 VirtualText {
262 marker_id,
263 text,
264 style,
265 fg_theme_key,
266 bg_theme_key,
267 position: vtext_position,
268 priority,
269 string_id: None,
270 namespace: None,
271 gutter_glyph: None,
272 gutter_color: None,
273 },
274 );
275 self.bump_version();
276
277 id
278 }
279
280 #[allow(clippy::too_many_arguments)]
284 pub fn add_with_id(
285 &mut self,
286 marker_list: &mut MarkerList,
287 position: usize,
288 text: String,
289 style: Style,
290 vtext_position: VirtualTextPosition,
291 priority: i32,
292 string_id: String,
293 ) -> VirtualTextId {
294 let marker_id = marker_list.create(position, false);
295
296 let id = VirtualTextId(self.next_id);
297 self.next_id += 1;
298
299 self.texts.insert(
300 id,
301 VirtualText {
302 marker_id,
303 text,
304 style,
305 fg_theme_key: None,
306 bg_theme_key: None,
307 position: vtext_position,
308 priority,
309 string_id: Some(string_id),
310 namespace: None,
311 gutter_glyph: None,
312 gutter_color: None,
313 },
314 );
315 self.bump_version();
316
317 id
318 }
319
320 #[allow(clippy::too_many_arguments)]
323 pub fn add_with_id_and_theme_keys(
324 &mut self,
325 marker_list: &mut MarkerList,
326 position: usize,
327 text: String,
328 style: Style,
329 fg_theme_key: Option<String>,
330 bg_theme_key: Option<String>,
331 vtext_position: VirtualTextPosition,
332 priority: i32,
333 string_id: String,
334 ) -> VirtualTextId {
335 debug_assert!(
336 vtext_position.is_inline(),
337 "add_with_id_and_theme_keys requires BeforeChar or AfterChar"
338 );
339
340 let marker_id = marker_list.create(position, false);
341
342 let id = VirtualTextId(self.next_id);
343 self.next_id += 1;
344
345 self.texts.insert(
346 id,
347 VirtualText {
348 marker_id,
349 text,
350 style,
351 fg_theme_key,
352 bg_theme_key,
353 position: vtext_position,
354 priority,
355 string_id: Some(string_id),
356 namespace: None,
357 gutter_glyph: None,
358 gutter_color: None,
359 },
360 );
361
362 id
363 }
364
365 #[allow(clippy::too_many_arguments)]
378 pub fn add_line(
379 &mut self,
380 marker_list: &mut MarkerList,
381 position: usize,
382 text: String,
383 style: Style,
384 placement: VirtualTextPosition,
385 namespace: VirtualTextNamespace,
386 priority: i32,
387 ) -> VirtualTextId {
388 self.add_line_with_theme_keys(
389 marker_list,
390 position,
391 text,
392 style,
393 None,
394 None,
395 placement,
396 namespace,
397 priority,
398 None,
399 None,
400 )
401 }
402
403 #[allow(clippy::too_many_arguments)]
411 pub fn add_line_with_theme_keys(
412 &mut self,
413 marker_list: &mut MarkerList,
414 position: usize,
415 text: String,
416 style: Style,
417 fg_theme_key: Option<String>,
418 bg_theme_key: Option<String>,
419 placement: VirtualTextPosition,
420 namespace: VirtualTextNamespace,
421 priority: i32,
422 gutter_glyph: Option<String>,
423 gutter_color: Option<Color>,
424 ) -> VirtualTextId {
425 debug_assert!(
426 placement.is_line(),
427 "add_line requires LineAbove or LineBelow"
428 );
429
430 let marker_id = marker_list.create(position, false);
431
432 let id = VirtualTextId(self.next_id);
433 self.next_id += 1;
434
435 self.texts.insert(
436 id,
437 VirtualText {
438 marker_id,
439 text,
440 style,
441 fg_theme_key,
442 bg_theme_key,
443 position: placement,
444 priority,
445 string_id: None,
446 namespace: Some(namespace),
447 gutter_glyph,
448 gutter_color,
449 },
450 );
451 self.bump_version();
452
453 id
454 }
455
456 pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
458 let to_remove: Vec<VirtualTextId> = self
460 .texts
461 .iter()
462 .filter_map(|(id, vtext)| {
463 if vtext.string_id.as_deref() == Some(string_id) {
464 Some(*id)
465 } else {
466 None
467 }
468 })
469 .collect();
470
471 let mut removed = false;
472 for id in to_remove {
473 if let Some(vtext) = self.texts.remove(&id) {
474 marker_list.delete(vtext.marker_id);
475 removed = true;
476 }
477 }
478 if removed {
479 self.bump_version();
480 }
481 removed
482 }
483
484 pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
486 let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
488 .texts
489 .iter()
490 .filter_map(|(id, vtext)| {
491 if let Some(ref sid) = vtext.string_id {
492 if sid.starts_with(prefix) {
493 return Some((*id, vtext.marker_id));
494 }
495 }
496 None
497 })
498 .collect();
499
500 let removed = !markers_to_delete.is_empty();
502 for (id, marker_id) in markers_to_delete {
503 marker_list.delete(marker_id);
504 self.texts.remove(&id);
505 }
506 if removed {
507 self.bump_version();
508 }
509 }
510
511 pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
513 if let Some(vtext) = self.texts.remove(&id) {
514 marker_list.delete(vtext.marker_id);
515 self.bump_version();
516 true
517 } else {
518 false
519 }
520 }
521
522 pub fn clear(&mut self, marker_list: &mut MarkerList) {
524 let was_non_empty = !self.texts.is_empty();
525 for vtext in self.texts.values() {
526 marker_list.delete(vtext.marker_id);
527 }
528 self.texts.clear();
529 if was_non_empty {
530 self.bump_version();
531 }
532 }
533
534 pub fn remove_in_range(
546 &mut self,
547 marker_list: &mut MarkerList,
548 start: usize,
549 end: usize,
550 ) -> usize {
551 if start >= end {
552 return 0;
553 }
554
555 let to_remove: Vec<VirtualTextId> = self
556 .texts
557 .iter()
558 .filter_map(|(id, vtext)| {
559 let pos = marker_list.get_position(vtext.marker_id)?;
560 if pos >= start && pos < end {
561 Some(*id)
562 } else {
563 None
564 }
565 })
566 .collect();
567
568 let count = to_remove.len();
569 for id in to_remove {
570 if let Some(vtext) = self.texts.remove(&id) {
571 marker_list.delete(vtext.marker_id);
572 }
573 }
574 if count > 0 {
575 self.bump_version();
576 }
577 count
578 }
579
580 pub fn len(&self) -> usize {
582 self.texts.len()
583 }
584
585 pub fn is_empty(&self) -> bool {
587 self.texts.is_empty()
588 }
589
590 pub fn query_range(
601 &self,
602 marker_list: &MarkerList,
603 start: usize,
604 end: usize,
605 ) -> Vec<(usize, &VirtualText)> {
606 let mut results: Vec<(usize, &VirtualText)> = self
607 .texts
608 .values()
609 .filter_map(|vtext| {
610 let pos = marker_list.get_position(vtext.marker_id)?;
611 if pos >= start && pos < end {
612 Some((pos, vtext))
613 } else {
614 None
615 }
616 })
617 .collect();
618
619 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
621
622 results
623 }
624
625 pub fn build_lookup(
630 &self,
631 marker_list: &MarkerList,
632 start: usize,
633 end: usize,
634 ) -> HashMap<usize, Vec<&VirtualText>> {
635 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
636
637 for vtext in self.texts.values() {
638 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
639 if pos >= start && pos < end {
640 lookup.entry(pos).or_default().push(vtext);
641 }
642 }
643 }
644
645 for texts in lookup.values_mut() {
647 texts.sort_by_key(|vt| vt.priority);
648 }
649
650 lookup
651 }
652
653 pub fn clear_namespace(
657 &mut self,
658 marker_list: &mut MarkerList,
659 namespace: &VirtualTextNamespace,
660 ) {
661 let to_remove: Vec<VirtualTextId> = self
662 .texts
663 .iter()
664 .filter_map(|(id, vtext)| {
665 if vtext.namespace.as_ref() == Some(namespace) {
666 Some(*id)
667 } else {
668 None
669 }
670 })
671 .collect();
672
673 let removed = !to_remove.is_empty();
674 for id in to_remove {
675 if let Some(vtext) = self.texts.remove(&id) {
676 marker_list.delete(vtext.marker_id);
677 }
678 }
679 if removed {
680 self.bump_version();
681 }
682 }
683
684 pub fn query_lines_in_range(
689 &self,
690 marker_list: &MarkerList,
691 start: usize,
692 end: usize,
693 ) -> Vec<(usize, &VirtualText)> {
694 let mut results: Vec<(usize, &VirtualText)> = self
695 .texts
696 .values()
697 .filter(|vtext| vtext.position.is_line())
698 .filter_map(|vtext| {
699 let pos = marker_list.get_position(vtext.marker_id)?;
700 if pos >= start && pos < end {
701 Some((pos, vtext))
702 } else {
703 None
704 }
705 })
706 .collect();
707
708 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
710
711 results
712 }
713
714 pub fn query_inline_in_range(
718 &self,
719 marker_list: &MarkerList,
720 start: usize,
721 end: usize,
722 ) -> Vec<(usize, &VirtualText)> {
723 let mut results: Vec<(usize, &VirtualText)> = self
724 .texts
725 .values()
726 .filter(|vtext| vtext.position.is_inline())
727 .filter_map(|vtext| {
728 let pos = marker_list.get_position(vtext.marker_id)?;
729 if pos >= start && pos < end {
730 Some((pos, vtext))
731 } else {
732 None
733 }
734 })
735 .collect();
736
737 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
739
740 results
741 }
742
743 pub fn build_lines_lookup(
748 &self,
749 marker_list: &MarkerList,
750 start: usize,
751 end: usize,
752 ) -> HashMap<usize, Vec<&VirtualText>> {
753 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
754
755 for vtext in self.texts.values() {
756 if !vtext.position.is_line() {
757 continue;
758 }
759 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
760 if pos >= start && pos < end {
761 lookup.entry(pos).or_default().push(vtext);
762 }
763 }
764 }
765
766 for texts in lookup.values_mut() {
768 texts.sort_by_key(|vt| vt.priority);
769 }
770
771 lookup
772 }
773}
774
775impl Default for VirtualTextManager {
776 fn default() -> Self {
777 Self::new()
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use ratatui::style::Color;
785
786 fn hint_style() -> Style {
787 Style::default().fg(Color::DarkGray)
788 }
789
790 #[test]
791 fn test_new_manager() {
792 let manager = VirtualTextManager::new();
793 assert_eq!(manager.len(), 0);
794 assert!(manager.is_empty());
795 }
796
797 #[test]
798 fn test_add_virtual_text() {
799 let mut marker_list = MarkerList::new();
800 let mut manager = VirtualTextManager::new();
801
802 let id = manager.add(
803 &mut marker_list,
804 10,
805 ": i32".to_string(),
806 hint_style(),
807 VirtualTextPosition::AfterChar,
808 0,
809 );
810
811 assert_eq!(manager.len(), 1);
812 assert!(!manager.is_empty());
813 assert_eq!(id.0, 0);
814 }
815
816 #[test]
817 fn test_remove_virtual_text() {
818 let mut marker_list = MarkerList::new();
819 let mut manager = VirtualTextManager::new();
820
821 let id = manager.add(
822 &mut marker_list,
823 10,
824 ": i32".to_string(),
825 hint_style(),
826 VirtualTextPosition::AfterChar,
827 0,
828 );
829
830 assert_eq!(manager.len(), 1);
831
832 let removed = manager.remove(&mut marker_list, id);
833 assert!(removed);
834 assert_eq!(manager.len(), 0);
835
836 assert_eq!(marker_list.marker_count(), 0);
838 }
839
840 #[test]
841 fn test_remove_nonexistent() {
842 let mut marker_list = MarkerList::new();
843 let mut manager = VirtualTextManager::new();
844
845 let removed = manager.remove(&mut marker_list, VirtualTextId(999));
846 assert!(!removed);
847 }
848
849 #[test]
850 fn test_clear() {
851 let mut marker_list = MarkerList::new();
852 let mut manager = VirtualTextManager::new();
853
854 manager.add(
855 &mut marker_list,
856 10,
857 ": i32".to_string(),
858 hint_style(),
859 VirtualTextPosition::AfterChar,
860 0,
861 );
862 manager.add(
863 &mut marker_list,
864 20,
865 ": String".to_string(),
866 hint_style(),
867 VirtualTextPosition::AfterChar,
868 0,
869 );
870
871 assert_eq!(manager.len(), 2);
872 assert_eq!(marker_list.marker_count(), 2);
873
874 manager.clear(&mut marker_list);
875
876 assert_eq!(manager.len(), 0);
877 assert_eq!(marker_list.marker_count(), 0);
878 }
879
880 #[test]
881 fn test_query_range() {
882 let mut marker_list = MarkerList::new();
883 let mut manager = VirtualTextManager::new();
884
885 manager.add(
887 &mut marker_list,
888 10,
889 ": i32".to_string(),
890 hint_style(),
891 VirtualTextPosition::AfterChar,
892 0,
893 );
894 manager.add(
895 &mut marker_list,
896 20,
897 ": String".to_string(),
898 hint_style(),
899 VirtualTextPosition::AfterChar,
900 0,
901 );
902 manager.add(
903 &mut marker_list,
904 30,
905 ": bool".to_string(),
906 hint_style(),
907 VirtualTextPosition::AfterChar,
908 0,
909 );
910
911 let results = manager.query_range(&marker_list, 15, 35);
913 assert_eq!(results.len(), 2);
914 assert_eq!(results[0].0, 20);
915 assert_eq!(results[0].1.text, ": String");
916 assert_eq!(results[1].0, 30);
917 assert_eq!(results[1].1.text, ": bool");
918
919 let results = manager.query_range(&marker_list, 0, 15);
921 assert_eq!(results.len(), 1);
922 assert_eq!(results[0].0, 10);
923 assert_eq!(results[0].1.text, ": i32");
924 }
925
926 #[test]
927 fn test_query_empty_range() {
928 let mut marker_list = MarkerList::new();
929 let mut manager = VirtualTextManager::new();
930
931 manager.add(
932 &mut marker_list,
933 10,
934 ": i32".to_string(),
935 hint_style(),
936 VirtualTextPosition::AfterChar,
937 0,
938 );
939
940 let results = manager.query_range(&marker_list, 100, 200);
942 assert!(results.is_empty());
943 }
944
945 #[test]
946 fn test_priority_ordering() {
947 let mut marker_list = MarkerList::new();
948 let mut manager = VirtualTextManager::new();
949
950 manager.add(
952 &mut marker_list,
953 10,
954 "low".to_string(),
955 hint_style(),
956 VirtualTextPosition::AfterChar,
957 0,
958 );
959 manager.add(
960 &mut marker_list,
961 10,
962 "high".to_string(),
963 hint_style(),
964 VirtualTextPosition::AfterChar,
965 10,
966 );
967 manager.add(
968 &mut marker_list,
969 10,
970 "medium".to_string(),
971 hint_style(),
972 VirtualTextPosition::AfterChar,
973 5,
974 );
975
976 let results = manager.query_range(&marker_list, 0, 20);
977 assert_eq!(results.len(), 3);
978 assert_eq!(results[0].1.text, "low");
980 assert_eq!(results[1].1.text, "medium");
981 assert_eq!(results[2].1.text, "high");
982 }
983
984 #[test]
985 fn test_build_lookup() {
986 let mut marker_list = MarkerList::new();
987 let mut manager = VirtualTextManager::new();
988
989 manager.add(
990 &mut marker_list,
991 10,
992 ": i32".to_string(),
993 hint_style(),
994 VirtualTextPosition::AfterChar,
995 0,
996 );
997 manager.add(
998 &mut marker_list,
999 10,
1000 " = 5".to_string(),
1001 hint_style(),
1002 VirtualTextPosition::AfterChar,
1003 1,
1004 );
1005 manager.add(
1006 &mut marker_list,
1007 20,
1008 ": String".to_string(),
1009 hint_style(),
1010 VirtualTextPosition::AfterChar,
1011 0,
1012 );
1013
1014 let lookup = manager.build_lookup(&marker_list, 0, 30);
1015
1016 assert_eq!(lookup.len(), 2); let at_10 = lookup.get(&10).unwrap();
1019 assert_eq!(at_10.len(), 2);
1020 assert_eq!(at_10[0].text, ": i32"); assert_eq!(at_10[1].text, " = 5"); let at_20 = lookup.get(&20).unwrap();
1024 assert_eq!(at_20.len(), 1);
1025 assert_eq!(at_20[0].text, ": String");
1026 }
1027
1028 #[test]
1029 fn test_position_tracking_after_insert() {
1030 let mut marker_list = MarkerList::new();
1031 let mut manager = VirtualTextManager::new();
1032
1033 manager.add(
1034 &mut marker_list,
1035 10,
1036 ": i32".to_string(),
1037 hint_style(),
1038 VirtualTextPosition::AfterChar,
1039 0,
1040 );
1041
1042 marker_list.adjust_for_insert(5, 5);
1044
1045 let results = manager.query_range(&marker_list, 0, 20);
1047 assert_eq!(results.len(), 1);
1048 assert_eq!(results[0].0, 15);
1049 }
1050
1051 #[test]
1052 fn test_position_tracking_after_delete() {
1053 let mut marker_list = MarkerList::new();
1054 let mut manager = VirtualTextManager::new();
1055
1056 manager.add(
1057 &mut marker_list,
1058 20,
1059 ": i32".to_string(),
1060 hint_style(),
1061 VirtualTextPosition::AfterChar,
1062 0,
1063 );
1064
1065 marker_list.adjust_for_delete(10, 5);
1067
1068 let results = manager.query_range(&marker_list, 0, 20);
1070 assert_eq!(results.len(), 1);
1071 assert_eq!(results[0].0, 15);
1072 }
1073
1074 #[test]
1075 fn test_before_and_after_positions() {
1076 let mut marker_list = MarkerList::new();
1077 let mut manager = VirtualTextManager::new();
1078
1079 manager.add(
1080 &mut marker_list,
1081 10,
1082 "/*param=*/".to_string(),
1083 hint_style(),
1084 VirtualTextPosition::BeforeChar,
1085 0,
1086 );
1087 manager.add(
1088 &mut marker_list,
1089 10,
1090 ": Type".to_string(),
1091 hint_style(),
1092 VirtualTextPosition::AfterChar,
1093 0,
1094 );
1095
1096 let lookup = manager.build_lookup(&marker_list, 0, 20);
1097 let at_10 = lookup.get(&10).unwrap();
1098
1099 assert_eq!(at_10.len(), 2);
1100 let before = at_10
1102 .iter()
1103 .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
1104 let after = at_10
1105 .iter()
1106 .find(|vt| vt.position == VirtualTextPosition::AfterChar);
1107
1108 assert!(before.is_some());
1109 assert!(after.is_some());
1110 assert_eq!(before.unwrap().text, "/*param=*/");
1111 assert_eq!(after.unwrap().text, ": Type");
1112 }
1113}