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::{Color, 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    /// Optional gutter glyph rendered in the line-number column on the
103    /// FIRST visual row of this virtual line. Subsequent wrapped rows
104    /// keep a blank gutter. `None` (the default) renders blank, which
105    /// matches the legacy behaviour. Used by `live_diff` to place "-"
106    /// directly on the deletion line itself instead of the source
107    /// line that happens to follow it.
108    pub gutter_glyph: Option<String>,
109    /// Foreground color for `gutter_glyph`. Falls back to
110    /// `theme.line_number_fg` when `None`.
111    pub gutter_color: Option<Color>,
112}
113
114impl VirtualText {
115    /// Resolve the on-screen `Style` for this entry against a live theme.
116    ///
117    /// Theme keys take precedence over the fallback `style`'s fg/bg.  If a
118    /// key fails to resolve (e.g. the theme doesn't define it), the
119    /// fallback colour is kept.  Modifiers from `style` (bold/italic/etc.)
120    /// always survive.
121    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/// Unique identifier for a virtual text entry
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub struct VirtualTextId(pub u64);
140
141/// Manages virtual text entries for a buffer
142///
143/// Uses the marker system for position tracking, so virtual text automatically
144/// adjusts when the buffer is edited.
145pub struct VirtualTextManager {
146    /// Map from virtual text ID to virtual text entry
147    texts: HashMap<VirtualTextId, VirtualText>,
148    /// Next ID to assign
149    next_id: u64,
150    /// Monotonic version, bumped on every mutation.  Folded into
151    /// `pipeline_inputs_version` so that adding / removing virtual
152    /// lines (e.g. markdown_compose's table borders) invalidates
153    /// `LineWrapCache` / `VisualRowIndex` entries — same mechanism
154    /// `SoftBreakManager` and `ConcealManager` use.
155    version: u32,
156}
157
158impl VirtualTextManager {
159    /// Create a new empty manager
160    pub fn new() -> Self {
161        Self {
162            texts: HashMap::new(),
163            next_id: 0,
164            version: 0,
165        }
166    }
167
168    /// Monotonic version. Increments on every mutation to virtual text
169    /// state. Used by `pipeline_inputs_version` to invalidate scroll-math
170    /// caches keyed off `EditorState`.
171    #[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    /// Add a virtual text entry
182    ///
183    /// # Arguments
184    /// * `marker_list` - The marker list to create a position marker in
185    /// * `position` - Byte offset in the buffer
186    /// * `text` - Text to display
187    /// * `style` - Styling for the text
188    /// * `vtext_position` - Whether to render before or after the character
189    /// * `priority` - Ordering priority (higher = later in render order)
190    ///
191    /// # Returns
192    /// The ID of the created virtual text entry
193    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        // Create marker at position
203        // Use right affinity (false) so the marker stays with the following character
204        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    /// Add an inline virtual text entry whose foreground/background colours
231    /// are stored as theme keys (resolved at render time so theme changes
232    /// apply live).
233    ///
234    /// `style` is the fallback used when a theme key fails to resolve;
235    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
236    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
237    #[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    /// Add a virtual text entry with a string identifier
281    ///
282    /// This is useful for plugins that need to track and remove virtual texts by name.
283    #[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    /// String-id form of [`add_with_theme_keys`] — same as
321    /// [`add_with_id`] but stores theme keys for live theme updates.
322    #[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    /// Add a virtual line (LineAbove or LineBelow) with namespace for bulk removal
366    ///
367    /// This is the primary API for features like git blame headers.
368    ///
369    /// # Arguments
370    /// * `marker_list` - The marker list to create a position marker in
371    /// * `position` - Byte offset in the buffer (anchors the line to this position)
372    /// * `text` - Full line content to display
373    /// * `style` - Styling for the line
374    /// * `placement` - LineAbove or LineBelow
375    /// * `namespace` - Namespace for bulk removal (e.g., "git-blame")
376    /// * `priority` - Ordering when multiple lines at same position
377    #[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    /// Add a virtual line whose foreground/background colours are stored
404    /// as theme keys (resolved at render time so theme changes apply
405    /// live).
406    ///
407    /// `style` is the fallback used when a theme key fails to resolve;
408    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
409    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
410    #[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    /// Remove a virtual text entry by its string identifier
457    pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
458        // Find the entry with matching string_id
459        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    /// Remove all virtual text entries whose string_id starts with the given prefix
485    pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
486        // Collect markers to delete
487        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        // Delete markers and remove entries
501        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    /// Remove a virtual text entry
512    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    /// Clear all virtual text entries
523    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    /// Remove all virtual text entries whose marker position lies within the
535    /// half-open byte range `[start, end)`.
536    ///
537    /// This must be called BEFORE the underlying buffer/marker list is
538    /// adjusted for a deletion, otherwise the affected markers will already
539    /// have been clamped to the deletion start and appear to fall outside
540    /// the range. Used by the editor to drop stale inlay hints whose
541    /// anchors have been erased by the user (a fresh LSP response will
542    /// repopulate them if still applicable).
543    ///
544    /// Returns the number of entries removed.
545    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    /// Get the number of virtual text entries
581    pub fn len(&self) -> usize {
582        self.texts.len()
583    }
584
585    /// Check if there are no virtual text entries
586    pub fn is_empty(&self) -> bool {
587        self.texts.is_empty()
588    }
589
590    /// Query virtual texts in a byte range
591    ///
592    /// Returns a vector of (byte_position, &VirtualText) pairs, sorted by:
593    /// 1. Byte position (ascending)
594    /// 2. Priority (ascending, so higher priority renders later)
595    ///
596    /// # Arguments
597    /// * `marker_list` - The marker list to query positions from
598    /// * `start` - Start byte offset (inclusive)
599    /// * `end` - End byte offset (exclusive)
600    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        // Sort by position, then by priority
620        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    /// Build a lookup map for efficient per-character access during rendering
626    ///
627    /// Returns a HashMap where keys are byte positions and values are vectors
628    /// of virtual texts at that position, sorted by priority.
629    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        // Sort each position's texts by priority
646        for texts in lookup.values_mut() {
647            texts.sort_by_key(|vt| vt.priority);
648        }
649
650        lookup
651    }
652
653    /// Clear all virtual texts in a namespace
654    ///
655    /// This is the primary way plugins remove their virtual texts (e.g., before updating blame data).
656    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    /// Query only virtual LINES (LineAbove/LineBelow) in a byte range
685    ///
686    /// Used by the render pipeline to inject header/footer lines.
687    /// Returns (byte_position, &VirtualText) pairs sorted by position then priority.
688    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        // Sort by position, then by priority
709        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    /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range
715    ///
716    /// Used by the render pipeline to inject inline hints.
717    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        // Sort by position, then by priority
738        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    /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position
744    ///
745    /// For each source line, the renderer can quickly check if there are
746    /// LineAbove or LineBelow virtual texts anchored to positions within that line.
747    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        // Sort each position's texts by priority
767        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        // Marker should also be removed
837        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        // Add three virtual texts at positions 10, 20, 30
886        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        // Query range [15, 35) should return positions 20 and 30
912        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        // Query range [0, 15) should return position 10
920        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        // Query range with no virtual texts
941        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        // Add multiple virtual texts at the same position with different priorities
951        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        // Should be sorted by priority: 0, 5, 10
979        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); // Two unique positions
1017
1018        let at_10 = lookup.get(&10).unwrap();
1019        assert_eq!(at_10.len(), 2);
1020        assert_eq!(at_10[0].text, ": i32"); // priority 0
1021        assert_eq!(at_10[1].text, " = 5"); // priority 1
1022
1023        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        // Insert 5 bytes before position 10
1043        marker_list.adjust_for_insert(5, 5);
1044
1045        // Virtual text should now be at position 15
1046        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        // Delete 5 bytes before position 20 (at position 10)
1066        marker_list.adjust_for_delete(10, 5);
1067
1068        // Virtual text should now be at position 15
1069        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        // Both at same position, check they have different positions
1101        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}