Skip to main content

fresh/
state.rs

1use crate::model::buffer::{Buffer, LineNumber};
2use crate::model::cursor::{Cursor, Cursors};
3use crate::model::document_model::{
4    DocumentCapabilities, DocumentModel, DocumentPosition, ViewportContent, ViewportLine,
5};
6use crate::model::event::{
7    Event, MarginContentData, MarginPositionData, OverlayFace as EventOverlayFace, PopupData,
8    PopupPositionData,
9};
10use crate::model::filesystem::FileSystem;
11use crate::model::marker::{MarkerId, MarkerList};
12use crate::primitives::detected_language::DetectedLanguage;
13use crate::primitives::grammar::GrammarRegistry;
14use crate::primitives::highlight_engine::HighlightEngine;
15use crate::primitives::indent::IndentCalculator;
16use crate::primitives::reference_highlighter::ReferenceHighlighter;
17use crate::primitives::text_property::TextPropertyManager;
18use crate::view::bracket_highlight_overlay::BracketHighlightOverlay;
19use crate::view::conceal::ConcealManager;
20use crate::view::folding::LspFoldRanges;
21use crate::view::margin::{MarginAnnotation, MarginContent, MarginManager, MarginPosition};
22use crate::view::overlay::{Overlay, OverlayFace, OverlayManager, UnderlineStyle};
23use crate::view::popup::{
24    Popup, PopupContent, PopupKind, PopupListItem, PopupManager, PopupPosition,
25};
26use crate::view::reference_highlight_overlay::ReferenceHighlightOverlay;
27use crate::view::soft_break::SoftBreakManager;
28use crate::view::virtual_text::VirtualTextManager;
29use anyhow::Result;
30use ratatui::style::{Color, Style};
31use std::cell::RefCell;
32use std::ops::Range;
33use std::sync::Arc;
34
35/// A marker whose position was displaced by a deletion.
36/// Stored in LogEntry (for single edits) or Event::BulkEdit (for bulk edits).
37/// On undo, the marker is restored to its exact original position.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub enum DisplacedMarker {
40    /// Marker from the main marker_list (virtual text, overlays)
41    Main { id: u64, position: usize },
42    /// Marker from margins.indicator_markers (breakpoints, line indicators)
43    Margin { id: u64, position: usize },
44}
45
46impl DisplacedMarker {
47    /// Encode as (u64, usize) for compact storage. Uses high bit to tag source.
48    pub fn encode(&self) -> (u64, usize) {
49        match self {
50            Self::Main { id, position } => (*id, *position),
51            Self::Margin { id, position } => (*id | (1u64 << 63), *position),
52        }
53    }
54
55    /// Decode from (u64, usize) compact representation.
56    pub fn decode(tagged_id: u64, position: usize) -> Self {
57        if (tagged_id >> 63) == 1 {
58            Self::Margin {
59                id: tagged_id & !(1u64 << 63),
60                position,
61            }
62        } else {
63            Self::Main {
64                id: tagged_id,
65                position,
66            }
67        }
68    }
69}
70
71/// Display mode for a buffer
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ViewMode {
74    /// Plain source rendering
75    Source,
76    /// Document-style page view with centered content, concealed markers,
77    /// and plugin-driven word wrapping (previously called "compose mode")
78    PageView,
79}
80
81/// Per-buffer user settings that should be preserved across file reloads (auto-revert).
82///
83/// These are user overrides that apply to a specific buffer, separate from:
84/// - File-derived state (syntax highlighting, language detection)
85/// - View-specific state (scroll position, line wrap - those live in SplitViewState)
86///
87/// TODO: Consider moving view-related settings (line numbers, debug mode) to SplitViewState
88/// to allow per-split preferences. Currently line numbers is in margins (coupled with plugin
89/// gutters), and debug_highlight_mode is in EditorState, but both could arguably be per-view
90/// rather than per-buffer.
91#[derive(Debug, Clone)]
92pub struct BufferSettings {
93    /// Resolved whitespace indicator visibility for this buffer.
94    /// Set based on global + language config; can be toggled per-buffer by user
95    pub whitespace: crate::config::WhitespaceVisibility,
96
97    /// Whether pressing Tab should insert a tab character instead of spaces.
98    /// Set based on language config; can be toggled per-buffer by user
99    pub use_tabs: bool,
100
101    /// Tab size (number of spaces per tab character) for rendering.
102    /// Used for visual display of tab characters and indent calculations.
103    /// Set based on language config; can be changed per-buffer by user
104    pub tab_size: usize,
105
106    /// Whether to auto-close brackets, parentheses, and quotes.
107    /// Set based on global + language config.
108    pub auto_close: bool,
109
110    /// Whether to surround selected text with matching pairs when typing a delimiter.
111    /// Set based on global + language config.
112    pub auto_surround: bool,
113
114    /// Extra characters (beyond alphanumeric + `_`) considered part of
115    /// identifiers for this language. Used by completion providers.
116    pub word_characters: String,
117}
118
119impl Default for BufferSettings {
120    fn default() -> Self {
121        Self {
122            whitespace: crate::config::WhitespaceVisibility::default(),
123            use_tabs: false,
124            tab_size: 4,
125            auto_close: true,
126            auto_surround: true,
127            word_characters: String::new(),
128        }
129    }
130}
131
132/// The complete editor state - everything needed to represent the current editing session
133///
134/// NOTE: Viewport is NOT stored here - it lives in SplitViewState.
135/// This is because viewport is view-specific (each split can view the same buffer
136/// at different scroll positions), while EditorState represents the buffer content.
137pub struct EditorState {
138    /// The text buffer
139    pub buffer: Buffer,
140
141    /// Syntax highlighter (tree-sitter or TextMate based on language)
142    pub highlighter: HighlightEngine,
143
144    /// Auto-indent calculator for smart indentation (RefCell for interior mutability)
145    pub indent_calculator: RefCell<IndentCalculator>,
146
147    /// Overlays for visual decorations (underlines, highlights, etc.)
148    pub overlays: OverlayManager,
149
150    /// Marker list for content-anchored overlay positions
151    pub marker_list: MarkerList,
152
153    /// Virtual text manager for inline hints (type hints, parameter hints, etc.)
154    pub virtual_texts: VirtualTextManager,
155
156    /// Conceal ranges for hiding/replacing byte ranges during rendering
157    pub conceals: ConcealManager,
158
159    /// Soft break points for marker-based line wrapping during rendering
160    pub soft_breaks: SoftBreakManager,
161
162    /// Popups for floating windows (completion, documentation, etc.)
163    pub popups: PopupManager,
164
165    /// Margins for line numbers, annotations, gutter symbols, etc.)
166    pub margins: MarginManager,
167
168    /// Cached line number for primary cursor (0-indexed)
169    /// Maintained incrementally to avoid O(n) scanning on every render
170    pub primary_cursor_line_number: LineNumber,
171
172    /// Current mode (for modal editing, if implemented)
173    pub mode: String,
174
175    /// Text properties for virtual buffers (embedded metadata in text ranges)
176    /// Used by virtual buffers to store location info, severity, etc.
177    pub text_properties: TextPropertyManager,
178
179    /// Whether to show cursors in this buffer (default true)
180    /// Can be set to false for virtual buffers like diagnostics panels
181    pub show_cursors: bool,
182
183    /// Whether editing is disabled for this buffer (default false)
184    /// When true, typing, deletion, cut/paste, undo/redo are blocked
185    /// but navigation, selection, and copy are still allowed
186    pub editing_disabled: bool,
187
188    /// Whether this buffer can be scrolled (default true). Fixed buffer-group
189    /// panels (toolbars, headers, footers) set this to false so the mouse
190    /// wheel is ignored and no scrollbar is drawn.
191    pub scrollable: bool,
192
193    /// Per-buffer user settings (tab size, indentation style, etc.)
194    /// These settings are preserved across file reloads (auto-revert)
195    pub buffer_settings: BufferSettings,
196
197    /// Semantic highlighter for word occurrence highlighting
198    pub reference_highlighter: ReferenceHighlighter,
199
200    /// Whether this buffer is a composite view (e.g., side-by-side diff)
201    pub is_composite_buffer: bool,
202
203    /// Debug mode: reveal highlight/overlay spans (WordPerfect-style)
204    pub debug_highlight_mode: bool,
205
206    /// Debounced semantic highlight cache
207    pub reference_highlight_overlay: ReferenceHighlightOverlay,
208
209    /// Bracket matching highlight overlay
210    pub bracket_highlight_overlay: BracketHighlightOverlay,
211
212    /// Cached LSP semantic tokens (converted to buffer byte ranges)
213    pub semantic_tokens: Option<SemanticTokenStore>,
214
215    /// Last-known LSP folding ranges for this buffer, tracked by byte markers
216    /// so they auto-adjust when content is inserted or deleted around them
217    /// (issue #1571).
218    pub folding_ranges: LspFoldRanges,
219
220    /// The detected language ID for this buffer (e.g., "rust", "csharp", "text").
221    /// Used for LSP config lookup and internal identification.
222    pub language: String,
223
224    /// Human-readable language display name (e.g., "Rust", "C#", "Plain Text").
225    /// Shown in the status bar and Set Language prompt.
226    // TODO: Consider embedding `DetectedLanguage` directly in `EditorState`
227    // instead of copying its fields, to avoid duplication between the two structs.
228    pub display_name: String,
229
230    /// Per-logical-line visual-row-count cache (pipeline-output).
231    /// Populated by both the renderer (as a side effect of rendering a
232    /// visible frame) and the scroll-math miss handler.  Entries are
233    /// keyed on every pipeline input; mutations to any input produce a
234    /// different key so stale entries are never returned — see
235    /// `docs/internal/line-wrap-cache-plan.md`.
236    pub line_wrap_cache: crate::view::line_wrap_cache::LineWrapCache,
237
238    /// Whole-buffer prefix-sum index over per-line visual row counts.
239    /// Sits one tier above `line_wrap_cache`: answers
240    /// "what visual row contains byte B?" / "what byte sits at row R?"
241    /// in O(log N_lines) for scrollbar drag, scrollbar render, and
242    /// `ensure_visible` wrapped scrolling.  Built lazily from
243    /// `line_wrap_cache`; same invalidation source (pipeline-input
244    /// version + geometry).  See
245    /// `crate::view::visual_row_index` for invariants.
246    pub visual_row_index: crate::view::visual_row_index::VisualRowIndex,
247}
248
249impl EditorState {
250    /// Create a new editor state with an empty buffer
251    ///
252    /// Note: width/height parameters are kept for backward compatibility but
253    /// are no longer used - viewport is now owned by SplitViewState.
254    /// Apply a detected language to this state. This is the single mutation point
255    /// for changing the language of a buffer after creation.
256    pub fn apply_language(&mut self, detected: DetectedLanguage) {
257        self.language = detected.name;
258        self.display_name = detected.display_name;
259        self.highlighter = detected.highlighter;
260        if let Some(lang) = &detected.ts_language {
261            self.reference_highlighter.set_language(lang);
262        }
263    }
264
265    /// Create a new state with a buffer and default (plain text) language.
266    /// All other fields are initialized to their defaults.
267    fn new_from_buffer(buffer: Buffer) -> Self {
268        let mut marker_list = MarkerList::new();
269        if !buffer.is_empty() {
270            marker_list.adjust_for_insert(0, buffer.len());
271        }
272
273        Self {
274            buffer,
275            highlighter: HighlightEngine::None,
276            indent_calculator: RefCell::new(IndentCalculator::new()),
277            overlays: OverlayManager::new(),
278            marker_list,
279            virtual_texts: VirtualTextManager::new(),
280            conceals: ConcealManager::new(),
281            soft_breaks: SoftBreakManager::new(),
282            popups: PopupManager::new(),
283            margins: MarginManager::new(),
284            primary_cursor_line_number: LineNumber::Absolute(0),
285            mode: "insert".to_string(),
286            text_properties: TextPropertyManager::new(),
287            show_cursors: true,
288            editing_disabled: false,
289            scrollable: true,
290            buffer_settings: BufferSettings::default(),
291            reference_highlighter: ReferenceHighlighter::new(),
292            is_composite_buffer: false,
293            debug_highlight_mode: false,
294            reference_highlight_overlay: ReferenceHighlightOverlay::new(),
295            bracket_highlight_overlay: BracketHighlightOverlay::new(),
296            semantic_tokens: None,
297            folding_ranges: LspFoldRanges::new(),
298            language: "text".to_string(),
299            display_name: "Text".to_string(),
300            line_wrap_cache: crate::view::line_wrap_cache::LineWrapCache::default(),
301            visual_row_index: crate::view::visual_row_index::VisualRowIndex::default(),
302        }
303    }
304
305    pub fn new(
306        _width: u16,
307        _height: u16,
308        large_file_threshold: usize,
309        fs: Arc<dyn FileSystem + Send + Sync>,
310    ) -> Self {
311        Self::new_from_buffer(Buffer::new(large_file_threshold, fs))
312    }
313
314    /// Create a new editor state with an empty buffer associated with a file path.
315    /// Used for files that don't exist yet — the path is set so saving will create the file.
316    pub fn new_with_path(
317        large_file_threshold: usize,
318        fs: Arc<dyn FileSystem + Send + Sync>,
319        path: std::path::PathBuf,
320    ) -> Self {
321        Self::new_from_buffer(Buffer::new_with_path(large_file_threshold, fs, path))
322    }
323
324    /// Set the syntax highlighting language based on a virtual buffer name.
325    /// Handles names like `*OLD:test.ts*` or `*OURS*.c` by stripping markers
326    /// and detecting language from the cleaned filename.
327    pub fn set_language_from_name(&mut self, name: &str, registry: &GrammarRegistry) {
328        let detected = DetectedLanguage::from_virtual_name(name, registry);
329        tracing::debug!(
330            "Set highlighter for virtual buffer based on name: {} (backend: {}, language: {})",
331            name,
332            detected.highlighter.backend_name(),
333            detected.name
334        );
335        self.apply_language(detected);
336    }
337
338    /// Create an editor state from a file
339    ///
340    /// Note: width/height parameters are kept for backward compatibility but
341    /// are no longer used - viewport is now owned by SplitViewState.
342    pub fn from_file(
343        path: &std::path::Path,
344        _width: u16,
345        _height: u16,
346        large_file_threshold: usize,
347        registry: &GrammarRegistry,
348        fs: Arc<dyn FileSystem + Send + Sync>,
349    ) -> anyhow::Result<Self> {
350        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
351        let first_line = buffer.first_line_lossy();
352        let detected = registry
353            .find_by_path(path, first_line.as_deref())
354            .map(|entry| DetectedLanguage::from_entry(entry, registry))
355            .unwrap_or_else(DetectedLanguage::plain_text);
356        let mut state = Self::new_from_buffer(buffer);
357        state.apply_language(detected);
358        Ok(state)
359    }
360
361    /// Create an editor state from a file with language configuration.
362    ///
363    /// This version uses the provided languages configuration for syntax detection,
364    /// allowing user-configured filename patterns to be respected for highlighting.
365    ///
366    /// Note: width/height parameters are kept for backward compatibility but
367    /// are no longer used - viewport is now owned by SplitViewState.
368    pub fn from_file_with_languages(
369        path: &std::path::Path,
370        _width: u16,
371        _height: u16,
372        large_file_threshold: usize,
373        registry: &GrammarRegistry,
374        languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
375        fs: Arc<dyn FileSystem + Send + Sync>,
376    ) -> anyhow::Result<Self> {
377        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
378        let first_line = buffer.first_line_lossy();
379        let detected =
380            DetectedLanguage::from_path(path, first_line.as_deref(), registry, languages);
381        let mut state = Self::new_from_buffer(buffer);
382        state.apply_language(detected);
383        Ok(state)
384    }
385
386    /// Create an editor state from a buffer and a pre-built `DetectedLanguage`.
387    ///
388    /// This is useful when you have already loaded a buffer with a specific encoding
389    /// and want to create an EditorState from it.
390    pub fn from_buffer_with_language(buffer: Buffer, detected: DetectedLanguage) -> Self {
391        let mut state = Self::new_from_buffer(buffer);
392        state.apply_language(detected);
393        state
394    }
395
396    /// Handle an Insert event - adjusts markers, buffer, highlighter, cursors, and line numbers
397    fn apply_insert(
398        &mut self,
399        cursors: &mut Cursors,
400        position: usize,
401        text: &str,
402        cursor_id: crate::model::event::CursorId,
403    ) {
404        let newlines_inserted = text.matches('\n').count();
405
406        // CRITICAL: Adjust markers BEFORE modifying buffer
407        self.marker_list.adjust_for_insert(position, text.len());
408        self.margins.adjust_for_insert(position, text.len());
409
410        // Insert text into buffer
411        self.buffer.insert(position, text);
412
413        // Notify highlighter of the insert (adjusts checkpoint marker positions)
414        // and invalidate span cache for the edited range.
415        self.highlighter.notify_insert(position, text.len());
416        self.highlighter
417            .invalidate_range(position..position + text.len());
418
419        // Note: reference_highlight_overlay uses markers that auto-adjust,
420        // so no manual invalidation needed
421
422        // Adjust all cursors after the edit
423        cursors.adjust_for_edit(position, 0, text.len());
424
425        // Move the cursor that made the edit to the end of the insertion
426        if let Some(cursor) = cursors.get_mut(cursor_id) {
427            cursor.position = position + text.len();
428            cursor.clear_selection();
429        }
430
431        // Update primary cursor line number if this was the primary cursor
432        if cursor_id == cursors.primary_id() {
433            self.primary_cursor_line_number = match self.primary_cursor_line_number {
434                LineNumber::Absolute(line) => LineNumber::Absolute(line + newlines_inserted),
435                LineNumber::Relative {
436                    line,
437                    from_cached_line,
438                } => LineNumber::Relative {
439                    line: line + newlines_inserted,
440                    from_cached_line,
441                },
442            };
443        }
444    }
445
446    /// Handle a Delete event - adjusts markers, buffer, highlighter, cursors, and line numbers
447    fn apply_delete(
448        &mut self,
449        cursors: &mut Cursors,
450        range: &std::ops::Range<usize>,
451        cursor_id: crate::model::event::CursorId,
452        deleted_text: &str,
453    ) {
454        let len = range.len();
455
456        // Count newlines deleted BEFORE the primary cursor's original position.
457        // For backspace: cursor was at range.end, so all deleted newlines are before it.
458        // For forward delete: cursor was at range.start, so no deleted newlines are before it.
459        let primary_newlines_removed = if cursor_id == cursors.primary_id() {
460            let cursor_pos = cursors.get(cursor_id).map_or(range.start, |c| c.position);
461            let bytes_before_cursor = cursor_pos
462                .saturating_sub(range.start)
463                .min(len)
464                .min(deleted_text.len());
465            deleted_text[..bytes_before_cursor].matches('\n').count()
466        } else {
467            0
468        };
469
470        // Drop virtual texts whose anchors are being erased. This is what
471        // makes inlay hints disappear immediately when the range containing
472        // them is deleted; without this the marker would just clamp to
473        // range.start and the hint would linger at the wrong position until
474        // the next LSP refresh.
475        self.virtual_texts
476            .remove_in_range(&mut self.marker_list, range.start, range.end);
477
478        // CRITICAL: Adjust markers BEFORE modifying buffer
479        self.marker_list.adjust_for_delete(range.start, len);
480        self.margins.adjust_for_delete(range.start, len);
481
482        // Delete from buffer
483        self.buffer.delete(range.clone());
484
485        // Notify highlighter of the delete (adjusts checkpoint marker positions)
486        // and invalidate span cache for the edited range.
487        self.highlighter.notify_delete(range.start, len);
488        self.highlighter.invalidate_range(range.clone());
489
490        // Note: reference_highlight_overlay uses markers that auto-adjust,
491        // so no manual invalidation needed
492
493        // Adjust all cursors after the edit
494        cursors.adjust_for_edit(range.start, len, 0);
495
496        // Move the cursor that made the edit to the start of deletion
497        if let Some(cursor) = cursors.get_mut(cursor_id) {
498            cursor.position = range.start;
499            cursor.clear_selection();
500        }
501
502        // Update primary cursor line number if this was the primary cursor
503        if cursor_id == cursors.primary_id() && primary_newlines_removed > 0 {
504            self.primary_cursor_line_number = match self.primary_cursor_line_number {
505                LineNumber::Absolute(line) => {
506                    LineNumber::Absolute(line.saturating_sub(primary_newlines_removed))
507                }
508                LineNumber::Relative {
509                    line,
510                    from_cached_line,
511                } => LineNumber::Relative {
512                    line: line.saturating_sub(primary_newlines_removed),
513                    from_cached_line,
514                },
515            };
516        }
517    }
518
519    /// Apply an event to the state - THE ONLY WAY TO MODIFY STATE
520    /// This is the heart of the event-driven architecture
521    pub fn apply(&mut self, cursors: &mut Cursors, event: &Event) {
522        match event {
523            Event::Insert {
524                position,
525                text,
526                cursor_id,
527            } => self.apply_insert(cursors, *position, text, *cursor_id),
528
529            Event::Delete {
530                range,
531                cursor_id,
532                deleted_text,
533            } => self.apply_delete(cursors, range, *cursor_id, deleted_text),
534
535            Event::MoveCursor {
536                cursor_id,
537                new_position,
538                new_anchor,
539                new_sticky_column,
540                ..
541            } => {
542                if let Some(cursor) = cursors.get_mut(*cursor_id) {
543                    cursor.position = *new_position;
544                    cursor.anchor = *new_anchor;
545                    cursor.sticky_column = *new_sticky_column;
546                }
547
548                // Update primary cursor line number if this is the primary cursor
549                // Try to get exact line number from buffer, or estimate for large files
550                if *cursor_id == cursors.primary_id() {
551                    self.primary_cursor_line_number =
552                        match self.buffer.offset_to_position(*new_position) {
553                            Some(pos) => LineNumber::Absolute(pos.line),
554                            None => {
555                                // Large file without line metadata - estimate line number
556                                // Use default estimated_line_length of 80 bytes
557                                let estimated_line = *new_position / 80;
558                                LineNumber::Absolute(estimated_line)
559                            }
560                        };
561                }
562            }
563
564            Event::AddCursor {
565                cursor_id,
566                position,
567                anchor,
568            } => {
569                let cursor = if let Some(anchor) = anchor {
570                    Cursor::with_selection(*anchor, *position)
571                } else {
572                    Cursor::new(*position)
573                };
574
575                // Insert cursor with the specific ID from the event
576                // This is important for undo/redo to work correctly
577                cursors.insert_with_id(*cursor_id, cursor);
578
579                cursors.normalize();
580            }
581
582            Event::RemoveCursor { cursor_id, .. } => {
583                cursors.remove(*cursor_id);
584            }
585
586            // View events (Scroll, SetViewport, Recenter) are now handled at Editor level
587            // via SplitViewState. They should not reach EditorState.apply().
588            Event::Scroll { .. } | Event::SetViewport { .. } | Event::Recenter => {
589                // These events are intercepted in Editor::apply_event_to_active_buffer
590                // and routed to SplitViewState. If we get here, something is wrong.
591                tracing::warn!("View event {:?} reached EditorState.apply() - should be handled by SplitViewState", event);
592            }
593
594            Event::SetAnchor {
595                cursor_id,
596                position,
597            } => {
598                // Set the anchor (selection start) for a specific cursor
599                // Also disable deselect_on_move so movement preserves the selection (Emacs mark mode)
600                if let Some(cursor) = cursors.get_mut(*cursor_id) {
601                    cursor.anchor = Some(*position);
602                    cursor.deselect_on_move = false;
603                }
604            }
605
606            Event::ClearAnchor { cursor_id } => {
607                // Clear the anchor and reset deselect_on_move to cancel mark mode
608                // Also clear block selection if active
609                if let Some(cursor) = cursors.get_mut(*cursor_id) {
610                    cursor.anchor = None;
611                    cursor.deselect_on_move = true;
612                    cursor.clear_block_selection();
613                }
614            }
615
616            Event::ChangeMode { mode } => {
617                self.mode = mode.clone();
618            }
619
620            Event::AddOverlay {
621                namespace,
622                range,
623                face,
624                priority,
625                message,
626                extend_to_line_end,
627                url,
628            } => {
629                tracing::trace!(
630                    "AddOverlay: namespace={:?}, range={:?}, face={:?}, priority={}",
631                    namespace,
632                    range,
633                    face,
634                    priority
635                );
636                // Convert event overlay face to overlay face
637                let overlay_face = convert_event_face_to_overlay_face(face);
638                tracing::trace!("Converted face: {:?}", overlay_face);
639
640                let mut overlay = Overlay::with_priority(
641                    &mut self.marker_list,
642                    range.clone(),
643                    overlay_face,
644                    *priority,
645                );
646                overlay.namespace = namespace.clone();
647                overlay.message = message.clone();
648                overlay.extend_to_line_end = *extend_to_line_end;
649                overlay.url = url.clone();
650
651                let actual_range = overlay.range(&self.marker_list);
652                tracing::trace!(
653                    "Created overlay with markers - actual range: {:?}, handle={:?}",
654                    actual_range,
655                    overlay.handle
656                );
657
658                self.overlays.add(overlay);
659            }
660
661            Event::RemoveOverlay { handle } => {
662                tracing::trace!("RemoveOverlay: handle={:?}", handle);
663                self.overlays
664                    .remove_by_handle(handle, &mut self.marker_list);
665            }
666
667            Event::RemoveOverlaysInRange { range } => {
668                self.overlays.remove_in_range(range, &mut self.marker_list);
669            }
670
671            Event::ClearNamespace { namespace } => {
672                tracing::trace!("ClearNamespace: namespace={:?}", namespace);
673                self.overlays
674                    .clear_namespace(namespace, &mut self.marker_list);
675            }
676
677            Event::ClearOverlays => {
678                self.overlays.clear(&mut self.marker_list);
679            }
680
681            Event::ShowPopup { popup } => {
682                let popup_obj = convert_popup_data_to_popup(popup);
683                self.popups.show_or_replace(popup_obj);
684            }
685
686            Event::HidePopup => {
687                self.popups.hide();
688            }
689
690            Event::ClearPopups => {
691                self.popups.clear();
692            }
693
694            Event::PopupSelectNext => {
695                if let Some(popup) = self.popups.top_mut() {
696                    popup.select_next();
697                }
698            }
699
700            Event::PopupSelectPrev => {
701                if let Some(popup) = self.popups.top_mut() {
702                    popup.select_prev();
703                }
704            }
705
706            Event::PopupPageDown => {
707                if let Some(popup) = self.popups.top_mut() {
708                    popup.page_down();
709                }
710            }
711
712            Event::PopupPageUp => {
713                if let Some(popup) = self.popups.top_mut() {
714                    popup.page_up();
715                }
716            }
717
718            Event::AddMarginAnnotation {
719                line,
720                position,
721                content,
722                annotation_id,
723            } => {
724                let margin_position = convert_margin_position(position);
725                let margin_content = convert_margin_content(content);
726                let annotation = if let Some(id) = annotation_id {
727                    MarginAnnotation::with_id(*line, margin_position, margin_content, id.clone())
728                } else {
729                    MarginAnnotation::new(*line, margin_position, margin_content)
730                };
731                self.margins.add_annotation(annotation);
732            }
733
734            Event::RemoveMarginAnnotation { annotation_id } => {
735                self.margins.remove_by_id(annotation_id);
736            }
737
738            Event::RemoveMarginAnnotationsAtLine { line, position } => {
739                let margin_position = convert_margin_position(position);
740                self.margins.remove_at_line(*line, margin_position);
741            }
742
743            Event::ClearMarginPosition { position } => {
744                let margin_position = convert_margin_position(position);
745                self.margins.clear_position(margin_position);
746            }
747
748            Event::ClearMargins => {
749                self.margins.clear_all();
750            }
751
752            Event::SetLineNumbers { enabled } => {
753                self.margins.configure_for_line_numbers(*enabled);
754            }
755
756            // Split events are handled at the Editor level, not at EditorState level
757            // These are no-ops here as they affect the split layout, not buffer state
758            Event::SplitPane { .. }
759            | Event::CloseSplit { .. }
760            | Event::SetActiveSplit { .. }
761            | Event::AdjustSplitRatio { .. }
762            | Event::NextSplit
763            | Event::PrevSplit => {
764                // No-op: split events are handled by Editor, not EditorState
765            }
766
767            Event::Batch { events, .. } => {
768                // Apply all events in the batch sequentially
769                // This ensures multi-cursor operations are applied atomically
770                for event in events {
771                    self.apply(cursors, event);
772                }
773            }
774
775            Event::BulkEdit {
776                new_snapshot,
777                new_cursors,
778                edits,
779                displaced_markers,
780                ..
781            } => {
782                // Restore the target buffer state (piece tree + buffers) for this event.
783                // - For undo: snapshots are swapped, so new_snapshot is the original state
784                // - For redo: new_snapshot is the state after edits
785                // Restoring buffers alongside the tree is critical because
786                // consolidate_after_save() can replace buffers between snapshot and restore.
787                if let Some(snapshot) = new_snapshot {
788                    self.buffer.restore_buffer_state(snapshot);
789                }
790
791                // Replay marker adjustments from the edit list.
792                // For redo: same adjustments as the forward path.
793                // For undo: inverse() has swapped del/ins, so adjustments are reversed.
794                // Edits are in descending position order — process as-is so later
795                // positions are adjusted first (no cascading shift errors).
796                //
797                // For replacements (del > 0 AND ins > 0 at same position), we only
798                // adjust for the net delta to avoid the marker-at-boundary problem
799                // where sequential delete+insert pushes markers incorrectly.
800                for &(pos, del_len, ins_len) in edits {
801                    if del_len > 0 && ins_len > 0 {
802                        // Replacement: adjust by net delta only
803                        if ins_len > del_len {
804                            let net = ins_len - del_len;
805                            self.marker_list.adjust_for_insert(pos, net);
806                            self.margins.adjust_for_insert(pos, net);
807                        } else if del_len > ins_len {
808                            let net = del_len - ins_len;
809                            self.marker_list.adjust_for_delete(pos, net);
810                            self.margins.adjust_for_delete(pos, net);
811                        }
812                        // If equal: net delta 0, no adjustment needed
813                    } else if del_len > 0 {
814                        self.marker_list.adjust_for_delete(pos, del_len);
815                        self.margins.adjust_for_delete(pos, del_len);
816                    } else if ins_len > 0 {
817                        self.marker_list.adjust_for_insert(pos, ins_len);
818                        self.margins.adjust_for_insert(pos, ins_len);
819                    }
820                }
821
822                // Restore displaced markers to their original positions.
823                // This fixes markers that were inside a deleted range and collapsed
824                // to the deletion boundary — they're now moved back to their exact
825                // original positions after the text has been restored by undo.
826                if !displaced_markers.is_empty() {
827                    self.restore_displaced_markers(displaced_markers);
828                }
829
830                // Clear ephemeral decorations — their source systems will re-push
831                // correct positions after the edit notification.
832                self.virtual_texts.clear(&mut self.marker_list);
833
834                use crate::view::overlay::OverlayNamespace;
835                let namespaces = ["lsp-diagnostic", "reference-highlight", "bracket-highlight"];
836                for ns in &namespaces {
837                    self.overlays.clear_namespace(
838                        &OverlayNamespace::from_string(ns.to_string()),
839                        &mut self.marker_list,
840                    );
841                }
842
843                // Update cursor positions
844                for (cursor_id, position, anchor) in new_cursors {
845                    if let Some(cursor) = cursors.get_mut(*cursor_id) {
846                        cursor.position = *position;
847                        cursor.anchor = *anchor;
848                    }
849                }
850
851                // Invalidate highlight cache for entire buffer
852                self.highlighter.invalidate_all();
853
854                // Update primary cursor line number
855                let primary_pos = cursors.primary().position;
856                self.primary_cursor_line_number = match self.buffer.offset_to_position(primary_pos)
857                {
858                    Some(pos) => crate::model::buffer::LineNumber::Absolute(pos.line),
859                    None => crate::model::buffer::LineNumber::Absolute(0),
860                };
861            }
862        }
863    }
864
865    /// Capture positions of markers strictly inside a deleted range.
866    /// Call this BEFORE applying the delete. Returns encoded displaced markers.
867    pub fn capture_displaced_markers(&self, range: &Range<usize>) -> Vec<(u64, usize)> {
868        let mut displaced = Vec::new();
869        if range.is_empty() {
870            return displaced;
871        }
872        for (marker_id, start, _end) in self.marker_list.query_range(range.start, range.end) {
873            if start > range.start && start < range.end {
874                displaced.push(
875                    DisplacedMarker::Main {
876                        id: marker_id.0,
877                        position: start,
878                    }
879                    .encode(),
880                );
881            }
882        }
883        for (marker_id, start, _end) in self.margins.query_indicator_range(range.start, range.end) {
884            if start > range.start && start < range.end {
885                displaced.push(
886                    DisplacedMarker::Margin {
887                        id: marker_id.0,
888                        position: start,
889                    }
890                    .encode(),
891                );
892            }
893        }
894        displaced
895    }
896
897    /// Capture displaced markers for multiple delete ranges (BulkEdit).
898    pub fn capture_displaced_markers_bulk(
899        &self,
900        edits: &[(usize, usize, String)],
901    ) -> Vec<(u64, usize)> {
902        let mut displaced = Vec::new();
903        for (pos, del_len, _text) in edits {
904            if *del_len > 0 {
905                displaced.extend(self.capture_displaced_markers(&(*pos..*pos + *del_len)));
906            }
907        }
908        displaced
909    }
910
911    /// Restore displaced markers to their exact original positions.
912    pub fn restore_displaced_markers(&mut self, displaced: &[(u64, usize)]) {
913        for &(tagged_id, original_pos) in displaced {
914            let dm = DisplacedMarker::decode(tagged_id, original_pos);
915            match dm {
916                DisplacedMarker::Main { id, position } => {
917                    self.marker_list.set_position(MarkerId(id), position);
918                }
919                DisplacedMarker::Margin { id, position } => {
920                    self.margins.set_indicator_position(MarkerId(id), position);
921                }
922            }
923        }
924    }
925
926    /// Apply multiple events in sequence
927    pub fn apply_many(&mut self, cursors: &mut Cursors, events: &[Event]) {
928        for event in events {
929            self.apply(cursors, event);
930        }
931    }
932
933    /// Called when this buffer loses focus (e.g., switching to another buffer,
934    /// opening a prompt, focusing file explorer, etc.)
935    /// Dismisses transient popups like Hover and Signature Help.
936    pub fn on_focus_lost(&mut self) {
937        if self.popups.dismiss_transient() {
938            tracing::debug!("Dismissed transient popup on buffer focus loss");
939        }
940    }
941}
942
943/// Convert event overlay face to the actual overlay face
944fn convert_event_face_to_overlay_face(event_face: &EventOverlayFace) -> OverlayFace {
945    match event_face {
946        EventOverlayFace::Underline { color, style } => {
947            let underline_style = match style {
948                crate::model::event::UnderlineStyle::Straight => UnderlineStyle::Straight,
949                crate::model::event::UnderlineStyle::Wavy => UnderlineStyle::Wavy,
950                crate::model::event::UnderlineStyle::Dotted => UnderlineStyle::Dotted,
951                crate::model::event::UnderlineStyle::Dashed => UnderlineStyle::Dashed,
952            };
953            OverlayFace::Underline {
954                color: Color::Rgb(color.0, color.1, color.2),
955                style: underline_style,
956            }
957        }
958        EventOverlayFace::Background { color } => OverlayFace::Background {
959            color: Color::Rgb(color.0, color.1, color.2),
960        },
961        EventOverlayFace::Foreground { color } => OverlayFace::Foreground {
962            color: Color::Rgb(color.0, color.1, color.2),
963        },
964        EventOverlayFace::Style { options } => {
965            use crate::view::theme::named_color_from_str;
966            use ratatui::style::Modifier;
967
968            // Build fallback style from RGB values or named colors
969            let mut style = Style::default();
970
971            // Extract foreground color (RGB, named color, or default white)
972            if let Some(ref fg) = options.fg {
973                if let Some((r, g, b)) = fg.as_rgb() {
974                    style = style.fg(Color::Rgb(r, g, b));
975                } else if let Some(key) = fg.as_theme_key() {
976                    if let Some(color) = named_color_from_str(key) {
977                        style = style.fg(color);
978                    }
979                }
980            }
981
982            // Extract background color (RGB, named color, or fallback)
983            if let Some(ref bg) = options.bg {
984                if let Some((r, g, b)) = bg.as_rgb() {
985                    style = style.bg(Color::Rgb(r, g, b));
986                } else if let Some(key) = bg.as_theme_key() {
987                    if let Some(color) = named_color_from_str(key) {
988                        style = style.bg(color);
989                    }
990                }
991            }
992
993            // Apply modifiers
994            let mut modifiers = Modifier::empty();
995            if options.bold {
996                modifiers |= Modifier::BOLD;
997            }
998            if options.italic {
999                modifiers |= Modifier::ITALIC;
1000            }
1001            if options.underline {
1002                modifiers |= Modifier::UNDERLINED;
1003            }
1004            if options.strikethrough {
1005                modifiers |= Modifier::CROSSED_OUT;
1006            }
1007            if !modifiers.is_empty() {
1008                style = style.add_modifier(modifiers);
1009            }
1010
1011            // Extract theme keys (exclude recognized named colors, already resolved above)
1012            let fg_theme = options
1013                .fg
1014                .as_ref()
1015                .and_then(|c| c.as_theme_key())
1016                .filter(|key| named_color_from_str(key).is_none())
1017                .map(String::from);
1018            let bg_theme = options
1019                .bg
1020                .as_ref()
1021                .and_then(|c| c.as_theme_key())
1022                .filter(|key| named_color_from_str(key).is_none())
1023                .map(String::from);
1024
1025            // If theme keys are provided, use ThemedStyle for runtime resolution
1026            if fg_theme.is_some() || bg_theme.is_some() {
1027                OverlayFace::ThemedStyle {
1028                    fallback_style: style,
1029                    fg_theme,
1030                    bg_theme,
1031                }
1032            } else {
1033                OverlayFace::Style { style }
1034            }
1035        }
1036    }
1037}
1038
1039/// Convert popup data to the actual popup object
1040pub(crate) fn convert_popup_data_to_popup(data: &PopupData) -> Popup {
1041    let content = match &data.content {
1042        crate::model::event::PopupContentData::Text(lines) => PopupContent::Text(lines.clone()),
1043        crate::model::event::PopupContentData::List { items, selected } => PopupContent::List {
1044            items: items
1045                .iter()
1046                .map(|item| PopupListItem {
1047                    text: item.text.clone(),
1048                    detail: item.detail.clone(),
1049                    icon: item.icon.clone(),
1050                    data: item.data.clone(),
1051                    disabled: false,
1052                })
1053                .collect(),
1054            selected: *selected,
1055        },
1056    };
1057
1058    let position = match data.position {
1059        PopupPositionData::AtCursor => PopupPosition::AtCursor,
1060        PopupPositionData::BelowCursor => PopupPosition::BelowCursor,
1061        PopupPositionData::AboveCursor => PopupPosition::AboveCursor,
1062        PopupPositionData::Fixed { x, y } => PopupPosition::Fixed { x, y },
1063        PopupPositionData::Centered => PopupPosition::Centered,
1064        PopupPositionData::BottomRight => PopupPosition::BottomRight,
1065        PopupPositionData::AboveStatusBarAt { x, status_row } => {
1066            PopupPosition::AboveStatusBarAt { x, status_row }
1067        }
1068    };
1069
1070    // Map the explicit kind hint to PopupKind for input handling
1071    let kind = match data.kind {
1072        crate::model::event::PopupKindHint::Completion => PopupKind::Completion,
1073        crate::model::event::PopupKindHint::List => PopupKind::List,
1074        crate::model::event::PopupKindHint::Text => PopupKind::Text,
1075    };
1076
1077    // Kind-implied resolver default: a popup whose kind is
1078    // `Completion` always confirms by inserting the selected
1079    // completion, regardless of who built it. Other kinds need an
1080    // explicit resolver (LSP confirm, plugin action, LSP status, code
1081    // action) because the same `List` kind is used for all four, so we
1082    // can't infer which feature owns the popup from its kind alone.
1083    let resolver = match kind {
1084        PopupKind::Completion => crate::view::popup::PopupResolver::Completion,
1085        _ => crate::view::popup::PopupResolver::None,
1086    };
1087
1088    // Popups that appear under the user's cursor without an explicit
1089    // user gesture default to *unfocused* so the next keystroke drives
1090    // the buffer rather than the popup. The user grabs focus with
1091    // `popup_focus` (default `Alt+T`). Popups that the user
1092    // *explicitly* invokes (completion via Tab, list/action choosers
1093    // via plugin commands, …) keep the historical focused-on-show
1094    // behavior — type-to-filter and arrow-nav need to "just work"
1095    // without an extra keystroke.
1096    // Only `PopupKindHint` variants reach this path — Completion, List,
1097    // Text. Hover/Action popups are constructed directly in editor code
1098    // (`lsp_requests.rs`) and set their own `focused` flag.
1099    let focused = match kind {
1100        // Completion popups are user-invoked (type-to-trigger / explicit
1101        // `lsp_completion`), so type-to-filter and arrow-nav need to
1102        // "just work" without the user pressing the focus-popup key.
1103        PopupKind::Completion => true,
1104        // List popups are typically explicit user invocations (plugin
1105        // action popups, status-bar menus). Keep them focused on show.
1106        PopupKind::List => true,
1107        // Text popups are auto-shown informational overlays —
1108        // unfocused so they don't swallow the user's next keystroke.
1109        PopupKind::Text => false,
1110        // Direct-construction kinds (Hover, Action) are not produced by
1111        // `Event::ShowPopup`; default to unfocused if ever reached so
1112        // an auto-shown overlay doesn't grab the keyboard by accident.
1113        PopupKind::Hover | PopupKind::Action => false,
1114    };
1115
1116    Popup {
1117        kind,
1118        title: data.title.clone(),
1119        description: data.description.clone(),
1120        transient: data.transient,
1121        content,
1122        position,
1123        width: data.width,
1124        max_height: data.max_height,
1125        bordered: data.bordered,
1126        border_style: Style::default().fg(Color::Gray),
1127        background_style: Style::default().bg(Color::Rgb(30, 30, 30)),
1128        scroll_offset: 0,
1129        text_selection: None,
1130        accept_key_hint: None,
1131        resolver,
1132        focused,
1133        focus_key_hint: None,
1134    }
1135}
1136
1137/// Convert margin position data to the actual margin position
1138fn convert_margin_position(position: &MarginPositionData) -> MarginPosition {
1139    match position {
1140        MarginPositionData::Left => MarginPosition::Left,
1141        MarginPositionData::Right => MarginPosition::Right,
1142    }
1143}
1144
1145/// Convert margin content data to the actual margin content
1146fn convert_margin_content(content: &MarginContentData) -> MarginContent {
1147    match content {
1148        MarginContentData::Text(text) => MarginContent::Text(text.clone()),
1149        MarginContentData::Symbol { text, color } => {
1150            if let Some((r, g, b)) = color {
1151                MarginContent::colored_symbol(text.clone(), Color::Rgb(*r, *g, *b))
1152            } else {
1153                MarginContent::symbol(text.clone(), Style::default())
1154            }
1155        }
1156        MarginContentData::Empty => MarginContent::Empty,
1157    }
1158}
1159
1160impl EditorState {
1161    /// Prepare viewport for rendering (called before frame render)
1162    ///
1163    /// This pre-loads all data that will be needed for rendering the current viewport,
1164    /// ensuring that subsequent read-only access during rendering will succeed.
1165    ///
1166    /// Takes viewport parameters since viewport is now owned by SplitViewState.
1167    pub fn prepare_for_render(&mut self, top_byte: usize, height: u16) -> Result<()> {
1168        self.buffer.prepare_viewport(top_byte, height as usize)?;
1169        Ok(())
1170    }
1171
1172    /// Resolve all plugin-injected virtual-line anchor byte positions
1173    /// for this buffer.  Sorted ascending.
1174    ///
1175    /// Used by `Viewport::scroll_down` / `scroll_up` /
1176    /// `find_max_visual_scroll_position` so the scroll math counts the
1177    /// rows the renderer actually draws (e.g. markdown_compose's
1178    /// `┌─┬─┐` table borders) when computing `max_scroll_row`.  Without
1179    /// this, mouse wheel and PageDown clamp to a row count that
1180    /// ignores virtual lines and stop short of the buffer's real tail.
1181    ///
1182    /// Empty when no plugin has added virtual lines.
1183    pub fn collect_virtual_line_positions(&self) -> Vec<usize> {
1184        if self.virtual_texts.is_empty() {
1185            return Vec::new();
1186        }
1187        let mut v: Vec<usize> = self
1188            .virtual_texts
1189            .query_lines_in_range(&self.marker_list, 0, self.buffer.len() + 1)
1190            .into_iter()
1191            .map(|(pos, _vt)| pos)
1192            .collect();
1193        v.sort_unstable();
1194        v
1195    }
1196
1197    /// Resolve all plugin-injected soft-break `(byte_position, indent)`
1198    /// pairs for this buffer.
1199    ///
1200    /// Returns a sorted slice suitable for passing to `Viewport::scroll_up` /
1201    /// `scroll_down`, which use it to keep their visual-row counting in
1202    /// lock-step with the renderer (which applies these same breaks via
1203    /// `apply_soft_breaks`).  The `indent` field is the column count of
1204    /// hanging-indent spaces the plugin asked the renderer to inject
1205    /// after the break — the wrap counter needs it to compute the
1206    /// continuation segment's effective width correctly.
1207    ///
1208    /// Empty when no plugin is wrapping the buffer.
1209    pub fn collect_soft_break_positions(&self) -> Vec<(usize, u16)> {
1210        if self.soft_breaks.is_empty() {
1211            return Vec::new();
1212        }
1213        // query_viewport already returns pairs sorted by ascending position.
1214        self.soft_breaks
1215            .query_viewport(0, self.buffer.len() + 1, &self.marker_list)
1216    }
1217
1218    // ========== DocumentModel Helper Methods ==========
1219    // These methods provide convenient access to DocumentModel functionality
1220    // while maintaining backward compatibility with existing code.
1221
1222    /// Get text in a range, driving lazy loading transparently
1223    ///
1224    /// This is a convenience wrapper around DocumentModel::get_range that:
1225    /// - Drives lazy loading automatically (never fails due to unloaded data)
1226    /// - Uses byte offsets directly
1227    /// - Returns String (not Result) - errors are logged internally
1228    /// - Returns empty string for invalid ranges
1229    ///
1230    /// This is the preferred API for getting text ranges. The caller never needs
1231    /// to worry about lazy loading or buffer preparation.
1232    ///
1233    /// # Example
1234    /// ```ignore
1235    /// let text = state.get_text_range(0, 100);
1236    /// ```
1237    pub fn get_text_range(&mut self, start: usize, end: usize) -> String {
1238        // TextBuffer::get_text_range_mut() handles lazy loading automatically
1239        match self
1240            .buffer
1241            .get_text_range_mut(start, end.saturating_sub(start))
1242        {
1243            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
1244            Err(e) => {
1245                tracing::warn!("Failed to get text range {}..{}: {}", start, end, e);
1246                String::new()
1247            }
1248        }
1249    }
1250
1251    /// Get the content of a line by its byte offset
1252    ///
1253    /// Returns the line containing the given offset, along with its start position.
1254    /// This uses DocumentModel's viewport functionality for consistent behavior.
1255    ///
1256    /// # Returns
1257    /// `Some((line_start_offset, line_content))` if successful, `None` if offset is invalid
1258    pub fn get_line_at_offset(&mut self, offset: usize) -> Option<(usize, String)> {
1259        use crate::model::document_model::DocumentModel;
1260
1261        // Find the start of the line containing this offset
1262        // Scan backwards to find the previous newline or start of buffer
1263        let mut line_start = offset;
1264        while line_start > 0 {
1265            if let Ok(text) = self.buffer.get_text_range_mut(line_start - 1, 1) {
1266                if text.first() == Some(&b'\n') {
1267                    break;
1268                }
1269                line_start -= 1;
1270            } else {
1271                break;
1272            }
1273        }
1274
1275        // Get a single line viewport starting at the line start
1276        let viewport = self
1277            .get_viewport_content(
1278                crate::model::document_model::DocumentPosition::byte(line_start),
1279                1,
1280            )
1281            .ok()?;
1282
1283        viewport
1284            .lines
1285            .first()
1286            .map(|line| (line.byte_offset, line.content.clone()))
1287    }
1288
1289    /// Get text from current cursor position to end of line
1290    ///
1291    /// This is a common pattern in editing operations. Uses DocumentModel
1292    /// for consistent behavior across file sizes.
1293    pub fn get_text_to_end_of_line(&mut self, cursor_pos: usize) -> Result<String> {
1294        use crate::model::document_model::DocumentModel;
1295
1296        // Get the line containing cursor
1297        let viewport = self.get_viewport_content(
1298            crate::model::document_model::DocumentPosition::byte(cursor_pos),
1299            1,
1300        )?;
1301
1302        if let Some(line) = viewport.lines.first() {
1303            let line_start = line.byte_offset;
1304            let line_end = line_start + line.content.len();
1305
1306            if cursor_pos >= line_start && cursor_pos <= line_end {
1307                let offset_in_line = cursor_pos - line_start;
1308                // Use get() to safely handle potential non-char-boundary offsets
1309                Ok(line.content.get(offset_in_line..).unwrap_or("").to_string())
1310            } else {
1311                Ok(String::new())
1312            }
1313        } else {
1314            Ok(String::new())
1315        }
1316    }
1317
1318    /// Replace cached semantic tokens with a new store.
1319    pub fn set_semantic_tokens(&mut self, store: SemanticTokenStore) {
1320        self.semantic_tokens = Some(store);
1321    }
1322
1323    /// Clear cached semantic tokens (e.g., when tokens are invalidated).
1324    pub fn clear_semantic_tokens(&mut self) {
1325        self.semantic_tokens = None;
1326    }
1327
1328    /// Get the server-provided semantic token result_id if available.
1329    pub fn semantic_tokens_result_id(&self) -> Option<&str> {
1330        self.semantic_tokens
1331            .as_ref()
1332            .and_then(|store| store.result_id.as_deref())
1333    }
1334}
1335
1336/// Implement DocumentModel trait for EditorState
1337///
1338/// This provides a clean abstraction layer between rendering/editing operations
1339/// and the underlying text buffer implementation.
1340impl DocumentModel for EditorState {
1341    fn capabilities(&self) -> DocumentCapabilities {
1342        let line_count = self.buffer.line_count();
1343        DocumentCapabilities {
1344            has_line_index: line_count.is_some(),
1345            uses_lazy_loading: false, // TODO: add large file detection
1346            byte_length: self.buffer.len(),
1347            approximate_line_count: line_count.unwrap_or_else(|| {
1348                // Estimate assuming ~80 bytes per line
1349                self.buffer.len() / 80
1350            }),
1351        }
1352    }
1353
1354    fn get_viewport_content(
1355        &mut self,
1356        start_pos: DocumentPosition,
1357        max_lines: usize,
1358    ) -> Result<ViewportContent> {
1359        // Convert to byte offset
1360        let start_offset = self.position_to_offset(start_pos)?;
1361
1362        // Use new efficient line iteration that tracks line numbers during iteration
1363        // by accumulating line_feed_cnt from pieces (single source of truth)
1364        let line_iter = self.buffer.iter_lines_from(start_offset, max_lines)?;
1365        let has_more = line_iter.has_more;
1366
1367        let lines = line_iter
1368            .map(|line_data| ViewportLine {
1369                byte_offset: line_data.byte_offset,
1370                content: line_data.content,
1371                has_newline: line_data.has_newline,
1372                approximate_line_number: line_data.line_number,
1373            })
1374            .collect();
1375
1376        Ok(ViewportContent {
1377            start_position: DocumentPosition::ByteOffset(start_offset),
1378            lines,
1379            has_more,
1380        })
1381    }
1382
1383    fn position_to_offset(&self, pos: DocumentPosition) -> Result<usize> {
1384        match pos {
1385            DocumentPosition::ByteOffset(offset) => Ok(offset),
1386            DocumentPosition::LineColumn { line, column } => {
1387                if !self.has_line_index() {
1388                    anyhow::bail!("Line indexing not available for this document");
1389                }
1390                // Use piece tree's position conversion
1391                let position = crate::model::piece_tree::Position { line, column };
1392                Ok(self.buffer.position_to_offset(position))
1393            }
1394        }
1395    }
1396
1397    fn offset_to_position(&self, offset: usize) -> DocumentPosition {
1398        if self.has_line_index() {
1399            if let Some(pos) = self.buffer.offset_to_position(offset) {
1400                DocumentPosition::LineColumn {
1401                    line: pos.line,
1402                    column: pos.column,
1403                }
1404            } else {
1405                // Line index exists but metadata unavailable - fall back to byte offset
1406                DocumentPosition::ByteOffset(offset)
1407            }
1408        } else {
1409            DocumentPosition::ByteOffset(offset)
1410        }
1411    }
1412
1413    fn get_range(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<String> {
1414        let start_offset = self.position_to_offset(start)?;
1415        let end_offset = self.position_to_offset(end)?;
1416
1417        if start_offset > end_offset {
1418            anyhow::bail!(
1419                "Invalid range: start offset {} > end offset {}",
1420                start_offset,
1421                end_offset
1422            );
1423        }
1424
1425        let bytes = self
1426            .buffer
1427            .get_text_range_mut(start_offset, end_offset - start_offset)?;
1428
1429        Ok(String::from_utf8_lossy(&bytes).into_owned())
1430    }
1431
1432    fn get_line_content(&mut self, line_number: usize) -> Option<String> {
1433        if !self.has_line_index() {
1434            return None;
1435        }
1436
1437        // Convert line number to byte offset
1438        let line_start_offset = self.buffer.line_start_offset(line_number)?;
1439
1440        // Get line content using iterator
1441        let mut iter = self.buffer.line_iterator(line_start_offset, 80);
1442        if let Some((_start, content)) = iter.next_line() {
1443            let has_newline = content.ends_with('\n');
1444            let line_content = if has_newline {
1445                content[..content.len() - 1].to_string()
1446            } else {
1447                content
1448            };
1449            Some(line_content)
1450        } else {
1451            None
1452        }
1453    }
1454
1455    fn get_chunk_at_offset(&mut self, offset: usize, size: usize) -> Result<(usize, String)> {
1456        let bytes = self.buffer.get_text_range_mut(offset, size)?;
1457
1458        Ok((offset, String::from_utf8_lossy(&bytes).into_owned()))
1459    }
1460
1461    fn insert(&mut self, pos: DocumentPosition, text: &str) -> Result<usize> {
1462        let offset = self.position_to_offset(pos)?;
1463        self.buffer.insert_bytes(offset, text.as_bytes().to_vec());
1464        Ok(text.len())
1465    }
1466
1467    fn delete(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<()> {
1468        let start_offset = self.position_to_offset(start)?;
1469        let end_offset = self.position_to_offset(end)?;
1470
1471        if start_offset > end_offset {
1472            anyhow::bail!(
1473                "Invalid range: start offset {} > end offset {}",
1474                start_offset,
1475                end_offset
1476            );
1477        }
1478
1479        self.buffer.delete(start_offset..end_offset);
1480        Ok(())
1481    }
1482
1483    fn replace(
1484        &mut self,
1485        start: DocumentPosition,
1486        end: DocumentPosition,
1487        text: &str,
1488    ) -> Result<()> {
1489        // Delete then insert
1490        self.delete(start, end)?;
1491        self.insert(start, text)?;
1492        Ok(())
1493    }
1494
1495    fn find_matches(
1496        &mut self,
1497        pattern: &str,
1498        search_range: Option<(DocumentPosition, DocumentPosition)>,
1499    ) -> Result<Vec<usize>> {
1500        let (start_offset, end_offset) = if let Some((start, end)) = search_range {
1501            (
1502                self.position_to_offset(start)?,
1503                self.position_to_offset(end)?,
1504            )
1505        } else {
1506            (0, self.buffer.len())
1507        };
1508
1509        // Get text in range
1510        let bytes = self
1511            .buffer
1512            .get_text_range_mut(start_offset, end_offset - start_offset)?;
1513        let text = String::from_utf8_lossy(&bytes);
1514
1515        // Find all matches (simple substring search for now)
1516        let mut matches = Vec::new();
1517        let mut search_offset = 0;
1518        while let Some(pos) = text[search_offset..].find(pattern) {
1519            matches.push(start_offset + search_offset + pos);
1520            search_offset += pos + pattern.len();
1521        }
1522
1523        Ok(matches)
1524    }
1525}
1526
1527/// Cached semantic tokens for a buffer.
1528#[derive(Clone, Debug)]
1529pub struct SemanticTokenStore {
1530    /// Buffer version the tokens correspond to.
1531    pub version: u64,
1532    /// Server-provided result identifier (if any).
1533    pub result_id: Option<String>,
1534    /// Raw semantic token data (u32 array, 5 integers per token).
1535    pub data: Vec<u32>,
1536    /// All semantic token spans resolved to byte ranges.
1537    pub tokens: Vec<SemanticTokenSpan>,
1538}
1539
1540/// A semantic token span resolved to buffer byte offsets.
1541#[derive(Clone, Debug)]
1542pub struct SemanticTokenSpan {
1543    pub range: Range<usize>,
1544    pub token_type: String,
1545    pub modifiers: Vec<String>,
1546}
1547
1548#[cfg(test)]
1549mod tests {
1550    use crate::model::filesystem::StdFileSystem;
1551    use std::sync::Arc;
1552
1553    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
1554        Arc::new(StdFileSystem)
1555    }
1556    use super::*;
1557    use crate::model::event::CursorId;
1558
1559    #[test]
1560    fn test_state_new() {
1561        let state = EditorState::new(
1562            80,
1563            24,
1564            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1565            test_fs(),
1566        );
1567        assert!(state.buffer.is_empty());
1568    }
1569
1570    #[test]
1571    fn test_apply_insert() {
1572        let mut state = EditorState::new(
1573            80,
1574            24,
1575            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1576            test_fs(),
1577        );
1578        let mut cursors = Cursors::new();
1579        let cursor_id = cursors.primary_id();
1580
1581        state.apply(
1582            &mut cursors,
1583            &Event::Insert {
1584                position: 0,
1585                text: "hello".to_string(),
1586                cursor_id,
1587            },
1588        );
1589
1590        assert_eq!(state.buffer.to_string().unwrap(), "hello");
1591        assert_eq!(cursors.primary().position, 5);
1592        assert!(state.buffer.is_modified());
1593    }
1594
1595    #[test]
1596    fn test_apply_delete() {
1597        let mut state = EditorState::new(
1598            80,
1599            24,
1600            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1601            test_fs(),
1602        );
1603        let mut cursors = Cursors::new();
1604        let cursor_id = cursors.primary_id();
1605
1606        // Insert then delete
1607        state.apply(
1608            &mut cursors,
1609            &Event::Insert {
1610                position: 0,
1611                text: "hello world".to_string(),
1612                cursor_id,
1613            },
1614        );
1615
1616        state.apply(
1617            &mut cursors,
1618            &Event::Delete {
1619                range: 5..11,
1620                deleted_text: " world".to_string(),
1621                cursor_id,
1622            },
1623        );
1624
1625        assert_eq!(state.buffer.to_string().unwrap(), "hello");
1626        assert_eq!(cursors.primary().position, 5);
1627    }
1628
1629    #[test]
1630    fn test_apply_move_cursor() {
1631        let mut state = EditorState::new(
1632            80,
1633            24,
1634            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1635            test_fs(),
1636        );
1637        let mut cursors = Cursors::new();
1638        let cursor_id = cursors.primary_id();
1639
1640        state.apply(
1641            &mut cursors,
1642            &Event::Insert {
1643                position: 0,
1644                text: "hello".to_string(),
1645                cursor_id,
1646            },
1647        );
1648
1649        state.apply(
1650            &mut cursors,
1651            &Event::MoveCursor {
1652                cursor_id,
1653                old_position: 5,
1654                new_position: 2,
1655                old_anchor: None,
1656                new_anchor: None,
1657                old_sticky_column: 0,
1658                new_sticky_column: 0,
1659            },
1660        );
1661
1662        assert_eq!(cursors.primary().position, 2);
1663    }
1664
1665    #[test]
1666    fn test_apply_add_cursor() {
1667        let mut state = EditorState::new(
1668            80,
1669            24,
1670            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1671            test_fs(),
1672        );
1673        let mut cursors = Cursors::new();
1674        let cursor_id = CursorId(1);
1675
1676        state.apply(
1677            &mut cursors,
1678            &Event::AddCursor {
1679                cursor_id,
1680                position: 5,
1681                anchor: None,
1682            },
1683        );
1684
1685        assert_eq!(cursors.count(), 2);
1686    }
1687
1688    #[test]
1689    fn test_apply_many() {
1690        let mut state = EditorState::new(
1691            80,
1692            24,
1693            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1694            test_fs(),
1695        );
1696        let mut cursors = Cursors::new();
1697        let cursor_id = cursors.primary_id();
1698
1699        let events = vec![
1700            Event::Insert {
1701                position: 0,
1702                text: "hello ".to_string(),
1703                cursor_id,
1704            },
1705            Event::Insert {
1706                position: 6,
1707                text: "world".to_string(),
1708                cursor_id,
1709            },
1710        ];
1711
1712        state.apply_many(&mut cursors, &events);
1713
1714        assert_eq!(state.buffer.to_string().unwrap(), "hello world");
1715    }
1716
1717    #[test]
1718    fn test_cursor_adjustment_after_insert() {
1719        let mut state = EditorState::new(
1720            80,
1721            24,
1722            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1723            test_fs(),
1724        );
1725        let mut cursors = Cursors::new();
1726        let cursor_id = cursors.primary_id();
1727
1728        // Add a second cursor at position 5
1729        state.apply(
1730            &mut cursors,
1731            &Event::AddCursor {
1732                cursor_id: CursorId(1),
1733                position: 5,
1734                anchor: None,
1735            },
1736        );
1737
1738        // Insert at position 0 - should push second cursor forward
1739        state.apply(
1740            &mut cursors,
1741            &Event::Insert {
1742                position: 0,
1743                text: "abc".to_string(),
1744                cursor_id,
1745            },
1746        );
1747
1748        // Second cursor should be at position 5 + 3 = 8
1749        if let Some(cursor) = cursors.get(CursorId(1)) {
1750            assert_eq!(cursor.position, 8);
1751        }
1752    }
1753
1754    // DocumentModel trait tests
1755    mod document_model_tests {
1756        use super::*;
1757        use crate::model::document_model::{DocumentModel, DocumentPosition};
1758
1759        #[test]
1760        fn test_capabilities_small_file() {
1761            let mut state = EditorState::new(
1762                80,
1763                24,
1764                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1765                test_fs(),
1766            );
1767            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1768
1769            let caps = state.capabilities();
1770            assert!(caps.has_line_index, "Small file should have line index");
1771            assert_eq!(caps.byte_length, "line1\nline2\nline3".len());
1772            assert_eq!(caps.approximate_line_count, 3, "Should have 3 lines");
1773        }
1774
1775        #[test]
1776        fn test_position_conversions() {
1777            let mut state = EditorState::new(
1778                80,
1779                24,
1780                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1781                test_fs(),
1782            );
1783            state.buffer = Buffer::from_str_test("hello\nworld\ntest");
1784
1785            // Test ByteOffset -> offset
1786            let pos1 = DocumentPosition::ByteOffset(6);
1787            let offset1 = state.position_to_offset(pos1).unwrap();
1788            assert_eq!(offset1, 6);
1789
1790            // Test LineColumn -> offset
1791            let pos2 = DocumentPosition::LineColumn { line: 1, column: 0 };
1792            let offset2 = state.position_to_offset(pos2).unwrap();
1793            assert_eq!(offset2, 6, "Line 1, column 0 should be at byte 6");
1794
1795            // Test offset -> position (should return LineColumn for small files)
1796            let converted = state.offset_to_position(6);
1797            match converted {
1798                DocumentPosition::LineColumn { line, column } => {
1799                    assert_eq!(line, 1);
1800                    assert_eq!(column, 0);
1801                }
1802                _ => panic!("Expected LineColumn for small file"),
1803            }
1804        }
1805
1806        #[test]
1807        fn test_get_viewport_content() {
1808            let mut state = EditorState::new(
1809                80,
1810                24,
1811                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1812                test_fs(),
1813            );
1814            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
1815
1816            let content = state
1817                .get_viewport_content(DocumentPosition::ByteOffset(0), 3)
1818                .unwrap();
1819
1820            assert_eq!(content.lines.len(), 3);
1821            assert_eq!(content.lines[0].content, "line1");
1822            assert_eq!(content.lines[1].content, "line2");
1823            assert_eq!(content.lines[2].content, "line3");
1824            assert!(content.has_more);
1825        }
1826
1827        #[test]
1828        fn test_get_range() {
1829            let mut state = EditorState::new(
1830                80,
1831                24,
1832                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1833                test_fs(),
1834            );
1835            state.buffer = Buffer::from_str_test("hello world");
1836
1837            let text = state
1838                .get_range(
1839                    DocumentPosition::ByteOffset(0),
1840                    DocumentPosition::ByteOffset(5),
1841                )
1842                .unwrap();
1843            assert_eq!(text, "hello");
1844
1845            let text2 = state
1846                .get_range(
1847                    DocumentPosition::ByteOffset(6),
1848                    DocumentPosition::ByteOffset(11),
1849                )
1850                .unwrap();
1851            assert_eq!(text2, "world");
1852        }
1853
1854        #[test]
1855        fn test_get_line_content() {
1856            let mut state = EditorState::new(
1857                80,
1858                24,
1859                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1860                test_fs(),
1861            );
1862            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1863
1864            let line0 = state.get_line_content(0).unwrap();
1865            assert_eq!(line0, "line1");
1866
1867            let line1 = state.get_line_content(1).unwrap();
1868            assert_eq!(line1, "line2");
1869
1870            let line2 = state.get_line_content(2).unwrap();
1871            assert_eq!(line2, "line3");
1872        }
1873
1874        #[test]
1875        fn test_insert_delete() {
1876            let mut state = EditorState::new(
1877                80,
1878                24,
1879                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1880                test_fs(),
1881            );
1882            state.buffer = Buffer::from_str_test("hello world");
1883
1884            // Insert text
1885            let bytes_inserted = state
1886                .insert(DocumentPosition::ByteOffset(6), "beautiful ")
1887                .unwrap();
1888            assert_eq!(bytes_inserted, 10);
1889            assert_eq!(state.buffer.to_string().unwrap(), "hello beautiful world");
1890
1891            // Delete text
1892            state
1893                .delete(
1894                    DocumentPosition::ByteOffset(6),
1895                    DocumentPosition::ByteOffset(16),
1896                )
1897                .unwrap();
1898            assert_eq!(state.buffer.to_string().unwrap(), "hello world");
1899        }
1900
1901        #[test]
1902        fn test_replace() {
1903            let mut state = EditorState::new(
1904                80,
1905                24,
1906                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1907                test_fs(),
1908            );
1909            state.buffer = Buffer::from_str_test("hello world");
1910
1911            state
1912                .replace(
1913                    DocumentPosition::ByteOffset(0),
1914                    DocumentPosition::ByteOffset(5),
1915                    "hi",
1916                )
1917                .unwrap();
1918            assert_eq!(state.buffer.to_string().unwrap(), "hi world");
1919        }
1920
1921        #[test]
1922        fn test_find_matches() {
1923            let mut state = EditorState::new(
1924                80,
1925                24,
1926                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1927                test_fs(),
1928            );
1929            state.buffer = Buffer::from_str_test("hello world hello");
1930
1931            let matches = state.find_matches("hello", None).unwrap();
1932            assert_eq!(matches.len(), 2);
1933            assert_eq!(matches[0], 0);
1934            assert_eq!(matches[1], 12);
1935        }
1936
1937        #[test]
1938        fn test_prepare_for_render() {
1939            let mut state = EditorState::new(
1940                80,
1941                24,
1942                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1943                test_fs(),
1944            );
1945            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
1946
1947            // Should not panic - pass top_byte=0 and height=24 (typical viewport params)
1948            state.prepare_for_render(0, 24).unwrap();
1949        }
1950
1951        #[test]
1952        fn test_helper_get_text_range() {
1953            let mut state = EditorState::new(
1954                80,
1955                24,
1956                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1957                test_fs(),
1958            );
1959            state.buffer = Buffer::from_str_test("hello world");
1960
1961            // Test normal range
1962            let text = state.get_text_range(0, 5);
1963            assert_eq!(text, "hello");
1964
1965            // Test middle range
1966            let text2 = state.get_text_range(6, 11);
1967            assert_eq!(text2, "world");
1968        }
1969
1970        #[test]
1971        fn test_helper_get_line_at_offset() {
1972            let mut state = EditorState::new(
1973                80,
1974                24,
1975                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1976                test_fs(),
1977            );
1978            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1979
1980            // Get first line (offset 0)
1981            let (offset, content) = state.get_line_at_offset(0).unwrap();
1982            assert_eq!(offset, 0);
1983            assert_eq!(content, "line1");
1984
1985            // Get second line (offset in middle of line)
1986            let (offset2, content2) = state.get_line_at_offset(8).unwrap();
1987            assert_eq!(offset2, 6); // Line starts at byte 6
1988            assert_eq!(content2, "line2");
1989
1990            // Get last line
1991            let (offset3, content3) = state.get_line_at_offset(12).unwrap();
1992            assert_eq!(offset3, 12);
1993            assert_eq!(content3, "line3");
1994        }
1995
1996        #[test]
1997        fn test_helper_get_text_to_end_of_line() {
1998            let mut state = EditorState::new(
1999                80,
2000                24,
2001                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2002                test_fs(),
2003            );
2004            state.buffer = Buffer::from_str_test("hello world\nline2");
2005
2006            // From beginning of line
2007            let text = state.get_text_to_end_of_line(0).unwrap();
2008            assert_eq!(text, "hello world");
2009
2010            // From middle of line
2011            let text2 = state.get_text_to_end_of_line(6).unwrap();
2012            assert_eq!(text2, "world");
2013
2014            // From end of line
2015            let text3 = state.get_text_to_end_of_line(11).unwrap();
2016            assert_eq!(text3, "");
2017
2018            // From second line
2019            let text4 = state.get_text_to_end_of_line(12).unwrap();
2020            assert_eq!(text4, "line2");
2021        }
2022    }
2023
2024    // Virtual text integration tests
2025    mod virtual_text_integration_tests {
2026        use super::*;
2027        use crate::view::virtual_text::VirtualTextPosition;
2028        use ratatui::style::Style;
2029
2030        #[test]
2031        fn test_virtual_text_add_and_query() {
2032            let mut state = EditorState::new(
2033                80,
2034                24,
2035                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2036                test_fs(),
2037            );
2038            state.buffer = Buffer::from_str_test("hello world");
2039
2040            // Initialize marker list for buffer
2041            if !state.buffer.is_empty() {
2042                state.marker_list.adjust_for_insert(0, state.buffer.len());
2043            }
2044
2045            // Add virtual text at position 5 (after 'hello')
2046            let vtext_id = state.virtual_texts.add(
2047                &mut state.marker_list,
2048                5,
2049                ": string".to_string(),
2050                Style::default(),
2051                VirtualTextPosition::AfterChar,
2052                0,
2053            );
2054
2055            // Query should return the virtual text
2056            let results = state.virtual_texts.query_range(&state.marker_list, 0, 11);
2057            assert_eq!(results.len(), 1);
2058            assert_eq!(results[0].0, 5); // Position
2059            assert_eq!(results[0].1.text, ": string");
2060
2061            // Build lookup should work
2062            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 11);
2063            assert!(lookup.contains_key(&5));
2064            assert_eq!(lookup[&5].len(), 1);
2065            assert_eq!(lookup[&5][0].text, ": string");
2066
2067            // Clean up
2068            state.virtual_texts.remove(&mut state.marker_list, vtext_id);
2069            assert!(state.virtual_texts.is_empty());
2070        }
2071
2072        #[test]
2073        fn test_virtual_text_position_tracking_on_insert() {
2074            let mut state = EditorState::new(
2075                80,
2076                24,
2077                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2078                test_fs(),
2079            );
2080            state.buffer = Buffer::from_str_test("hello world");
2081
2082            // Initialize marker list for buffer
2083            if !state.buffer.is_empty() {
2084                state.marker_list.adjust_for_insert(0, state.buffer.len());
2085            }
2086
2087            // Add virtual text at position 6 (the 'w' in 'world')
2088            let _vtext_id = state.virtual_texts.add(
2089                &mut state.marker_list,
2090                6,
2091                "/*param*/".to_string(),
2092                Style::default(),
2093                VirtualTextPosition::BeforeChar,
2094                0,
2095            );
2096
2097            // Insert "beautiful " at position 6 using Event
2098            let mut cursors = Cursors::new();
2099            let cursor_id = cursors.primary_id();
2100            state.apply(
2101                &mut cursors,
2102                &Event::Insert {
2103                    position: 6,
2104                    text: "beautiful ".to_string(),
2105                    cursor_id,
2106                },
2107            );
2108
2109            // Virtual text should now be at position 16 (6 + 10)
2110            let results = state.virtual_texts.query_range(&state.marker_list, 0, 30);
2111            assert_eq!(results.len(), 1);
2112            assert_eq!(results[0].0, 16); // Position should have moved
2113            assert_eq!(results[0].1.text, "/*param*/");
2114        }
2115
2116        #[test]
2117        fn test_virtual_text_position_tracking_on_delete() {
2118            let mut state = EditorState::new(
2119                80,
2120                24,
2121                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2122                test_fs(),
2123            );
2124            state.buffer = Buffer::from_str_test("hello beautiful world");
2125
2126            // Initialize marker list for buffer
2127            if !state.buffer.is_empty() {
2128                state.marker_list.adjust_for_insert(0, state.buffer.len());
2129            }
2130
2131            // Add virtual text at position 16 (the 'w' in 'world')
2132            let _vtext_id = state.virtual_texts.add(
2133                &mut state.marker_list,
2134                16,
2135                ": string".to_string(),
2136                Style::default(),
2137                VirtualTextPosition::AfterChar,
2138                0,
2139            );
2140
2141            // Delete "beautiful " (positions 6-16) using Event
2142            let mut cursors = Cursors::new();
2143            let cursor_id = cursors.primary_id();
2144            state.apply(
2145                &mut cursors,
2146                &Event::Delete {
2147                    range: 6..16,
2148                    deleted_text: "beautiful ".to_string(),
2149                    cursor_id,
2150                },
2151            );
2152
2153            // Virtual text should now be at position 6
2154            let results = state.virtual_texts.query_range(&state.marker_list, 0, 20);
2155            assert_eq!(results.len(), 1);
2156            assert_eq!(results[0].0, 6); // Position should have moved back
2157            assert_eq!(results[0].1.text, ": string");
2158        }
2159
2160        #[test]
2161        fn test_multiple_virtual_texts_with_priorities() {
2162            let mut state = EditorState::new(
2163                80,
2164                24,
2165                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2166                test_fs(),
2167            );
2168            state.buffer = Buffer::from_str_test("let x = 5");
2169
2170            // Initialize marker list for buffer
2171            if !state.buffer.is_empty() {
2172                state.marker_list.adjust_for_insert(0, state.buffer.len());
2173            }
2174
2175            // Add type hint after 'x' (position 5)
2176            state.virtual_texts.add(
2177                &mut state.marker_list,
2178                5,
2179                ": i32".to_string(),
2180                Style::default(),
2181                VirtualTextPosition::AfterChar,
2182                0, // Lower priority - renders first
2183            );
2184
2185            // Add another hint at same position with higher priority
2186            state.virtual_texts.add(
2187                &mut state.marker_list,
2188                5,
2189                " /* inferred */".to_string(),
2190                Style::default(),
2191                VirtualTextPosition::AfterChar,
2192                10, // Higher priority - renders second
2193            );
2194
2195            // Build lookup - should have both, sorted by priority (lower first)
2196            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 10);
2197            assert!(lookup.contains_key(&5));
2198            let vtexts = &lookup[&5];
2199            assert_eq!(vtexts.len(), 2);
2200            // Lower priority first (like layer ordering)
2201            assert_eq!(vtexts[0].text, ": i32");
2202            assert_eq!(vtexts[1].text, " /* inferred */");
2203        }
2204
2205        #[test]
2206        fn test_virtual_text_clear() {
2207            let mut state = EditorState::new(
2208                80,
2209                24,
2210                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2211                test_fs(),
2212            );
2213            state.buffer = Buffer::from_str_test("test");
2214
2215            // Initialize marker list for buffer
2216            if !state.buffer.is_empty() {
2217                state.marker_list.adjust_for_insert(0, state.buffer.len());
2218            }
2219
2220            // Add multiple virtual texts
2221            state.virtual_texts.add(
2222                &mut state.marker_list,
2223                0,
2224                "hint1".to_string(),
2225                Style::default(),
2226                VirtualTextPosition::BeforeChar,
2227                0,
2228            );
2229            state.virtual_texts.add(
2230                &mut state.marker_list,
2231                2,
2232                "hint2".to_string(),
2233                Style::default(),
2234                VirtualTextPosition::AfterChar,
2235                0,
2236            );
2237
2238            assert_eq!(state.virtual_texts.len(), 2);
2239
2240            // Clear all
2241            state.virtual_texts.clear(&mut state.marker_list);
2242            assert!(state.virtual_texts.is_empty());
2243
2244            // Query should return nothing
2245            let results = state.virtual_texts.query_range(&state.marker_list, 0, 10);
2246            assert!(results.is_empty());
2247        }
2248    }
2249}