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    /// Per-range modifier overlays applied on top of the base fg/bg.
113    /// Offsets are byte offsets within `text`. Used e.g. by live-diff
114    /// to bold + underline removed words on deletion virtual lines.
115    pub text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay>,
116}
117
118impl VirtualText {
119    /// Resolve the on-screen `Style` for this entry against a live theme.
120    ///
121    /// Theme keys take precedence over the fallback `style`'s fg/bg.  If a
122    /// key fails to resolve (e.g. the theme doesn't define it), the
123    /// fallback colour is kept.  Modifiers from `style` (bold/italic/etc.)
124    /// always survive.
125    pub fn resolved_style(&self, theme: &crate::view::theme::Theme) -> Style {
126        let mut style = self.style;
127        if let Some(ref key) = self.fg_theme_key {
128            if let Some(color) = theme.resolve_theme_key(key) {
129                style = style.fg(color);
130            }
131        }
132        if let Some(ref key) = self.bg_theme_key {
133            if let Some(color) = theme.resolve_theme_key(key) {
134                style = style.bg(color);
135            }
136        }
137        style
138    }
139}
140
141/// Unique identifier for a virtual text entry
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143pub struct VirtualTextId(pub u64);
144
145/// Manages virtual text entries for a buffer
146///
147/// Uses the marker system for position tracking, so virtual text automatically
148/// adjusts when the buffer is edited.
149pub struct VirtualTextManager {
150    /// Map from virtual text ID to virtual text entry
151    texts: HashMap<VirtualTextId, VirtualText>,
152    /// Next ID to assign
153    next_id: u64,
154    /// Monotonic version, bumped on every mutation.  Folded into
155    /// `pipeline_inputs_version` so that adding / removing virtual
156    /// lines (e.g. markdown_compose's table borders) invalidates
157    /// `LineWrapCache` / `VisualRowIndex` entries — same mechanism
158    /// `SoftBreakManager` and `ConcealManager` use.
159    version: u32,
160}
161
162impl VirtualTextManager {
163    /// Create a new empty manager
164    pub fn new() -> Self {
165        Self {
166            texts: HashMap::new(),
167            next_id: 0,
168            version: 0,
169        }
170    }
171
172    /// Monotonic version. Increments on every mutation to virtual text
173    /// state. Used by `pipeline_inputs_version` to invalidate scroll-math
174    /// caches keyed off `EditorState`.
175    #[inline]
176    /// MarkerId backing the given virtual text, or `None` if the id is unknown.
177    /// Callers pair this with `MarkerList::get_position` to read the current
178    /// buffer position after edits have moved the marker — used by async-paste
179    /// to look up where the placeholder ended up so the pasted text lands
180    /// there, not where the cursor was at Ctrl+V time.
181    pub fn marker_id_of(&self, id: VirtualTextId) -> Option<MarkerId> {
182        self.texts.get(&id).map(|vt| vt.marker_id)
183    }
184
185    pub fn version(&self) -> u32 {
186        self.version
187    }
188
189    #[inline]
190    fn bump_version(&mut self) {
191        self.version = self.version.wrapping_add(1);
192    }
193
194    /// Add a virtual text entry
195    ///
196    /// # Arguments
197    /// * `marker_list` - The marker list to create a position marker in
198    /// * `position` - Byte offset in the buffer
199    /// * `text` - Text to display
200    /// * `style` - Styling for the text
201    /// * `vtext_position` - Whether to render before or after the character
202    /// * `priority` - Ordering priority (higher = later in render order)
203    ///
204    /// # Returns
205    /// The ID of the created virtual text entry
206    pub fn add(
207        &mut self,
208        marker_list: &mut MarkerList,
209        position: usize,
210        text: String,
211        style: Style,
212        vtext_position: VirtualTextPosition,
213        priority: i32,
214    ) -> VirtualTextId {
215        // Create marker at position
216        // Use right affinity (false) so the marker stays with the following character
217        let marker_id = marker_list.create(position, false);
218
219        let id = VirtualTextId(self.next_id);
220        self.next_id += 1;
221
222        self.texts.insert(
223            id,
224            VirtualText {
225                marker_id,
226                text,
227                style,
228                fg_theme_key: None,
229                bg_theme_key: None,
230                position: vtext_position,
231                priority,
232                string_id: None,
233                namespace: None,
234                gutter_glyph: None,
235                gutter_color: None,
236                text_overlays: Vec::new(),
237            },
238        );
239        self.bump_version();
240
241        id
242    }
243
244    /// Add an inline virtual text entry whose foreground/background colours
245    /// are stored as theme keys (resolved at render time so theme changes
246    /// apply live).
247    ///
248    /// `style` is the fallback used when a theme key fails to resolve;
249    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
250    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
251    #[allow(clippy::too_many_arguments)]
252    pub fn add_with_theme_keys(
253        &mut self,
254        marker_list: &mut MarkerList,
255        position: usize,
256        text: String,
257        style: Style,
258        fg_theme_key: Option<String>,
259        bg_theme_key: Option<String>,
260        vtext_position: VirtualTextPosition,
261        priority: i32,
262    ) -> VirtualTextId {
263        debug_assert!(
264            vtext_position.is_inline(),
265            "add_with_theme_keys requires BeforeChar or AfterChar"
266        );
267
268        let marker_id = marker_list.create(position, false);
269
270        let id = VirtualTextId(self.next_id);
271        self.next_id += 1;
272
273        self.texts.insert(
274            id,
275            VirtualText {
276                marker_id,
277                text,
278                style,
279                fg_theme_key,
280                bg_theme_key,
281                position: vtext_position,
282                priority,
283                string_id: None,
284                namespace: None,
285                gutter_glyph: None,
286                gutter_color: None,
287                text_overlays: Vec::new(),
288            },
289        );
290        self.bump_version();
291
292        id
293    }
294
295    /// Add a virtual text entry with a string identifier
296    ///
297    /// This is useful for plugins that need to track and remove virtual texts by name.
298    #[allow(clippy::too_many_arguments)]
299    pub fn add_with_id(
300        &mut self,
301        marker_list: &mut MarkerList,
302        position: usize,
303        text: String,
304        style: Style,
305        vtext_position: VirtualTextPosition,
306        priority: i32,
307        string_id: String,
308    ) -> VirtualTextId {
309        let marker_id = marker_list.create(position, false);
310
311        let id = VirtualTextId(self.next_id);
312        self.next_id += 1;
313
314        self.texts.insert(
315            id,
316            VirtualText {
317                marker_id,
318                text,
319                style,
320                fg_theme_key: None,
321                bg_theme_key: None,
322                position: vtext_position,
323                priority,
324                string_id: Some(string_id),
325                namespace: None,
326                gutter_glyph: None,
327                gutter_color: None,
328                text_overlays: Vec::new(),
329            },
330        );
331        self.bump_version();
332
333        id
334    }
335
336    /// String-id form of [`add_with_theme_keys`] — same as
337    /// [`add_with_id`] but stores theme keys for live theme updates.
338    #[allow(clippy::too_many_arguments)]
339    pub fn add_with_id_and_theme_keys(
340        &mut self,
341        marker_list: &mut MarkerList,
342        position: usize,
343        text: String,
344        style: Style,
345        fg_theme_key: Option<String>,
346        bg_theme_key: Option<String>,
347        vtext_position: VirtualTextPosition,
348        priority: i32,
349        string_id: String,
350    ) -> VirtualTextId {
351        debug_assert!(
352            vtext_position.is_inline(),
353            "add_with_id_and_theme_keys requires BeforeChar or AfterChar"
354        );
355
356        let marker_id = marker_list.create(position, false);
357
358        let id = VirtualTextId(self.next_id);
359        self.next_id += 1;
360
361        self.texts.insert(
362            id,
363            VirtualText {
364                marker_id,
365                text,
366                style,
367                fg_theme_key,
368                bg_theme_key,
369                position: vtext_position,
370                priority,
371                string_id: Some(string_id),
372                namespace: None,
373                gutter_glyph: None,
374                gutter_color: None,
375                text_overlays: Vec::new(),
376            },
377        );
378
379        id
380    }
381
382    /// Add a virtual line (LineAbove or LineBelow) with namespace for bulk removal
383    ///
384    /// This is the primary API for features like git blame headers.
385    ///
386    /// # Arguments
387    /// * `marker_list` - The marker list to create a position marker in
388    /// * `position` - Byte offset in the buffer (anchors the line to this position)
389    /// * `text` - Full line content to display
390    /// * `style` - Styling for the line
391    /// * `placement` - LineAbove or LineBelow
392    /// * `namespace` - Namespace for bulk removal (e.g., "git-blame")
393    /// * `priority` - Ordering when multiple lines at same position
394    #[allow(clippy::too_many_arguments)]
395    pub fn add_line(
396        &mut self,
397        marker_list: &mut MarkerList,
398        position: usize,
399        text: String,
400        style: Style,
401        placement: VirtualTextPosition,
402        namespace: VirtualTextNamespace,
403        priority: i32,
404    ) -> VirtualTextId {
405        self.add_line_with_theme_keys(
406            marker_list,
407            position,
408            text,
409            style,
410            None,
411            None,
412            placement,
413            namespace,
414            priority,
415            None,
416            None,
417            Vec::new(),
418        )
419    }
420
421    /// Add a virtual line whose foreground/background colours are stored
422    /// as theme keys (resolved at render time so theme changes apply
423    /// live).
424    ///
425    /// `style` is the fallback used when a theme key fails to resolve;
426    /// `fg_theme_key` / `bg_theme_key` are the keys passed to
427    /// `Theme::resolve_theme_key` (e.g. `"editor.line_number_fg"`).
428    #[allow(clippy::too_many_arguments)]
429    pub fn add_line_with_theme_keys(
430        &mut self,
431        marker_list: &mut MarkerList,
432        position: usize,
433        text: String,
434        style: Style,
435        fg_theme_key: Option<String>,
436        bg_theme_key: Option<String>,
437        placement: VirtualTextPosition,
438        namespace: VirtualTextNamespace,
439        priority: i32,
440        gutter_glyph: Option<String>,
441        gutter_color: Option<Color>,
442        text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay>,
443    ) -> VirtualTextId {
444        debug_assert!(
445            placement.is_line(),
446            "add_line requires LineAbove or LineBelow"
447        );
448
449        let marker_id = marker_list.create(position, false);
450
451        let id = VirtualTextId(self.next_id);
452        self.next_id += 1;
453
454        self.texts.insert(
455            id,
456            VirtualText {
457                marker_id,
458                text,
459                style,
460                fg_theme_key,
461                bg_theme_key,
462                position: placement,
463                priority,
464                string_id: None,
465                namespace: Some(namespace),
466                gutter_glyph,
467                gutter_color,
468                text_overlays,
469            },
470        );
471        self.bump_version();
472
473        id
474    }
475
476    /// Remove a virtual text entry by its string identifier
477    pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
478        // Find the entry with matching string_id
479        let to_remove: Vec<VirtualTextId> = self
480            .texts
481            .iter()
482            .filter_map(|(id, vtext)| {
483                if vtext.string_id.as_deref() == Some(string_id) {
484                    Some(*id)
485                } else {
486                    None
487                }
488            })
489            .collect();
490
491        let mut removed = false;
492        for id in to_remove {
493            if let Some(vtext) = self.texts.remove(&id) {
494                marker_list.delete(vtext.marker_id);
495                removed = true;
496            }
497        }
498        if removed {
499            self.bump_version();
500        }
501        removed
502    }
503
504    /// Remove all virtual text entries whose string_id starts with the given prefix
505    pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
506        // Collect markers to delete
507        let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
508            .texts
509            .iter()
510            .filter_map(|(id, vtext)| {
511                if let Some(ref sid) = vtext.string_id {
512                    if sid.starts_with(prefix) {
513                        return Some((*id, vtext.marker_id));
514                    }
515                }
516                None
517            })
518            .collect();
519
520        // Delete markers and remove entries
521        let removed = !markers_to_delete.is_empty();
522        for (id, marker_id) in markers_to_delete {
523            marker_list.delete(marker_id);
524            self.texts.remove(&id);
525        }
526        if removed {
527            self.bump_version();
528        }
529    }
530
531    /// Remove a virtual text entry
532    pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
533        if let Some(vtext) = self.texts.remove(&id) {
534            marker_list.delete(vtext.marker_id);
535            self.bump_version();
536            true
537        } else {
538            false
539        }
540    }
541
542    /// Clear all virtual text entries
543    pub fn clear(&mut self, marker_list: &mut MarkerList) {
544        let was_non_empty = !self.texts.is_empty();
545        for vtext in self.texts.values() {
546            marker_list.delete(vtext.marker_id);
547        }
548        self.texts.clear();
549        if was_non_empty {
550            self.bump_version();
551        }
552    }
553
554    /// Remove all virtual text entries whose marker position lies within the
555    /// half-open byte range `[start, end)`.
556    ///
557    /// This must be called BEFORE the underlying buffer/marker list is
558    /// adjusted for a deletion, otherwise the affected markers will already
559    /// have been clamped to the deletion start and appear to fall outside
560    /// the range. Used by the editor to drop stale inlay hints whose
561    /// anchors have been erased by the user (a fresh LSP response will
562    /// repopulate them if still applicable).
563    ///
564    /// Returns the number of entries removed.
565    pub fn remove_in_range(
566        &mut self,
567        marker_list: &mut MarkerList,
568        start: usize,
569        end: usize,
570    ) -> usize {
571        if start >= end {
572            return 0;
573        }
574
575        let to_remove: Vec<VirtualTextId> = self
576            .texts
577            .iter()
578            .filter_map(|(id, vtext)| {
579                let pos = marker_list.get_position(vtext.marker_id)?;
580                if pos >= start && pos < end {
581                    Some(*id)
582                } else {
583                    None
584                }
585            })
586            .collect();
587
588        let count = to_remove.len();
589        for id in to_remove {
590            if let Some(vtext) = self.texts.remove(&id) {
591                marker_list.delete(vtext.marker_id);
592            }
593        }
594        if count > 0 {
595            self.bump_version();
596        }
597        count
598    }
599
600    /// Get the number of virtual text entries
601    pub fn len(&self) -> usize {
602        self.texts.len()
603    }
604
605    /// Check if there are no virtual text entries
606    pub fn is_empty(&self) -> bool {
607        self.texts.is_empty()
608    }
609
610    /// Query virtual texts in a byte range
611    ///
612    /// Returns a vector of (byte_position, &VirtualText) pairs, sorted by:
613    /// 1. Byte position (ascending)
614    /// 2. Priority (ascending, so higher priority renders later)
615    ///
616    /// # Arguments
617    /// * `marker_list` - The marker list to query positions from
618    /// * `start` - Start byte offset (inclusive)
619    /// * `end` - End byte offset (exclusive)
620    pub fn query_range(
621        &self,
622        marker_list: &MarkerList,
623        start: usize,
624        end: usize,
625    ) -> Vec<(usize, &VirtualText)> {
626        let mut results: Vec<(usize, &VirtualText)> = self
627            .texts
628            .values()
629            .filter_map(|vtext| {
630                let pos = marker_list.get_position(vtext.marker_id)?;
631                if pos >= start && pos < end {
632                    Some((pos, vtext))
633                } else {
634                    None
635                }
636            })
637            .collect();
638
639        // Sort by position, then by priority
640        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
641
642        results
643    }
644
645    /// Build a lookup map for efficient per-character access during rendering
646    ///
647    /// Returns a HashMap where keys are byte positions and values are vectors
648    /// of virtual texts at that position, sorted by priority.
649    pub fn build_lookup(
650        &self,
651        marker_list: &MarkerList,
652        start: usize,
653        end: usize,
654    ) -> HashMap<usize, Vec<&VirtualText>> {
655        let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
656
657        for vtext in self.texts.values() {
658            if let Some(pos) = marker_list.get_position(vtext.marker_id) {
659                if pos >= start && pos < end {
660                    lookup.entry(pos).or_default().push(vtext);
661                }
662            }
663        }
664
665        // Sort each position's texts by priority
666        for texts in lookup.values_mut() {
667            texts.sort_by_key(|vt| vt.priority);
668        }
669
670        lookup
671    }
672
673    /// Clear all virtual texts in a namespace
674    ///
675    /// This is the primary way plugins remove their virtual texts (e.g., before updating blame data).
676    pub fn clear_namespace(
677        &mut self,
678        marker_list: &mut MarkerList,
679        namespace: &VirtualTextNamespace,
680    ) {
681        let to_remove: Vec<VirtualTextId> = self
682            .texts
683            .iter()
684            .filter_map(|(id, vtext)| {
685                if vtext.namespace.as_ref() == Some(namespace) {
686                    Some(*id)
687                } else {
688                    None
689                }
690            })
691            .collect();
692
693        let removed = !to_remove.is_empty();
694        for id in to_remove {
695            if let Some(vtext) = self.texts.remove(&id) {
696                marker_list.delete(vtext.marker_id);
697            }
698        }
699        if removed {
700            self.bump_version();
701        }
702    }
703
704    /// Query only virtual LINES (LineAbove/LineBelow) in a byte range
705    ///
706    /// Used by the render pipeline to inject header/footer lines.
707    /// Returns (byte_position, &VirtualText) pairs sorted by position then priority.
708    pub fn query_lines_in_range(
709        &self,
710        marker_list: &MarkerList,
711        start: usize,
712        end: usize,
713    ) -> Vec<(usize, &VirtualText)> {
714        let mut results: Vec<(usize, &VirtualText)> = self
715            .texts
716            .values()
717            .filter(|vtext| vtext.position.is_line())
718            .filter_map(|vtext| {
719                let pos = marker_list.get_position(vtext.marker_id)?;
720                if pos >= start && pos < end {
721                    Some((pos, vtext))
722                } else {
723                    None
724                }
725            })
726            .collect();
727
728        // Sort by position, then by priority
729        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
730
731        results
732    }
733
734    /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range
735    ///
736    /// Used by the render pipeline to inject inline hints.
737    pub fn query_inline_in_range(
738        &self,
739        marker_list: &MarkerList,
740        start: usize,
741        end: usize,
742    ) -> Vec<(usize, &VirtualText)> {
743        let mut results: Vec<(usize, &VirtualText)> = self
744            .texts
745            .values()
746            .filter(|vtext| vtext.position.is_inline())
747            .filter_map(|vtext| {
748                let pos = marker_list.get_position(vtext.marker_id)?;
749                if pos >= start && pos < end {
750                    Some((pos, vtext))
751                } else {
752                    None
753                }
754            })
755            .collect();
756
757        // Sort by position, then by priority
758        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
759
760        results
761    }
762
763    /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position
764    ///
765    /// For each source line, the renderer can quickly check if there are
766    /// LineAbove or LineBelow virtual texts anchored to positions within that line.
767    pub fn build_lines_lookup(
768        &self,
769        marker_list: &MarkerList,
770        start: usize,
771        end: usize,
772    ) -> HashMap<usize, Vec<&VirtualText>> {
773        let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
774
775        for vtext in self.texts.values() {
776            if !vtext.position.is_line() {
777                continue;
778            }
779            if let Some(pos) = marker_list.get_position(vtext.marker_id) {
780                if pos >= start && pos < end {
781                    lookup.entry(pos).or_default().push(vtext);
782                }
783            }
784        }
785
786        // Sort each position's texts by priority
787        for texts in lookup.values_mut() {
788            texts.sort_by_key(|vt| vt.priority);
789        }
790
791        lookup
792    }
793}
794
795impl Default for VirtualTextManager {
796    fn default() -> Self {
797        Self::new()
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use ratatui::style::Color;
805
806    fn hint_style() -> Style {
807        Style::default().fg(Color::DarkGray)
808    }
809
810    #[test]
811    fn test_new_manager() {
812        let manager = VirtualTextManager::new();
813        assert_eq!(manager.len(), 0);
814        assert!(manager.is_empty());
815    }
816
817    #[test]
818    fn test_add_virtual_text() {
819        let mut marker_list = MarkerList::new();
820        let mut manager = VirtualTextManager::new();
821
822        let id = manager.add(
823            &mut marker_list,
824            10,
825            ": i32".to_string(),
826            hint_style(),
827            VirtualTextPosition::AfterChar,
828            0,
829        );
830
831        assert_eq!(manager.len(), 1);
832        assert!(!manager.is_empty());
833        assert_eq!(id.0, 0);
834    }
835
836    #[test]
837    fn test_remove_virtual_text() {
838        let mut marker_list = MarkerList::new();
839        let mut manager = VirtualTextManager::new();
840
841        let id = manager.add(
842            &mut marker_list,
843            10,
844            ": i32".to_string(),
845            hint_style(),
846            VirtualTextPosition::AfterChar,
847            0,
848        );
849
850        assert_eq!(manager.len(), 1);
851
852        let removed = manager.remove(&mut marker_list, id);
853        assert!(removed);
854        assert_eq!(manager.len(), 0);
855
856        // Marker should also be removed
857        assert_eq!(marker_list.marker_count(), 0);
858    }
859
860    #[test]
861    fn test_remove_nonexistent() {
862        let mut marker_list = MarkerList::new();
863        let mut manager = VirtualTextManager::new();
864
865        let removed = manager.remove(&mut marker_list, VirtualTextId(999));
866        assert!(!removed);
867    }
868
869    #[test]
870    fn test_clear() {
871        let mut marker_list = MarkerList::new();
872        let mut manager = VirtualTextManager::new();
873
874        manager.add(
875            &mut marker_list,
876            10,
877            ": i32".to_string(),
878            hint_style(),
879            VirtualTextPosition::AfterChar,
880            0,
881        );
882        manager.add(
883            &mut marker_list,
884            20,
885            ": String".to_string(),
886            hint_style(),
887            VirtualTextPosition::AfterChar,
888            0,
889        );
890
891        assert_eq!(manager.len(), 2);
892        assert_eq!(marker_list.marker_count(), 2);
893
894        manager.clear(&mut marker_list);
895
896        assert_eq!(manager.len(), 0);
897        assert_eq!(marker_list.marker_count(), 0);
898    }
899
900    #[test]
901    fn test_query_range() {
902        let mut marker_list = MarkerList::new();
903        let mut manager = VirtualTextManager::new();
904
905        // Add three virtual texts at positions 10, 20, 30
906        manager.add(
907            &mut marker_list,
908            10,
909            ": i32".to_string(),
910            hint_style(),
911            VirtualTextPosition::AfterChar,
912            0,
913        );
914        manager.add(
915            &mut marker_list,
916            20,
917            ": String".to_string(),
918            hint_style(),
919            VirtualTextPosition::AfterChar,
920            0,
921        );
922        manager.add(
923            &mut marker_list,
924            30,
925            ": bool".to_string(),
926            hint_style(),
927            VirtualTextPosition::AfterChar,
928            0,
929        );
930
931        // Query range [15, 35) should return positions 20 and 30
932        let results = manager.query_range(&marker_list, 15, 35);
933        assert_eq!(results.len(), 2);
934        assert_eq!(results[0].0, 20);
935        assert_eq!(results[0].1.text, ": String");
936        assert_eq!(results[1].0, 30);
937        assert_eq!(results[1].1.text, ": bool");
938
939        // Query range [0, 15) should return position 10
940        let results = manager.query_range(&marker_list, 0, 15);
941        assert_eq!(results.len(), 1);
942        assert_eq!(results[0].0, 10);
943        assert_eq!(results[0].1.text, ": i32");
944    }
945
946    #[test]
947    fn test_query_empty_range() {
948        let mut marker_list = MarkerList::new();
949        let mut manager = VirtualTextManager::new();
950
951        manager.add(
952            &mut marker_list,
953            10,
954            ": i32".to_string(),
955            hint_style(),
956            VirtualTextPosition::AfterChar,
957            0,
958        );
959
960        // Query range with no virtual texts
961        let results = manager.query_range(&marker_list, 100, 200);
962        assert!(results.is_empty());
963    }
964
965    #[test]
966    fn test_priority_ordering() {
967        let mut marker_list = MarkerList::new();
968        let mut manager = VirtualTextManager::new();
969
970        // Add multiple virtual texts at the same position with different priorities
971        manager.add(
972            &mut marker_list,
973            10,
974            "low".to_string(),
975            hint_style(),
976            VirtualTextPosition::AfterChar,
977            0,
978        );
979        manager.add(
980            &mut marker_list,
981            10,
982            "high".to_string(),
983            hint_style(),
984            VirtualTextPosition::AfterChar,
985            10,
986        );
987        manager.add(
988            &mut marker_list,
989            10,
990            "medium".to_string(),
991            hint_style(),
992            VirtualTextPosition::AfterChar,
993            5,
994        );
995
996        let results = manager.query_range(&marker_list, 0, 20);
997        assert_eq!(results.len(), 3);
998        // Should be sorted by priority: 0, 5, 10
999        assert_eq!(results[0].1.text, "low");
1000        assert_eq!(results[1].1.text, "medium");
1001        assert_eq!(results[2].1.text, "high");
1002    }
1003
1004    #[test]
1005    fn test_build_lookup() {
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        manager.add(
1018            &mut marker_list,
1019            10,
1020            " = 5".to_string(),
1021            hint_style(),
1022            VirtualTextPosition::AfterChar,
1023            1,
1024        );
1025        manager.add(
1026            &mut marker_list,
1027            20,
1028            ": String".to_string(),
1029            hint_style(),
1030            VirtualTextPosition::AfterChar,
1031            0,
1032        );
1033
1034        let lookup = manager.build_lookup(&marker_list, 0, 30);
1035
1036        assert_eq!(lookup.len(), 2); // Two unique positions
1037
1038        let at_10 = lookup.get(&10).unwrap();
1039        assert_eq!(at_10.len(), 2);
1040        assert_eq!(at_10[0].text, ": i32"); // priority 0
1041        assert_eq!(at_10[1].text, " = 5"); // priority 1
1042
1043        let at_20 = lookup.get(&20).unwrap();
1044        assert_eq!(at_20.len(), 1);
1045        assert_eq!(at_20[0].text, ": String");
1046    }
1047
1048    #[test]
1049    fn test_position_tracking_after_insert() {
1050        let mut marker_list = MarkerList::new();
1051        let mut manager = VirtualTextManager::new();
1052
1053        manager.add(
1054            &mut marker_list,
1055            10,
1056            ": i32".to_string(),
1057            hint_style(),
1058            VirtualTextPosition::AfterChar,
1059            0,
1060        );
1061
1062        // Insert 5 bytes before position 10
1063        marker_list.adjust_for_insert(5, 5);
1064
1065        // Virtual text should now be at position 15
1066        let results = manager.query_range(&marker_list, 0, 20);
1067        assert_eq!(results.len(), 1);
1068        assert_eq!(results[0].0, 15);
1069    }
1070
1071    #[test]
1072    fn test_position_tracking_after_delete() {
1073        let mut marker_list = MarkerList::new();
1074        let mut manager = VirtualTextManager::new();
1075
1076        manager.add(
1077            &mut marker_list,
1078            20,
1079            ": i32".to_string(),
1080            hint_style(),
1081            VirtualTextPosition::AfterChar,
1082            0,
1083        );
1084
1085        // Delete 5 bytes before position 20 (at position 10)
1086        marker_list.adjust_for_delete(10, 5);
1087
1088        // Virtual text should now be at position 15
1089        let results = manager.query_range(&marker_list, 0, 20);
1090        assert_eq!(results.len(), 1);
1091        assert_eq!(results[0].0, 15);
1092    }
1093
1094    #[test]
1095    fn test_before_and_after_positions() {
1096        let mut marker_list = MarkerList::new();
1097        let mut manager = VirtualTextManager::new();
1098
1099        manager.add(
1100            &mut marker_list,
1101            10,
1102            "/*param=*/".to_string(),
1103            hint_style(),
1104            VirtualTextPosition::BeforeChar,
1105            0,
1106        );
1107        manager.add(
1108            &mut marker_list,
1109            10,
1110            ": Type".to_string(),
1111            hint_style(),
1112            VirtualTextPosition::AfterChar,
1113            0,
1114        );
1115
1116        let lookup = manager.build_lookup(&marker_list, 0, 20);
1117        let at_10 = lookup.get(&10).unwrap();
1118
1119        assert_eq!(at_10.len(), 2);
1120        // Both at same position, check they have different positions
1121        let before = at_10
1122            .iter()
1123            .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
1124        let after = at_10
1125            .iter()
1126            .find(|vt| vt.position == VirtualTextPosition::AfterChar);
1127
1128        assert!(before.is_some());
1129        assert!(after.is_some());
1130        assert_eq!(before.unwrap().text, "/*param=*/");
1131        assert_eq!(after.unwrap().text, ": Type");
1132    }
1133}