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