1use ratatui::style::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}
103
104impl VirtualText {
105 pub fn resolved_style(&self, theme: &crate::view::theme::Theme) -> Style {
112 let mut style = self.style;
113 if let Some(ref key) = self.fg_theme_key {
114 if let Some(color) = theme.resolve_theme_key(key) {
115 style = style.fg(color);
116 }
117 }
118 if let Some(ref key) = self.bg_theme_key {
119 if let Some(color) = theme.resolve_theme_key(key) {
120 style = style.bg(color);
121 }
122 }
123 style
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub struct VirtualTextId(pub u64);
130
131pub struct VirtualTextManager {
136 texts: HashMap<VirtualTextId, VirtualText>,
138 next_id: u64,
140 version: u32,
146}
147
148impl VirtualTextManager {
149 pub fn new() -> Self {
151 Self {
152 texts: HashMap::new(),
153 next_id: 0,
154 version: 0,
155 }
156 }
157
158 #[inline]
162 pub fn version(&self) -> u32 {
163 self.version
164 }
165
166 #[inline]
167 fn bump_version(&mut self) {
168 self.version = self.version.wrapping_add(1);
169 }
170
171 pub fn add(
184 &mut self,
185 marker_list: &mut MarkerList,
186 position: usize,
187 text: String,
188 style: Style,
189 vtext_position: VirtualTextPosition,
190 priority: i32,
191 ) -> VirtualTextId {
192 let marker_id = marker_list.create(position, false);
195
196 let id = VirtualTextId(self.next_id);
197 self.next_id += 1;
198
199 self.texts.insert(
200 id,
201 VirtualText {
202 marker_id,
203 text,
204 style,
205 fg_theme_key: None,
206 bg_theme_key: None,
207 position: vtext_position,
208 priority,
209 string_id: None,
210 namespace: None,
211 },
212 );
213 self.bump_version();
214
215 id
216 }
217
218 #[allow(clippy::too_many_arguments)]
226 pub fn add_with_theme_keys(
227 &mut self,
228 marker_list: &mut MarkerList,
229 position: usize,
230 text: String,
231 style: Style,
232 fg_theme_key: Option<String>,
233 bg_theme_key: Option<String>,
234 vtext_position: VirtualTextPosition,
235 priority: i32,
236 ) -> VirtualTextId {
237 debug_assert!(
238 vtext_position.is_inline(),
239 "add_with_theme_keys requires BeforeChar or AfterChar"
240 );
241
242 let marker_id = marker_list.create(position, false);
243
244 let id = VirtualTextId(self.next_id);
245 self.next_id += 1;
246
247 self.texts.insert(
248 id,
249 VirtualText {
250 marker_id,
251 text,
252 style,
253 fg_theme_key,
254 bg_theme_key,
255 position: vtext_position,
256 priority,
257 string_id: None,
258 namespace: None,
259 },
260 );
261 self.bump_version();
262
263 id
264 }
265
266 #[allow(clippy::too_many_arguments)]
270 pub fn add_with_id(
271 &mut self,
272 marker_list: &mut MarkerList,
273 position: usize,
274 text: String,
275 style: Style,
276 vtext_position: VirtualTextPosition,
277 priority: i32,
278 string_id: String,
279 ) -> VirtualTextId {
280 let marker_id = marker_list.create(position, false);
281
282 let id = VirtualTextId(self.next_id);
283 self.next_id += 1;
284
285 self.texts.insert(
286 id,
287 VirtualText {
288 marker_id,
289 text,
290 style,
291 fg_theme_key: None,
292 bg_theme_key: None,
293 position: vtext_position,
294 priority,
295 string_id: Some(string_id),
296 namespace: None,
297 },
298 );
299 self.bump_version();
300
301 id
302 }
303
304 #[allow(clippy::too_many_arguments)]
307 pub fn add_with_id_and_theme_keys(
308 &mut self,
309 marker_list: &mut MarkerList,
310 position: usize,
311 text: String,
312 style: Style,
313 fg_theme_key: Option<String>,
314 bg_theme_key: Option<String>,
315 vtext_position: VirtualTextPosition,
316 priority: i32,
317 string_id: String,
318 ) -> VirtualTextId {
319 debug_assert!(
320 vtext_position.is_inline(),
321 "add_with_id_and_theme_keys requires BeforeChar or AfterChar"
322 );
323
324 let marker_id = marker_list.create(position, false);
325
326 let id = VirtualTextId(self.next_id);
327 self.next_id += 1;
328
329 self.texts.insert(
330 id,
331 VirtualText {
332 marker_id,
333 text,
334 style,
335 fg_theme_key,
336 bg_theme_key,
337 position: vtext_position,
338 priority,
339 string_id: Some(string_id),
340 namespace: None,
341 },
342 );
343
344 id
345 }
346
347 #[allow(clippy::too_many_arguments)]
360 pub fn add_line(
361 &mut self,
362 marker_list: &mut MarkerList,
363 position: usize,
364 text: String,
365 style: Style,
366 placement: VirtualTextPosition,
367 namespace: VirtualTextNamespace,
368 priority: i32,
369 ) -> VirtualTextId {
370 self.add_line_with_theme_keys(
371 marker_list,
372 position,
373 text,
374 style,
375 None,
376 None,
377 placement,
378 namespace,
379 priority,
380 )
381 }
382
383 #[allow(clippy::too_many_arguments)]
391 pub fn add_line_with_theme_keys(
392 &mut self,
393 marker_list: &mut MarkerList,
394 position: usize,
395 text: String,
396 style: Style,
397 fg_theme_key: Option<String>,
398 bg_theme_key: Option<String>,
399 placement: VirtualTextPosition,
400 namespace: VirtualTextNamespace,
401 priority: i32,
402 ) -> VirtualTextId {
403 debug_assert!(
404 placement.is_line(),
405 "add_line requires LineAbove or LineBelow"
406 );
407
408 let marker_id = marker_list.create(position, false);
409
410 let id = VirtualTextId(self.next_id);
411 self.next_id += 1;
412
413 self.texts.insert(
414 id,
415 VirtualText {
416 marker_id,
417 text,
418 style,
419 fg_theme_key,
420 bg_theme_key,
421 position: placement,
422 priority,
423 string_id: None,
424 namespace: Some(namespace),
425 },
426 );
427 self.bump_version();
428
429 id
430 }
431
432 pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
434 let to_remove: Vec<VirtualTextId> = self
436 .texts
437 .iter()
438 .filter_map(|(id, vtext)| {
439 if vtext.string_id.as_deref() == Some(string_id) {
440 Some(*id)
441 } else {
442 None
443 }
444 })
445 .collect();
446
447 let mut removed = false;
448 for id in to_remove {
449 if let Some(vtext) = self.texts.remove(&id) {
450 marker_list.delete(vtext.marker_id);
451 removed = true;
452 }
453 }
454 if removed {
455 self.bump_version();
456 }
457 removed
458 }
459
460 pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
462 let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
464 .texts
465 .iter()
466 .filter_map(|(id, vtext)| {
467 if let Some(ref sid) = vtext.string_id {
468 if sid.starts_with(prefix) {
469 return Some((*id, vtext.marker_id));
470 }
471 }
472 None
473 })
474 .collect();
475
476 let removed = !markers_to_delete.is_empty();
478 for (id, marker_id) in markers_to_delete {
479 marker_list.delete(marker_id);
480 self.texts.remove(&id);
481 }
482 if removed {
483 self.bump_version();
484 }
485 }
486
487 pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
489 if let Some(vtext) = self.texts.remove(&id) {
490 marker_list.delete(vtext.marker_id);
491 self.bump_version();
492 true
493 } else {
494 false
495 }
496 }
497
498 pub fn clear(&mut self, marker_list: &mut MarkerList) {
500 let was_non_empty = !self.texts.is_empty();
501 for vtext in self.texts.values() {
502 marker_list.delete(vtext.marker_id);
503 }
504 self.texts.clear();
505 if was_non_empty {
506 self.bump_version();
507 }
508 }
509
510 pub fn remove_in_range(
522 &mut self,
523 marker_list: &mut MarkerList,
524 start: usize,
525 end: usize,
526 ) -> usize {
527 if start >= end {
528 return 0;
529 }
530
531 let to_remove: Vec<VirtualTextId> = self
532 .texts
533 .iter()
534 .filter_map(|(id, vtext)| {
535 let pos = marker_list.get_position(vtext.marker_id)?;
536 if pos >= start && pos < end {
537 Some(*id)
538 } else {
539 None
540 }
541 })
542 .collect();
543
544 let count = to_remove.len();
545 for id in to_remove {
546 if let Some(vtext) = self.texts.remove(&id) {
547 marker_list.delete(vtext.marker_id);
548 }
549 }
550 if count > 0 {
551 self.bump_version();
552 }
553 count
554 }
555
556 pub fn len(&self) -> usize {
558 self.texts.len()
559 }
560
561 pub fn is_empty(&self) -> bool {
563 self.texts.is_empty()
564 }
565
566 pub fn query_range(
577 &self,
578 marker_list: &MarkerList,
579 start: usize,
580 end: usize,
581 ) -> Vec<(usize, &VirtualText)> {
582 let mut results: Vec<(usize, &VirtualText)> = self
583 .texts
584 .values()
585 .filter_map(|vtext| {
586 let pos = marker_list.get_position(vtext.marker_id)?;
587 if pos >= start && pos < end {
588 Some((pos, vtext))
589 } else {
590 None
591 }
592 })
593 .collect();
594
595 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
597
598 results
599 }
600
601 pub fn build_lookup(
606 &self,
607 marker_list: &MarkerList,
608 start: usize,
609 end: usize,
610 ) -> HashMap<usize, Vec<&VirtualText>> {
611 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
612
613 for vtext in self.texts.values() {
614 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
615 if pos >= start && pos < end {
616 lookup.entry(pos).or_default().push(vtext);
617 }
618 }
619 }
620
621 for texts in lookup.values_mut() {
623 texts.sort_by_key(|vt| vt.priority);
624 }
625
626 lookup
627 }
628
629 pub fn clear_namespace(
633 &mut self,
634 marker_list: &mut MarkerList,
635 namespace: &VirtualTextNamespace,
636 ) {
637 let to_remove: Vec<VirtualTextId> = self
638 .texts
639 .iter()
640 .filter_map(|(id, vtext)| {
641 if vtext.namespace.as_ref() == Some(namespace) {
642 Some(*id)
643 } else {
644 None
645 }
646 })
647 .collect();
648
649 let removed = !to_remove.is_empty();
650 for id in to_remove {
651 if let Some(vtext) = self.texts.remove(&id) {
652 marker_list.delete(vtext.marker_id);
653 }
654 }
655 if removed {
656 self.bump_version();
657 }
658 }
659
660 pub fn query_lines_in_range(
665 &self,
666 marker_list: &MarkerList,
667 start: usize,
668 end: usize,
669 ) -> Vec<(usize, &VirtualText)> {
670 let mut results: Vec<(usize, &VirtualText)> = self
671 .texts
672 .values()
673 .filter(|vtext| vtext.position.is_line())
674 .filter_map(|vtext| {
675 let pos = marker_list.get_position(vtext.marker_id)?;
676 if pos >= start && pos < end {
677 Some((pos, vtext))
678 } else {
679 None
680 }
681 })
682 .collect();
683
684 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
686
687 results
688 }
689
690 pub fn query_inline_in_range(
694 &self,
695 marker_list: &MarkerList,
696 start: usize,
697 end: usize,
698 ) -> Vec<(usize, &VirtualText)> {
699 let mut results: Vec<(usize, &VirtualText)> = self
700 .texts
701 .values()
702 .filter(|vtext| vtext.position.is_inline())
703 .filter_map(|vtext| {
704 let pos = marker_list.get_position(vtext.marker_id)?;
705 if pos >= start && pos < end {
706 Some((pos, vtext))
707 } else {
708 None
709 }
710 })
711 .collect();
712
713 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
715
716 results
717 }
718
719 pub fn build_lines_lookup(
724 &self,
725 marker_list: &MarkerList,
726 start: usize,
727 end: usize,
728 ) -> HashMap<usize, Vec<&VirtualText>> {
729 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
730
731 for vtext in self.texts.values() {
732 if !vtext.position.is_line() {
733 continue;
734 }
735 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
736 if pos >= start && pos < end {
737 lookup.entry(pos).or_default().push(vtext);
738 }
739 }
740 }
741
742 for texts in lookup.values_mut() {
744 texts.sort_by_key(|vt| vt.priority);
745 }
746
747 lookup
748 }
749}
750
751impl Default for VirtualTextManager {
752 fn default() -> Self {
753 Self::new()
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use ratatui::style::Color;
761
762 fn hint_style() -> Style {
763 Style::default().fg(Color::DarkGray)
764 }
765
766 #[test]
767 fn test_new_manager() {
768 let manager = VirtualTextManager::new();
769 assert_eq!(manager.len(), 0);
770 assert!(manager.is_empty());
771 }
772
773 #[test]
774 fn test_add_virtual_text() {
775 let mut marker_list = MarkerList::new();
776 let mut manager = VirtualTextManager::new();
777
778 let id = manager.add(
779 &mut marker_list,
780 10,
781 ": i32".to_string(),
782 hint_style(),
783 VirtualTextPosition::AfterChar,
784 0,
785 );
786
787 assert_eq!(manager.len(), 1);
788 assert!(!manager.is_empty());
789 assert_eq!(id.0, 0);
790 }
791
792 #[test]
793 fn test_remove_virtual_text() {
794 let mut marker_list = MarkerList::new();
795 let mut manager = VirtualTextManager::new();
796
797 let id = manager.add(
798 &mut marker_list,
799 10,
800 ": i32".to_string(),
801 hint_style(),
802 VirtualTextPosition::AfterChar,
803 0,
804 );
805
806 assert_eq!(manager.len(), 1);
807
808 let removed = manager.remove(&mut marker_list, id);
809 assert!(removed);
810 assert_eq!(manager.len(), 0);
811
812 assert_eq!(marker_list.marker_count(), 0);
814 }
815
816 #[test]
817 fn test_remove_nonexistent() {
818 let mut marker_list = MarkerList::new();
819 let mut manager = VirtualTextManager::new();
820
821 let removed = manager.remove(&mut marker_list, VirtualTextId(999));
822 assert!(!removed);
823 }
824
825 #[test]
826 fn test_clear() {
827 let mut marker_list = MarkerList::new();
828 let mut manager = VirtualTextManager::new();
829
830 manager.add(
831 &mut marker_list,
832 10,
833 ": i32".to_string(),
834 hint_style(),
835 VirtualTextPosition::AfterChar,
836 0,
837 );
838 manager.add(
839 &mut marker_list,
840 20,
841 ": String".to_string(),
842 hint_style(),
843 VirtualTextPosition::AfterChar,
844 0,
845 );
846
847 assert_eq!(manager.len(), 2);
848 assert_eq!(marker_list.marker_count(), 2);
849
850 manager.clear(&mut marker_list);
851
852 assert_eq!(manager.len(), 0);
853 assert_eq!(marker_list.marker_count(), 0);
854 }
855
856 #[test]
857 fn test_query_range() {
858 let mut marker_list = MarkerList::new();
859 let mut manager = VirtualTextManager::new();
860
861 manager.add(
863 &mut marker_list,
864 10,
865 ": i32".to_string(),
866 hint_style(),
867 VirtualTextPosition::AfterChar,
868 0,
869 );
870 manager.add(
871 &mut marker_list,
872 20,
873 ": String".to_string(),
874 hint_style(),
875 VirtualTextPosition::AfterChar,
876 0,
877 );
878 manager.add(
879 &mut marker_list,
880 30,
881 ": bool".to_string(),
882 hint_style(),
883 VirtualTextPosition::AfterChar,
884 0,
885 );
886
887 let results = manager.query_range(&marker_list, 15, 35);
889 assert_eq!(results.len(), 2);
890 assert_eq!(results[0].0, 20);
891 assert_eq!(results[0].1.text, ": String");
892 assert_eq!(results[1].0, 30);
893 assert_eq!(results[1].1.text, ": bool");
894
895 let results = manager.query_range(&marker_list, 0, 15);
897 assert_eq!(results.len(), 1);
898 assert_eq!(results[0].0, 10);
899 assert_eq!(results[0].1.text, ": i32");
900 }
901
902 #[test]
903 fn test_query_empty_range() {
904 let mut marker_list = MarkerList::new();
905 let mut manager = VirtualTextManager::new();
906
907 manager.add(
908 &mut marker_list,
909 10,
910 ": i32".to_string(),
911 hint_style(),
912 VirtualTextPosition::AfterChar,
913 0,
914 );
915
916 let results = manager.query_range(&marker_list, 100, 200);
918 assert!(results.is_empty());
919 }
920
921 #[test]
922 fn test_priority_ordering() {
923 let mut marker_list = MarkerList::new();
924 let mut manager = VirtualTextManager::new();
925
926 manager.add(
928 &mut marker_list,
929 10,
930 "low".to_string(),
931 hint_style(),
932 VirtualTextPosition::AfterChar,
933 0,
934 );
935 manager.add(
936 &mut marker_list,
937 10,
938 "high".to_string(),
939 hint_style(),
940 VirtualTextPosition::AfterChar,
941 10,
942 );
943 manager.add(
944 &mut marker_list,
945 10,
946 "medium".to_string(),
947 hint_style(),
948 VirtualTextPosition::AfterChar,
949 5,
950 );
951
952 let results = manager.query_range(&marker_list, 0, 20);
953 assert_eq!(results.len(), 3);
954 assert_eq!(results[0].1.text, "low");
956 assert_eq!(results[1].1.text, "medium");
957 assert_eq!(results[2].1.text, "high");
958 }
959
960 #[test]
961 fn test_build_lookup() {
962 let mut marker_list = MarkerList::new();
963 let mut manager = VirtualTextManager::new();
964
965 manager.add(
966 &mut marker_list,
967 10,
968 ": i32".to_string(),
969 hint_style(),
970 VirtualTextPosition::AfterChar,
971 0,
972 );
973 manager.add(
974 &mut marker_list,
975 10,
976 " = 5".to_string(),
977 hint_style(),
978 VirtualTextPosition::AfterChar,
979 1,
980 );
981 manager.add(
982 &mut marker_list,
983 20,
984 ": String".to_string(),
985 hint_style(),
986 VirtualTextPosition::AfterChar,
987 0,
988 );
989
990 let lookup = manager.build_lookup(&marker_list, 0, 30);
991
992 assert_eq!(lookup.len(), 2); let at_10 = lookup.get(&10).unwrap();
995 assert_eq!(at_10.len(), 2);
996 assert_eq!(at_10[0].text, ": i32"); assert_eq!(at_10[1].text, " = 5"); let at_20 = lookup.get(&20).unwrap();
1000 assert_eq!(at_20.len(), 1);
1001 assert_eq!(at_20[0].text, ": String");
1002 }
1003
1004 #[test]
1005 fn test_position_tracking_after_insert() {
1006 let mut marker_list = MarkerList::new();
1007 let mut manager = VirtualTextManager::new();
1008
1009 manager.add(
1010 &mut marker_list,
1011 10,
1012 ": i32".to_string(),
1013 hint_style(),
1014 VirtualTextPosition::AfterChar,
1015 0,
1016 );
1017
1018 marker_list.adjust_for_insert(5, 5);
1020
1021 let results = manager.query_range(&marker_list, 0, 20);
1023 assert_eq!(results.len(), 1);
1024 assert_eq!(results[0].0, 15);
1025 }
1026
1027 #[test]
1028 fn test_position_tracking_after_delete() {
1029 let mut marker_list = MarkerList::new();
1030 let mut manager = VirtualTextManager::new();
1031
1032 manager.add(
1033 &mut marker_list,
1034 20,
1035 ": i32".to_string(),
1036 hint_style(),
1037 VirtualTextPosition::AfterChar,
1038 0,
1039 );
1040
1041 marker_list.adjust_for_delete(10, 5);
1043
1044 let results = manager.query_range(&marker_list, 0, 20);
1046 assert_eq!(results.len(), 1);
1047 assert_eq!(results[0].0, 15);
1048 }
1049
1050 #[test]
1051 fn test_before_and_after_positions() {
1052 let mut marker_list = MarkerList::new();
1053 let mut manager = VirtualTextManager::new();
1054
1055 manager.add(
1056 &mut marker_list,
1057 10,
1058 "/*param=*/".to_string(),
1059 hint_style(),
1060 VirtualTextPosition::BeforeChar,
1061 0,
1062 );
1063 manager.add(
1064 &mut marker_list,
1065 10,
1066 ": Type".to_string(),
1067 hint_style(),
1068 VirtualTextPosition::AfterChar,
1069 0,
1070 );
1071
1072 let lookup = manager.build_lookup(&marker_list, 0, 20);
1073 let at_10 = lookup.get(&10).unwrap();
1074
1075 assert_eq!(at_10.len(), 2);
1076 let before = at_10
1078 .iter()
1079 .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
1080 let after = at_10
1081 .iter()
1082 .find(|vt| vt.position == VirtualTextPosition::AfterChar);
1083
1084 assert!(before.is_some());
1085 assert!(after.is_some());
1086 assert_eq!(before.unwrap().text, "/*param=*/");
1087 assert_eq!(after.unwrap().text, ": Type");
1088 }
1089}