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    /// Monotonic version, bumped on every mutation.  Folded into
141    /// `pipeline_inputs_version` so that adding / removing virtual
142    /// lines (e.g. markdown_compose's table borders) invalidates
143    /// `LineWrapCache` / `VisualRowIndex` entries — same mechanism
144    /// `SoftBreakManager` and `ConcealManager` use.
145    version: u32,
146}
147
148impl VirtualTextManager {
149    /// Create a new empty manager
150    pub fn new() -> Self {
151        Self {
152            texts: HashMap::new(),
153            next_id: 0,
154            version: 0,
155        }
156    }
157
158    /// Monotonic version. Increments on every mutation to virtual text
159    /// state. Used by `pipeline_inputs_version` to invalidate scroll-math
160    /// caches keyed off `EditorState`.
161    #[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    /// Add a virtual text entry
172    ///
173    /// # Arguments
174    /// * `marker_list` - The marker list to create a position marker in
175    /// * `position` - Byte offset in the buffer
176    /// * `text` - Text to display
177    /// * `style` - Styling for the text
178    /// * `vtext_position` - Whether to render before or after the character
179    /// * `priority` - Ordering priority (higher = later in render order)
180    ///
181    /// # Returns
182    /// The ID of the created virtual text entry
183    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        // Create marker at position
193        // Use right affinity (false) so the marker stays with the following character
194        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    /// Add an inline virtual text entry whose foreground/background colours
219    /// are stored as theme keys (resolved at render time so theme changes
220    /// apply live).
221    ///
222    /// `style` is the fallback used when a theme key fails to resolve;
223    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
224    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
225    #[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    /// Add a virtual text entry with a string identifier
267    ///
268    /// This is useful for plugins that need to track and remove virtual texts by name.
269    #[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    /// String-id form of [`add_with_theme_keys`] — same as
305    /// [`add_with_id`] but stores theme keys for live theme updates.
306    #[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    /// Add a virtual line (LineAbove or LineBelow) with namespace for bulk removal
348    ///
349    /// This is the primary API for features like git blame headers.
350    ///
351    /// # Arguments
352    /// * `marker_list` - The marker list to create a position marker in
353    /// * `position` - Byte offset in the buffer (anchors the line to this position)
354    /// * `text` - Full line content to display
355    /// * `style` - Styling for the line
356    /// * `placement` - LineAbove or LineBelow
357    /// * `namespace` - Namespace for bulk removal (e.g., "git-blame")
358    /// * `priority` - Ordering when multiple lines at same position
359    #[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    /// Add a virtual line whose foreground/background colours are stored
384    /// as theme keys (resolved at render time so theme changes apply
385    /// live).
386    ///
387    /// `style` is the fallback used when a theme key fails to resolve;
388    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
389    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
390    #[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    /// Remove a virtual text entry by its string identifier
433    pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
434        // Find the entry with matching string_id
435        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    /// Remove all virtual text entries whose string_id starts with the given prefix
461    pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
462        // Collect markers to delete
463        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        // Delete markers and remove entries
477        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    /// Remove a virtual text entry
488    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    /// Clear all virtual text entries
499    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    /// Remove all virtual text entries whose marker position lies within the
511    /// half-open byte range `[start, end)`.
512    ///
513    /// This must be called BEFORE the underlying buffer/marker list is
514    /// adjusted for a deletion, otherwise the affected markers will already
515    /// have been clamped to the deletion start and appear to fall outside
516    /// the range. Used by the editor to drop stale inlay hints whose
517    /// anchors have been erased by the user (a fresh LSP response will
518    /// repopulate them if still applicable).
519    ///
520    /// Returns the number of entries removed.
521    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    /// Get the number of virtual text entries
557    pub fn len(&self) -> usize {
558        self.texts.len()
559    }
560
561    /// Check if there are no virtual text entries
562    pub fn is_empty(&self) -> bool {
563        self.texts.is_empty()
564    }
565
566    /// Query virtual texts in a byte range
567    ///
568    /// Returns a vector of (byte_position, &VirtualText) pairs, sorted by:
569    /// 1. Byte position (ascending)
570    /// 2. Priority (ascending, so higher priority renders later)
571    ///
572    /// # Arguments
573    /// * `marker_list` - The marker list to query positions from
574    /// * `start` - Start byte offset (inclusive)
575    /// * `end` - End byte offset (exclusive)
576    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        // Sort by position, then by priority
596        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    /// Build a lookup map for efficient per-character access during rendering
602    ///
603    /// Returns a HashMap where keys are byte positions and values are vectors
604    /// of virtual texts at that position, sorted by priority.
605    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        // Sort each position's texts by priority
622        for texts in lookup.values_mut() {
623            texts.sort_by_key(|vt| vt.priority);
624        }
625
626        lookup
627    }
628
629    /// Clear all virtual texts in a namespace
630    ///
631    /// This is the primary way plugins remove their virtual texts (e.g., before updating blame data).
632    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    /// Query only virtual LINES (LineAbove/LineBelow) in a byte range
661    ///
662    /// Used by the render pipeline to inject header/footer lines.
663    /// Returns (byte_position, &VirtualText) pairs sorted by position then priority.
664    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        // Sort by position, then by priority
685        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    /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range
691    ///
692    /// Used by the render pipeline to inject inline hints.
693    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        // Sort by position, then by priority
714        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    /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position
720    ///
721    /// For each source line, the renderer can quickly check if there are
722    /// LineAbove or LineBelow virtual texts anchored to positions within that line.
723    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        // Sort each position's texts by priority
743        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        // Marker should also be removed
813        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        // Add three virtual texts at positions 10, 20, 30
862        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        // Query range [15, 35) should return positions 20 and 30
888        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        // Query range [0, 15) should return position 10
896        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        // Query range with no virtual texts
917        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        // Add multiple virtual texts at the same position with different priorities
927        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        // Should be sorted by priority: 0, 5, 10
955        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); // Two unique positions
993
994        let at_10 = lookup.get(&10).unwrap();
995        assert_eq!(at_10.len(), 2);
996        assert_eq!(at_10[0].text, ": i32"); // priority 0
997        assert_eq!(at_10[1].text, " = 5"); // priority 1
998
999        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        // Insert 5 bytes before position 10
1019        marker_list.adjust_for_insert(5, 5);
1020
1021        // Virtual text should now be at position 15
1022        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        // Delete 5 bytes before position 20 (at position 10)
1042        marker_list.adjust_for_delete(10, 5);
1043
1044        // Virtual text should now be at position 15
1045        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        // Both at same position, check they have different positions
1077        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}