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}
141
142impl VirtualTextManager {
143 pub fn new() -> Self {
145 Self {
146 texts: HashMap::new(),
147 next_id: 0,
148 }
149 }
150
151 pub fn add(
164 &mut self,
165 marker_list: &mut MarkerList,
166 position: usize,
167 text: String,
168 style: Style,
169 vtext_position: VirtualTextPosition,
170 priority: i32,
171 ) -> VirtualTextId {
172 let marker_id = marker_list.create(position, false);
175
176 let id = VirtualTextId(self.next_id);
177 self.next_id += 1;
178
179 self.texts.insert(
180 id,
181 VirtualText {
182 marker_id,
183 text,
184 style,
185 fg_theme_key: None,
186 bg_theme_key: None,
187 position: vtext_position,
188 priority,
189 string_id: None,
190 namespace: None,
191 },
192 );
193
194 id
195 }
196
197 #[allow(clippy::too_many_arguments)]
205 pub fn add_with_theme_keys(
206 &mut self,
207 marker_list: &mut MarkerList,
208 position: usize,
209 text: String,
210 style: Style,
211 fg_theme_key: Option<String>,
212 bg_theme_key: Option<String>,
213 vtext_position: VirtualTextPosition,
214 priority: i32,
215 ) -> VirtualTextId {
216 debug_assert!(
217 vtext_position.is_inline(),
218 "add_with_theme_keys requires BeforeChar or AfterChar"
219 );
220
221 let marker_id = marker_list.create(position, false);
222
223 let id = VirtualTextId(self.next_id);
224 self.next_id += 1;
225
226 self.texts.insert(
227 id,
228 VirtualText {
229 marker_id,
230 text,
231 style,
232 fg_theme_key,
233 bg_theme_key,
234 position: vtext_position,
235 priority,
236 string_id: None,
237 namespace: None,
238 },
239 );
240
241 id
242 }
243
244 #[allow(clippy::too_many_arguments)]
248 pub fn add_with_id(
249 &mut self,
250 marker_list: &mut MarkerList,
251 position: usize,
252 text: String,
253 style: Style,
254 vtext_position: VirtualTextPosition,
255 priority: i32,
256 string_id: String,
257 ) -> VirtualTextId {
258 let marker_id = marker_list.create(position, false);
259
260 let id = VirtualTextId(self.next_id);
261 self.next_id += 1;
262
263 self.texts.insert(
264 id,
265 VirtualText {
266 marker_id,
267 text,
268 style,
269 fg_theme_key: None,
270 bg_theme_key: None,
271 position: vtext_position,
272 priority,
273 string_id: Some(string_id),
274 namespace: None,
275 },
276 );
277
278 id
279 }
280
281 #[allow(clippy::too_many_arguments)]
294 pub fn add_line(
295 &mut self,
296 marker_list: &mut MarkerList,
297 position: usize,
298 text: String,
299 style: Style,
300 placement: VirtualTextPosition,
301 namespace: VirtualTextNamespace,
302 priority: i32,
303 ) -> VirtualTextId {
304 self.add_line_with_theme_keys(
305 marker_list,
306 position,
307 text,
308 style,
309 None,
310 None,
311 placement,
312 namespace,
313 priority,
314 )
315 }
316
317 #[allow(clippy::too_many_arguments)]
325 pub fn add_line_with_theme_keys(
326 &mut self,
327 marker_list: &mut MarkerList,
328 position: usize,
329 text: String,
330 style: Style,
331 fg_theme_key: Option<String>,
332 bg_theme_key: Option<String>,
333 placement: VirtualTextPosition,
334 namespace: VirtualTextNamespace,
335 priority: i32,
336 ) -> VirtualTextId {
337 debug_assert!(
338 placement.is_line(),
339 "add_line requires LineAbove or LineBelow"
340 );
341
342 let marker_id = marker_list.create(position, false);
343
344 let id = VirtualTextId(self.next_id);
345 self.next_id += 1;
346
347 self.texts.insert(
348 id,
349 VirtualText {
350 marker_id,
351 text,
352 style,
353 fg_theme_key,
354 bg_theme_key,
355 position: placement,
356 priority,
357 string_id: None,
358 namespace: Some(namespace),
359 },
360 );
361
362 id
363 }
364
365 pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
367 let to_remove: Vec<VirtualTextId> = self
369 .texts
370 .iter()
371 .filter_map(|(id, vtext)| {
372 if vtext.string_id.as_deref() == Some(string_id) {
373 Some(*id)
374 } else {
375 None
376 }
377 })
378 .collect();
379
380 let mut removed = false;
381 for id in to_remove {
382 if let Some(vtext) = self.texts.remove(&id) {
383 marker_list.delete(vtext.marker_id);
384 removed = true;
385 }
386 }
387 removed
388 }
389
390 pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
392 let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
394 .texts
395 .iter()
396 .filter_map(|(id, vtext)| {
397 if let Some(ref sid) = vtext.string_id {
398 if sid.starts_with(prefix) {
399 return Some((*id, vtext.marker_id));
400 }
401 }
402 None
403 })
404 .collect();
405
406 for (id, marker_id) in markers_to_delete {
408 marker_list.delete(marker_id);
409 self.texts.remove(&id);
410 }
411 }
412
413 pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
415 if let Some(vtext) = self.texts.remove(&id) {
416 marker_list.delete(vtext.marker_id);
417 true
418 } else {
419 false
420 }
421 }
422
423 pub fn clear(&mut self, marker_list: &mut MarkerList) {
425 for vtext in self.texts.values() {
426 marker_list.delete(vtext.marker_id);
427 }
428 self.texts.clear();
429 }
430
431 pub fn remove_in_range(
443 &mut self,
444 marker_list: &mut MarkerList,
445 start: usize,
446 end: usize,
447 ) -> usize {
448 if start >= end {
449 return 0;
450 }
451
452 let to_remove: Vec<VirtualTextId> = self
453 .texts
454 .iter()
455 .filter_map(|(id, vtext)| {
456 let pos = marker_list.get_position(vtext.marker_id)?;
457 if pos >= start && pos < end {
458 Some(*id)
459 } else {
460 None
461 }
462 })
463 .collect();
464
465 let count = to_remove.len();
466 for id in to_remove {
467 if let Some(vtext) = self.texts.remove(&id) {
468 marker_list.delete(vtext.marker_id);
469 }
470 }
471 count
472 }
473
474 pub fn len(&self) -> usize {
476 self.texts.len()
477 }
478
479 pub fn is_empty(&self) -> bool {
481 self.texts.is_empty()
482 }
483
484 pub fn query_range(
495 &self,
496 marker_list: &MarkerList,
497 start: usize,
498 end: usize,
499 ) -> Vec<(usize, &VirtualText)> {
500 let mut results: Vec<(usize, &VirtualText)> = self
501 .texts
502 .values()
503 .filter_map(|vtext| {
504 let pos = marker_list.get_position(vtext.marker_id)?;
505 if pos >= start && pos < end {
506 Some((pos, vtext))
507 } else {
508 None
509 }
510 })
511 .collect();
512
513 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
515
516 results
517 }
518
519 pub fn build_lookup(
524 &self,
525 marker_list: &MarkerList,
526 start: usize,
527 end: usize,
528 ) -> HashMap<usize, Vec<&VirtualText>> {
529 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
530
531 for vtext in self.texts.values() {
532 if let Some(pos) = marker_list.get_position(vtext.marker_id) {
533 if pos >= start && pos < end {
534 lookup.entry(pos).or_default().push(vtext);
535 }
536 }
537 }
538
539 for texts in lookup.values_mut() {
541 texts.sort_by_key(|vt| vt.priority);
542 }
543
544 lookup
545 }
546
547 pub fn clear_namespace(
551 &mut self,
552 marker_list: &mut MarkerList,
553 namespace: &VirtualTextNamespace,
554 ) {
555 let to_remove: Vec<VirtualTextId> = self
556 .texts
557 .iter()
558 .filter_map(|(id, vtext)| {
559 if vtext.namespace.as_ref() == Some(namespace) {
560 Some(*id)
561 } else {
562 None
563 }
564 })
565 .collect();
566
567 for id in to_remove {
568 if let Some(vtext) = self.texts.remove(&id) {
569 marker_list.delete(vtext.marker_id);
570 }
571 }
572 }
573
574 pub fn query_lines_in_range(
579 &self,
580 marker_list: &MarkerList,
581 start: usize,
582 end: usize,
583 ) -> Vec<(usize, &VirtualText)> {
584 let mut results: Vec<(usize, &VirtualText)> = self
585 .texts
586 .values()
587 .filter(|vtext| vtext.position.is_line())
588 .filter_map(|vtext| {
589 let pos = marker_list.get_position(vtext.marker_id)?;
590 if pos >= start && pos < end {
591 Some((pos, vtext))
592 } else {
593 None
594 }
595 })
596 .collect();
597
598 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
600
601 results
602 }
603
604 pub fn query_inline_in_range(
608 &self,
609 marker_list: &MarkerList,
610 start: usize,
611 end: usize,
612 ) -> Vec<(usize, &VirtualText)> {
613 let mut results: Vec<(usize, &VirtualText)> = self
614 .texts
615 .values()
616 .filter(|vtext| vtext.position.is_inline())
617 .filter_map(|vtext| {
618 let pos = marker_list.get_position(vtext.marker_id)?;
619 if pos >= start && pos < end {
620 Some((pos, vtext))
621 } else {
622 None
623 }
624 })
625 .collect();
626
627 results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
629
630 results
631 }
632
633 pub fn build_lines_lookup(
638 &self,
639 marker_list: &MarkerList,
640 start: usize,
641 end: usize,
642 ) -> HashMap<usize, Vec<&VirtualText>> {
643 let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
644
645 for vtext in self.texts.values() {
646 if !vtext.position.is_line() {
647 continue;
648 }
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
665impl Default for VirtualTextManager {
666 fn default() -> Self {
667 Self::new()
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use ratatui::style::Color;
675
676 fn hint_style() -> Style {
677 Style::default().fg(Color::DarkGray)
678 }
679
680 #[test]
681 fn test_new_manager() {
682 let manager = VirtualTextManager::new();
683 assert_eq!(manager.len(), 0);
684 assert!(manager.is_empty());
685 }
686
687 #[test]
688 fn test_add_virtual_text() {
689 let mut marker_list = MarkerList::new();
690 let mut manager = VirtualTextManager::new();
691
692 let id = manager.add(
693 &mut marker_list,
694 10,
695 ": i32".to_string(),
696 hint_style(),
697 VirtualTextPosition::AfterChar,
698 0,
699 );
700
701 assert_eq!(manager.len(), 1);
702 assert!(!manager.is_empty());
703 assert_eq!(id.0, 0);
704 }
705
706 #[test]
707 fn test_remove_virtual_text() {
708 let mut marker_list = MarkerList::new();
709 let mut manager = VirtualTextManager::new();
710
711 let id = manager.add(
712 &mut marker_list,
713 10,
714 ": i32".to_string(),
715 hint_style(),
716 VirtualTextPosition::AfterChar,
717 0,
718 );
719
720 assert_eq!(manager.len(), 1);
721
722 let removed = manager.remove(&mut marker_list, id);
723 assert!(removed);
724 assert_eq!(manager.len(), 0);
725
726 assert_eq!(marker_list.marker_count(), 0);
728 }
729
730 #[test]
731 fn test_remove_nonexistent() {
732 let mut marker_list = MarkerList::new();
733 let mut manager = VirtualTextManager::new();
734
735 let removed = manager.remove(&mut marker_list, VirtualTextId(999));
736 assert!(!removed);
737 }
738
739 #[test]
740 fn test_clear() {
741 let mut marker_list = MarkerList::new();
742 let mut manager = VirtualTextManager::new();
743
744 manager.add(
745 &mut marker_list,
746 10,
747 ": i32".to_string(),
748 hint_style(),
749 VirtualTextPosition::AfterChar,
750 0,
751 );
752 manager.add(
753 &mut marker_list,
754 20,
755 ": String".to_string(),
756 hint_style(),
757 VirtualTextPosition::AfterChar,
758 0,
759 );
760
761 assert_eq!(manager.len(), 2);
762 assert_eq!(marker_list.marker_count(), 2);
763
764 manager.clear(&mut marker_list);
765
766 assert_eq!(manager.len(), 0);
767 assert_eq!(marker_list.marker_count(), 0);
768 }
769
770 #[test]
771 fn test_query_range() {
772 let mut marker_list = MarkerList::new();
773 let mut manager = VirtualTextManager::new();
774
775 manager.add(
777 &mut marker_list,
778 10,
779 ": i32".to_string(),
780 hint_style(),
781 VirtualTextPosition::AfterChar,
782 0,
783 );
784 manager.add(
785 &mut marker_list,
786 20,
787 ": String".to_string(),
788 hint_style(),
789 VirtualTextPosition::AfterChar,
790 0,
791 );
792 manager.add(
793 &mut marker_list,
794 30,
795 ": bool".to_string(),
796 hint_style(),
797 VirtualTextPosition::AfterChar,
798 0,
799 );
800
801 let results = manager.query_range(&marker_list, 15, 35);
803 assert_eq!(results.len(), 2);
804 assert_eq!(results[0].0, 20);
805 assert_eq!(results[0].1.text, ": String");
806 assert_eq!(results[1].0, 30);
807 assert_eq!(results[1].1.text, ": bool");
808
809 let results = manager.query_range(&marker_list, 0, 15);
811 assert_eq!(results.len(), 1);
812 assert_eq!(results[0].0, 10);
813 assert_eq!(results[0].1.text, ": i32");
814 }
815
816 #[test]
817 fn test_query_empty_range() {
818 let mut marker_list = MarkerList::new();
819 let mut manager = VirtualTextManager::new();
820
821 manager.add(
822 &mut marker_list,
823 10,
824 ": i32".to_string(),
825 hint_style(),
826 VirtualTextPosition::AfterChar,
827 0,
828 );
829
830 let results = manager.query_range(&marker_list, 100, 200);
832 assert!(results.is_empty());
833 }
834
835 #[test]
836 fn test_priority_ordering() {
837 let mut marker_list = MarkerList::new();
838 let mut manager = VirtualTextManager::new();
839
840 manager.add(
842 &mut marker_list,
843 10,
844 "low".to_string(),
845 hint_style(),
846 VirtualTextPosition::AfterChar,
847 0,
848 );
849 manager.add(
850 &mut marker_list,
851 10,
852 "high".to_string(),
853 hint_style(),
854 VirtualTextPosition::AfterChar,
855 10,
856 );
857 manager.add(
858 &mut marker_list,
859 10,
860 "medium".to_string(),
861 hint_style(),
862 VirtualTextPosition::AfterChar,
863 5,
864 );
865
866 let results = manager.query_range(&marker_list, 0, 20);
867 assert_eq!(results.len(), 3);
868 assert_eq!(results[0].1.text, "low");
870 assert_eq!(results[1].1.text, "medium");
871 assert_eq!(results[2].1.text, "high");
872 }
873
874 #[test]
875 fn test_build_lookup() {
876 let mut marker_list = MarkerList::new();
877 let mut manager = VirtualTextManager::new();
878
879 manager.add(
880 &mut marker_list,
881 10,
882 ": i32".to_string(),
883 hint_style(),
884 VirtualTextPosition::AfterChar,
885 0,
886 );
887 manager.add(
888 &mut marker_list,
889 10,
890 " = 5".to_string(),
891 hint_style(),
892 VirtualTextPosition::AfterChar,
893 1,
894 );
895 manager.add(
896 &mut marker_list,
897 20,
898 ": String".to_string(),
899 hint_style(),
900 VirtualTextPosition::AfterChar,
901 0,
902 );
903
904 let lookup = manager.build_lookup(&marker_list, 0, 30);
905
906 assert_eq!(lookup.len(), 2); let at_10 = lookup.get(&10).unwrap();
909 assert_eq!(at_10.len(), 2);
910 assert_eq!(at_10[0].text, ": i32"); assert_eq!(at_10[1].text, " = 5"); let at_20 = lookup.get(&20).unwrap();
914 assert_eq!(at_20.len(), 1);
915 assert_eq!(at_20[0].text, ": String");
916 }
917
918 #[test]
919 fn test_position_tracking_after_insert() {
920 let mut marker_list = MarkerList::new();
921 let mut manager = VirtualTextManager::new();
922
923 manager.add(
924 &mut marker_list,
925 10,
926 ": i32".to_string(),
927 hint_style(),
928 VirtualTextPosition::AfterChar,
929 0,
930 );
931
932 marker_list.adjust_for_insert(5, 5);
934
935 let results = manager.query_range(&marker_list, 0, 20);
937 assert_eq!(results.len(), 1);
938 assert_eq!(results[0].0, 15);
939 }
940
941 #[test]
942 fn test_position_tracking_after_delete() {
943 let mut marker_list = MarkerList::new();
944 let mut manager = VirtualTextManager::new();
945
946 manager.add(
947 &mut marker_list,
948 20,
949 ": i32".to_string(),
950 hint_style(),
951 VirtualTextPosition::AfterChar,
952 0,
953 );
954
955 marker_list.adjust_for_delete(10, 5);
957
958 let results = manager.query_range(&marker_list, 0, 20);
960 assert_eq!(results.len(), 1);
961 assert_eq!(results[0].0, 15);
962 }
963
964 #[test]
965 fn test_before_and_after_positions() {
966 let mut marker_list = MarkerList::new();
967 let mut manager = VirtualTextManager::new();
968
969 manager.add(
970 &mut marker_list,
971 10,
972 "/*param=*/".to_string(),
973 hint_style(),
974 VirtualTextPosition::BeforeChar,
975 0,
976 );
977 manager.add(
978 &mut marker_list,
979 10,
980 ": Type".to_string(),
981 hint_style(),
982 VirtualTextPosition::AfterChar,
983 0,
984 );
985
986 let lookup = manager.build_lookup(&marker_list, 0, 20);
987 let at_10 = lookup.get(&10).unwrap();
988
989 assert_eq!(at_10.len(), 2);
990 let before = at_10
992 .iter()
993 .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
994 let after = at_10
995 .iter()
996 .find(|vt| vt.position == VirtualTextPosition::AfterChar);
997
998 assert!(before.is_some());
999 assert!(after.is_some());
1000 assert_eq!(before.unwrap().text, "/*param=*/");
1001 assert_eq!(after.unwrap().text, ": Type");
1002 }
1003}