Skip to main content

fresh/view/
virtual_text.rs

1//! Virtual text rendering infrastructure
2//!
3//! Provides a system for rendering virtual text that doesn't exist in the buffer.
4//! Used for inlay hints (type annotations, parameter names), git blame headers, etc.
5//!
6//! Two types of virtual text are supported:
7//! - **Inline**: Text inserted before/after a character (e.g., `: i32` type hints)
8//! - **Line**: Full lines inserted above/below a position (e.g., git blame headers)
9//!
10//! Virtual text is rendered during the render phase by reading from VirtualTextManager.
11//! The buffer content remains unchanged - we just inject extra styled text during rendering.
12//!
13//! ## Architecture
14//!
15//! This follows an Emacs-like model where:
16//! 1. Plugins add virtual text in response to buffer changes (async, fire-and-forget)
17//! 2. Virtual text is stored persistently with marker-based position tracking
18//! 3. Render loop reads virtual text synchronously from memory (no async waiting)
19//!
20//! This ensures frame coherence: render always sees a consistent snapshot of virtual text.
21
22use ratatui::style::Style;
23use std::collections::HashMap;
24
25use crate::model::marker::{MarkerId, MarkerList};
26
27/// Position relative to the character at the marker position
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum VirtualTextPosition {
30    // ─── Inline positions (within a line) ───
31    /// Render before the character (e.g., parameter hints: `/*count=*/5`)
32    BeforeChar,
33    /// Render after the character (e.g., type hints: `x: i32`)
34    AfterChar,
35
36    // ─── Line positions (full lines) ───
37    /// Render as a full line ABOVE the line containing this position
38    /// Used for git blame headers, section separators, etc.
39    /// These lines do NOT get line numbers in the gutter.
40    LineAbove,
41    /// Render as a full line BELOW the line containing this position
42    /// Used for inline documentation, fold previews, etc.
43    /// These lines do NOT get line numbers in the gutter.
44    LineBelow,
45}
46
47impl VirtualTextPosition {
48    /// Returns true if this is a line-level position (LineAbove/LineBelow)
49    pub fn is_line(&self) -> bool {
50        matches!(self, Self::LineAbove | Self::LineBelow)
51    }
52
53    /// Returns true if this is an inline position (BeforeChar/AfterChar)
54    pub fn is_inline(&self) -> bool {
55        matches!(self, Self::BeforeChar | Self::AfterChar)
56    }
57}
58
59/// Namespace for grouping virtual texts (for efficient bulk removal).
60/// Similar to OverlayNamespace - plugins create a namespace once and use it for all their virtual texts.
61#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62pub struct VirtualTextNamespace(pub String);
63
64impl VirtualTextNamespace {
65    /// Create a namespace from a string (for plugin registration)
66    pub fn from_string(s: String) -> Self {
67        Self(s)
68    }
69
70    /// Get the internal string representation
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76/// A piece of virtual text to render at a specific position
77#[derive(Debug, Clone)]
78pub struct VirtualText {
79    /// Marker tracking the position (auto-adjusts on edits)
80    pub marker_id: MarkerId,
81    /// Text to display (for LineAbove/LineBelow, this is the full line content)
82    pub text: String,
83    /// Fallback styling, used when the theme-key fields below are unset OR
84    /// the keys don't resolve in the active theme.  The renderer composes
85    /// the final style by overlaying any resolved theme colours on top of
86    /// this fallback (see [`VirtualText::resolved_style`]).
87    pub style: Style,
88    /// Optional theme key for the foreground colour (e.g.
89    /// `"editor.line_number_fg"`).  Resolved on every render so the line
90    /// follows live theme changes.
91    pub fg_theme_key: Option<String>,
92    /// Optional theme key for the background colour.
93    pub bg_theme_key: Option<String>,
94    /// Where to render relative to the marker position
95    pub position: VirtualTextPosition,
96    /// Priority for ordering multiple items at same position (higher = later)
97    pub priority: i32,
98    /// Optional string identifier for this virtual text (for plugin use)
99    pub string_id: Option<String>,
100    /// Optional namespace for bulk removal (like Overlay's namespace)
101    pub namespace: Option<VirtualTextNamespace>,
102}
103
104impl VirtualText {
105    /// Resolve the on-screen `Style` for this entry against a live theme.
106    ///
107    /// Theme keys take precedence over the fallback `style`'s fg/bg.  If a
108    /// key fails to resolve (e.g. the theme doesn't define it), the
109    /// fallback colour is kept.  Modifiers from `style` (bold/italic/etc.)
110    /// always survive.
111    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/// Unique identifier for a virtual text entry
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub struct VirtualTextId(pub u64);
130
131/// Manages virtual text entries for a buffer
132///
133/// Uses the marker system for position tracking, so virtual text automatically
134/// adjusts when the buffer is edited.
135pub struct VirtualTextManager {
136    /// Map from virtual text ID to virtual text entry
137    texts: HashMap<VirtualTextId, VirtualText>,
138    /// Next ID to assign
139    next_id: u64,
140}
141
142impl VirtualTextManager {
143    /// Create a new empty manager
144    pub fn new() -> Self {
145        Self {
146            texts: HashMap::new(),
147            next_id: 0,
148        }
149    }
150
151    /// Add a virtual text entry
152    ///
153    /// # Arguments
154    /// * `marker_list` - The marker list to create a position marker in
155    /// * `position` - Byte offset in the buffer
156    /// * `text` - Text to display
157    /// * `style` - Styling for the text
158    /// * `vtext_position` - Whether to render before or after the character
159    /// * `priority` - Ordering priority (higher = later in render order)
160    ///
161    /// # Returns
162    /// The ID of the created virtual text entry
163    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        // Create marker at position
173        // Use right affinity (false) so the marker stays with the following character
174        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    /// Add an inline virtual text entry whose foreground/background colours
198    /// are stored as theme keys (resolved at render time so theme changes
199    /// apply live).
200    ///
201    /// `style` is the fallback used when a theme key fails to resolve;
202    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
203    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
204    #[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    /// Add a virtual text entry with a string identifier
245    ///
246    /// This is useful for plugins that need to track and remove virtual texts by name.
247    #[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    /// Add a virtual line (LineAbove or LineBelow) with namespace for bulk removal
282    ///
283    /// This is the primary API for features like git blame headers.
284    ///
285    /// # Arguments
286    /// * `marker_list` - The marker list to create a position marker in
287    /// * `position` - Byte offset in the buffer (anchors the line to this position)
288    /// * `text` - Full line content to display
289    /// * `style` - Styling for the line
290    /// * `placement` - LineAbove or LineBelow
291    /// * `namespace` - Namespace for bulk removal (e.g., "git-blame")
292    /// * `priority` - Ordering when multiple lines at same position
293    #[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    /// Add a virtual line whose foreground/background colours are stored
318    /// as theme keys (resolved at render time so theme changes apply
319    /// live).
320    ///
321    /// `style` is the fallback used when a theme key fails to resolve;
322    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
323    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
324    #[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    /// Remove a virtual text entry by its string identifier
366    pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
367        // Find the entry with matching string_id
368        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    /// Remove all virtual text entries whose string_id starts with the given prefix
391    pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
392        // Collect markers to delete
393        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        // Delete markers and remove entries
407        for (id, marker_id) in markers_to_delete {
408            marker_list.delete(marker_id);
409            self.texts.remove(&id);
410        }
411    }
412
413    /// Remove a virtual text entry
414    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    /// Clear all virtual text entries
424    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    /// Remove all virtual text entries whose marker position lies within the
432    /// half-open byte range `[start, end)`.
433    ///
434    /// This must be called BEFORE the underlying buffer/marker list is
435    /// adjusted for a deletion, otherwise the affected markers will already
436    /// have been clamped to the deletion start and appear to fall outside
437    /// the range. Used by the editor to drop stale inlay hints whose
438    /// anchors have been erased by the user (a fresh LSP response will
439    /// repopulate them if still applicable).
440    ///
441    /// Returns the number of entries removed.
442    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    /// Get the number of virtual text entries
475    pub fn len(&self) -> usize {
476        self.texts.len()
477    }
478
479    /// Check if there are no virtual text entries
480    pub fn is_empty(&self) -> bool {
481        self.texts.is_empty()
482    }
483
484    /// Query virtual texts in a byte range
485    ///
486    /// Returns a vector of (byte_position, &VirtualText) pairs, sorted by:
487    /// 1. Byte position (ascending)
488    /// 2. Priority (ascending, so higher priority renders later)
489    ///
490    /// # Arguments
491    /// * `marker_list` - The marker list to query positions from
492    /// * `start` - Start byte offset (inclusive)
493    /// * `end` - End byte offset (exclusive)
494    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        // Sort by position, then by priority
514        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    /// Build a lookup map for efficient per-character access during rendering
520    ///
521    /// Returns a HashMap where keys are byte positions and values are vectors
522    /// of virtual texts at that position, sorted by priority.
523    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        // Sort each position's texts by priority
540        for texts in lookup.values_mut() {
541            texts.sort_by_key(|vt| vt.priority);
542        }
543
544        lookup
545    }
546
547    /// Clear all virtual texts in a namespace
548    ///
549    /// This is the primary way plugins remove their virtual texts (e.g., before updating blame data).
550    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    /// Query only virtual LINES (LineAbove/LineBelow) in a byte range
575    ///
576    /// Used by the render pipeline to inject header/footer lines.
577    /// Returns (byte_position, &VirtualText) pairs sorted by position then priority.
578    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        // Sort by position, then by priority
599        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    /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range
605    ///
606    /// Used by the render pipeline to inject inline hints.
607    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        // Sort by position, then by priority
628        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    /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position
634    ///
635    /// For each source line, the renderer can quickly check if there are
636    /// LineAbove or LineBelow virtual texts anchored to positions within that line.
637    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        // Sort each position's texts by priority
657        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        // Marker should also be removed
727        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        // Add three virtual texts at positions 10, 20, 30
776        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        // Query range [15, 35) should return positions 20 and 30
802        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        // Query range [0, 15) should return position 10
810        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        // Query range with no virtual texts
831        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        // Add multiple virtual texts at the same position with different priorities
841        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        // Should be sorted by priority: 0, 5, 10
869        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); // Two unique positions
907
908        let at_10 = lookup.get(&10).unwrap();
909        assert_eq!(at_10.len(), 2);
910        assert_eq!(at_10[0].text, ": i32"); // priority 0
911        assert_eq!(at_10[1].text, " = 5"); // priority 1
912
913        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        // Insert 5 bytes before position 10
933        marker_list.adjust_for_insert(5, 5);
934
935        // Virtual text should now be at position 15
936        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        // Delete 5 bytes before position 20 (at position 10)
956        marker_list.adjust_for_delete(10, 5);
957
958        // Virtual text should now be at position 15
959        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        // Both at same position, check they have different positions
991        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}