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