Skip to main content

fresh/app/
event_apply.rs

1//! Event application orchestrators on `Editor`.
2//!
3//! Every buffer mutation in this editor flows through one of:
4//!
5//! - `log_and_apply_event` — the canonical single-event path that logs
6//!   to the EventLog and applies the event.
7//! - `apply_event_to_active_buffer` — apply without logging, used by
8//!   replay paths.
9//! - `apply_events_as_bulk_edit` — batched multi-event application
10//!   under one undo boundary, used by replace-all, format-on-save, etc.
11//! - `trigger_plugin_hooks_for_event` — broadcast hook notifications
12//!   to plugins after an event applies.
13//!
14//! Plus three "scroll/viewport event" handlers that bypass the buffer
15//! entirely: handle_scroll_event, handle_set_viewport_event,
16//! handle_recenter_event. And a small invalidate_layouts_for_buffer
17//! helper.
18
19use lsp_types::TextDocumentContentChangeEvent;
20
21use crate::model::event::{BufferId, Event, LeafId};
22
23use super::types::EventLineInfo;
24use super::Editor;
25
26impl Editor {
27    /// All event applications MUST go through this method to ensure consistency.
28    /// Log an event and apply it to the active buffer.
29    /// For Delete events, captures displaced marker positions before applying
30    /// so undo can restore them to their exact original positions.
31    pub fn log_and_apply_event(&mut self, event: &Event) {
32        // Capture displaced markers before the event is applied
33        if let Event::Delete { range, .. } = event {
34            let displaced = self.active_state().capture_displaced_markers(range);
35            self.active_event_log_mut().append(event.clone());
36            if !displaced.is_empty() {
37                self.active_event_log_mut()
38                    .set_displaced_markers_on_last(displaced);
39            }
40        } else {
41            self.active_event_log_mut().append(event.clone());
42        }
43        self.apply_event_to_active_buffer(event);
44    }
45
46    pub fn apply_event_to_active_buffer(&mut self, event: &Event) {
47        // Handle View events at Editor level - View events go to SplitViewState, not EditorState
48        // This properly separates Buffer state from View state
49        match event {
50            Event::Scroll { line_offset } => {
51                self.handle_scroll_event(*line_offset);
52                return;
53            }
54            Event::SetViewport { top_line } => {
55                self.handle_set_viewport_event(*top_line);
56                return;
57            }
58            Event::Recenter => {
59                self.handle_recenter_event();
60                return;
61            }
62            _ => {}
63        }
64
65        // Any buffer-modifying event commits the user to this file, so promote
66        // it out of preview mode. Cursor moves and view-only events don't
67        // count — only real edits (Insert / Delete / BulkEdit, or a Batch
68        // containing any of those) flip the bit. Placed here (rather than
69        // in `log_and_apply_event`) because several edit paths bypass
70        // logging and call `apply_event_to_active_buffer` directly — notably
71        // `InsertChar` (single-character typing).
72        if event.modifies_buffer() {
73            self.promote_active_buffer_from_preview();
74        }
75
76        // IMPORTANT: Calculate LSP changes and line info BEFORE applying to buffer!
77        // The byte positions in the events are relative to the ORIGINAL buffer,
78        // so we must convert them to LSP positions before modifying the buffer.
79        let lsp_changes = self.collect_lsp_changes(event);
80
81        // Calculate line info for plugin hooks (using same pre-modification buffer state)
82        let line_info = self.calculate_event_line_info(event);
83
84        // 1. Apply the event to the buffer
85        // Borrow cursors from SplitViewState (sole source of truth) and state from buffers.
86        //
87        // Use the *effective* active split so events targeting a focused
88        // buffer-group panel land in the panel's own split view state, not
89        // the group host's. Without this, MoveCursor events for a focused
90        // panel would try to look up the panel buffer's keyed state in the
91        // host split (which doesn't have it) and panic on unwrap.
92        //
93        // Debug-only check: verify the pane-buffer invariant before
94        // dereferencing. Any mismatch means a write path skipped
95        // `Editor::set_pane_buffer` (see `active_focus.rs`); we want
96        // that to fail with a clear message in tests rather than
97        // surfacing as a bare `Option::unwrap` panic in production
98        // (issue #1620).
99        {
100            let split_id = self.effective_active_split();
101            let active_buf = self.active_buffer();
102            debug_assert!(
103                self.split_view_states
104                    .get(&split_id)
105                    .is_some_and(|vs| vs.keyed_states.contains_key(&active_buf)),
106                "pane-buffer invariant violated: split {:?} resolves to buffer {:?} \
107                 but that split's keyed_states has no entry for it. Some write path \
108                 bypassed Editor::set_pane_buffer; see active_focus.rs / issue #1620.",
109                split_id,
110                active_buf,
111            );
112            let cursors = &mut self
113                .split_view_states
114                .get_mut(&split_id)
115                .unwrap()
116                .keyed_states
117                .get_mut(&active_buf)
118                .unwrap()
119                .cursors;
120            let state = self.buffers.get_mut(&active_buf).unwrap();
121            state.apply(cursors, event);
122        }
123
124        // 1c. Invalidate layouts for all views of this buffer after content changes
125        // Note: recovery_pending is set automatically by the buffer on edits
126        match event {
127            Event::Insert { .. } | Event::Delete { .. } | Event::BulkEdit { .. } => {
128                self.invalidate_layouts_for_buffer(self.active_buffer());
129                self.schedule_semantic_tokens_full_refresh(self.active_buffer());
130                self.schedule_folding_ranges_refresh(self.active_buffer());
131            }
132            Event::Batch { events, .. } => {
133                let has_edits = events
134                    .iter()
135                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
136                if has_edits {
137                    self.invalidate_layouts_for_buffer(self.active_buffer());
138                    self.schedule_semantic_tokens_full_refresh(self.active_buffer());
139                    self.schedule_folding_ranges_refresh(self.active_buffer());
140                }
141            }
142            _ => {}
143        }
144
145        // 2. Adjust cursors in other splits that share the same buffer
146        self.adjust_other_split_cursors_for_event(event);
147
148        // 3. Clear search highlights on edit (Insert/Delete events)
149        // This preserves highlights while navigating but clears them when modifying text
150        // EXCEPT during interactive replace where we want to keep highlights visible
151        let in_interactive_replace = self.interactive_replace_state.is_some();
152
153        // Note: We intentionally do NOT clear search overlays on buffer modification.
154        // Overlays have markers that automatically track position changes through edits,
155        // which allows F3/Shift+F3 to find matches at their updated positions.
156        // The visual highlights may be on text that no longer matches the query,
157        // but that's acceptable - user can see where original matches were.
158        let _ = in_interactive_replace; // silence unused warning
159
160        // 3. Trigger plugin hooks for this event (with pre-calculated line info)
161        self.trigger_plugin_hooks_for_event(event, line_info);
162
163        // 4. Notify LSP of the change using pre-calculated positions
164        // For BulkEdit events (undo/redo of code actions, renames, etc.),
165        // collect_lsp_changes returns empty because there are no incremental byte
166        // positions to convert — BulkEdit restores a tree snapshot.  Send a
167        // full-document replacement so the LSP server stays in sync.
168        if lsp_changes.is_empty() && event.modifies_buffer() {
169            if let Some(full_text) = self.active_state().buffer.to_string() {
170                let full_change = vec![TextDocumentContentChangeEvent {
171                    range: None,
172                    range_length: None,
173                    text: full_text,
174                }];
175                self.send_lsp_changes_for_buffer(self.active_buffer(), full_change);
176            }
177        } else {
178            self.send_lsp_changes_for_buffer(self.active_buffer(), lsp_changes);
179        }
180    }
181
182    /// Apply multiple Insert/Delete events efficiently using bulk edit optimization.
183    ///
184    /// This avoids O(n²) complexity by:
185    /// 1. Converting events to (position, delete_len, insert_text) tuples
186    /// 2. Applying all edits in a single tree pass via apply_bulk_edits
187    /// 3. Creating a BulkEdit event for undo (stores tree snapshot via Arc clone = O(1))
188    ///
189    /// # Arguments
190    /// * `events` - Vec of Insert/Delete events (sorted by position descending for correct application)
191    /// * `description` - Description for the undo log
192    ///
193    /// # Returns
194    /// The BulkEdit event that was applied, for tracking purposes
195    pub fn apply_events_as_bulk_edit(
196        &mut self,
197        events: Vec<Event>,
198        description: String,
199    ) -> Option<Event> {
200        use crate::model::event::CursorId;
201
202        // Check if any events modify the buffer
203        let has_buffer_mods = events
204            .iter()
205            .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
206
207        if !has_buffer_mods {
208            // No buffer modifications - use regular Batch
209            return None;
210        }
211
212        // Multi-cursor edits and code-action rewrites go through this path
213        // (not `apply_event_to_active_buffer`). Promote any preview tab
214        // here too so the invariant "edited buffer is never preview"
215        // holds regardless of which edit path runs.
216        self.promote_active_buffer_from_preview();
217
218        let active_buf = self.active_buffer();
219        let split_id = self.split_manager.active_split();
220
221        // Capture old cursor states from SplitViewState (sole source of truth)
222        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
223            .split_view_states
224            .get(&split_id)
225            .unwrap()
226            .keyed_states
227            .get(&active_buf)
228            .unwrap()
229            .cursors
230            .iter()
231            .map(|(id, c)| (id, c.position, c.anchor))
232            .collect();
233
234        let state = self.buffers.get_mut(&active_buf).unwrap();
235
236        // Snapshot buffer state for undo (piece tree + buffers)
237        let old_snapshot = state.buffer.snapshot_buffer_state();
238
239        // Convert events to edit tuples: (position, delete_len, insert_text)
240        // Events must be sorted by position descending (later positions first)
241        // This ensures earlier edits don't shift positions of later edits
242        let mut edits: Vec<(usize, usize, String)> = Vec::new();
243
244        for event in &events {
245            match event {
246                Event::Insert { position, text, .. } => {
247                    edits.push((*position, 0, text.clone()));
248                }
249                Event::Delete { range, .. } => {
250                    edits.push((range.start, range.len(), String::new()));
251                }
252                _ => {}
253            }
254        }
255
256        // Sort edits by position descending (required by apply_bulk_edits)
257        edits.sort_by(|a, b| b.0.cmp(&a.0));
258
259        // Convert to references for apply_bulk_edits
260        let edit_refs: Vec<(usize, usize, &str)> = edits
261            .iter()
262            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
263            .collect();
264
265        // Snapshot displaced markers before edits so undo can restore them exactly.
266        let displaced_markers = state.capture_displaced_markers_bulk(&edits);
267
268        // Apply bulk edits
269        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
270
271        // Convert edit list to lengths-only for marker replay.
272        // Merge edits at the same position into a single (pos, del_len, ins_len)
273        // tuple. This is necessary because delete+insert at the same position
274        // (e.g., line move: delete block, insert rearranged block) should be
275        // treated as a replacement, not two independent adjustments.
276        let edit_lengths: Vec<(usize, usize, usize)> = {
277            let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
278            for (pos, del_len, text) in &edits {
279                if let Some(last) = lengths.last_mut() {
280                    if last.0 == *pos {
281                        // Same position: merge del and ins lengths
282                        last.1 += del_len;
283                        last.2 += text.len();
284                        continue;
285                    }
286                }
287                lengths.push((*pos, *del_len, text.len()));
288            }
289            lengths
290        };
291
292        // Adjust markers and margins using the merged edit lengths.
293        // Using merged edits (net delta for same-position replacements) avoids
294        // the marker-at-boundary problem where sequential delete+insert at the
295        // same position pushes markers incorrectly.
296        for &(pos, del_len, ins_len) in &edit_lengths {
297            if del_len > 0 && ins_len > 0 {
298                // Replacement: adjust by net delta only
299                if ins_len > del_len {
300                    state.marker_list.adjust_for_insert(pos, ins_len - del_len);
301                    state.margins.adjust_for_insert(pos, ins_len - del_len);
302                } else if del_len > ins_len {
303                    state.marker_list.adjust_for_delete(pos, del_len - ins_len);
304                    state.margins.adjust_for_delete(pos, del_len - ins_len);
305                }
306                // Equal: net delta 0, no adjustment needed
307            } else if del_len > 0 {
308                state.marker_list.adjust_for_delete(pos, del_len);
309                state.margins.adjust_for_delete(pos, del_len);
310            } else if ins_len > 0 {
311                state.marker_list.adjust_for_insert(pos, ins_len);
312                state.margins.adjust_for_insert(pos, ins_len);
313            }
314        }
315
316        // Snapshot buffer state after edits (for redo)
317        let new_snapshot = state.buffer.snapshot_buffer_state();
318
319        // Calculate new cursor positions based on events
320        // Process cursor movements from the original events
321        let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
322
323        // Calculate position adjustments from edits (sorted ascending by position)
324        // Each entry is (edit_position, delta) where delta = insert_len - delete_len
325        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
326        for (pos, del_len, text) in &edits {
327            let delta = text.len() as isize - *del_len as isize;
328            position_deltas.push((*pos, delta));
329        }
330        position_deltas.sort_by_key(|(pos, _)| *pos);
331
332        // Helper: calculate cumulative shift for a position based on edits at lower positions
333        let calc_shift = |original_pos: usize| -> isize {
334            let mut shift: isize = 0;
335            for (edit_pos, delta) in &position_deltas {
336                if *edit_pos < original_pos {
337                    shift += delta;
338                }
339            }
340            shift
341        };
342
343        // Apply adjustments to cursor positions
344        // First check for explicit MoveCursor events (e.g., from indent operations)
345        // These take precedence over implicit cursor updates from Insert/Delete
346        for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
347            let mut found_move_cursor = false;
348            // Save original position before any modifications - needed for shift calculation
349            let original_pos = *pos;
350
351            // Check if this cursor has an Insert at its original position (auto-close pattern).
352            // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
353            // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
354            let insert_at_cursor_pos = events.iter().any(|e| {
355                matches!(e, Event::Insert { position, cursor_id: c, .. }
356                    if *c == *cursor_id && *position == original_pos)
357            });
358
359            // First pass: look for explicit MoveCursor events for this cursor
360            for event in &events {
361                if let Event::MoveCursor {
362                    cursor_id: event_cursor,
363                    new_position,
364                    new_anchor,
365                    ..
366                } = event
367                {
368                    if event_cursor == cursor_id {
369                        // Only adjust for shifts if the Insert was at the cursor's original position
370                        // (like auto-close). For other operations (like indent where Insert is at
371                        // line start), the MoveCursor already accounts for the shift.
372                        let shift = if insert_at_cursor_pos {
373                            calc_shift(original_pos)
374                        } else {
375                            0
376                        };
377                        *pos = (*new_position as isize + shift).max(0) as usize;
378                        *anchor = *new_anchor;
379                        found_move_cursor = true;
380                    }
381                }
382            }
383
384            // If no explicit MoveCursor, derive position from Insert/Delete
385            if !found_move_cursor {
386                let mut found_edit = false;
387                for event in &events {
388                    match event {
389                        Event::Insert {
390                            position,
391                            text,
392                            cursor_id: event_cursor,
393                        } if event_cursor == cursor_id => {
394                            // For insert, cursor moves to end of inserted text
395                            // Account for shifts from edits at lower positions
396                            let shift = calc_shift(*position);
397                            let adjusted_pos = (*position as isize + shift).max(0) as usize;
398                            *pos = adjusted_pos.saturating_add(text.len());
399                            *anchor = None;
400                            found_edit = true;
401                        }
402                        Event::Delete {
403                            range,
404                            cursor_id: event_cursor,
405                            ..
406                        } if event_cursor == cursor_id => {
407                            // For delete, cursor moves to start of deleted range
408                            // Account for shifts from edits at lower positions
409                            let shift = calc_shift(range.start);
410                            *pos = (range.start as isize + shift).max(0) as usize;
411                            *anchor = None;
412                            found_edit = true;
413                        }
414                        _ => {}
415                    }
416                }
417
418                // If this cursor had no events at all (e.g., cursor at end of buffer
419                // during Delete, or at start during Backspace), still adjust its position
420                // for shifts caused by other cursors' edits.
421                if !found_edit {
422                    let shift = calc_shift(original_pos);
423                    *pos = (original_pos as isize + shift).max(0) as usize;
424                }
425            }
426        }
427
428        // Update cursors in SplitViewState (sole source of truth)
429        {
430            let cursors = &mut self
431                .split_view_states
432                .get_mut(&split_id)
433                .unwrap()
434                .keyed_states
435                .get_mut(&active_buf)
436                .unwrap()
437                .cursors;
438            for (cursor_id, position, anchor) in &new_cursors {
439                if let Some(cursor) = cursors.get_mut(*cursor_id) {
440                    cursor.position = *position;
441                    cursor.anchor = *anchor;
442                }
443            }
444        }
445
446        // Invalidate highlighter
447        self.buffers
448            .get_mut(&active_buf)
449            .unwrap()
450            .highlighter
451            .invalidate_all();
452
453        // Create BulkEdit event with both buffer snapshots
454        let bulk_edit = Event::BulkEdit {
455            old_snapshot: Some(old_snapshot),
456            new_snapshot: Some(new_snapshot),
457            old_cursors,
458            new_cursors,
459            description,
460            edits: edit_lengths,
461            displaced_markers,
462        };
463
464        // Post-processing (layout invalidation, split cursor sync, etc.)
465        self.invalidate_layouts_for_buffer(self.active_buffer());
466        self.adjust_other_split_cursors_for_event(&bulk_edit);
467        // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
468
469        // Notify LSP of the change using full document replacement.
470        // Bulk edits combine multiple Delete+Insert operations into a single tree pass,
471        // so computing individual incremental LSP changes is not feasible. Instead,
472        // send the full document content which is always correct.
473        let buffer_id = self.active_buffer();
474        let full_content_change = self
475            .buffers
476            .get(&buffer_id)
477            .and_then(|s| s.buffer.to_string())
478            .map(|text| {
479                vec![TextDocumentContentChangeEvent {
480                    range: None,
481                    range_length: None,
482                    text,
483                }]
484            })
485            .unwrap_or_default();
486        if !full_content_change.is_empty() {
487            self.send_lsp_changes_for_buffer(buffer_id, full_content_change);
488        }
489
490        Some(bulk_edit)
491    }
492
493    /// Trigger plugin hooks for an event (if any)
494    /// line_info contains pre-calculated line numbers from BEFORE buffer modification
495    fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
496        let buffer_id = self.active_buffer();
497
498        // Convert event to hook args and fire the appropriate hook
499        let mut cursor_changed_lines = false;
500        let hook_args = match event {
501            Event::Insert { position, text, .. } => {
502                let insert_position = *position;
503                let insert_len = text.len();
504
505                // Adjust byte ranges for the insertion
506                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
507                    // Collect adjusted ranges:
508                    // - Ranges ending before insert: keep unchanged
509                    // - Ranges containing insert point: remove (content changed)
510                    // - Ranges starting after insert: shift by insert_len
511                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
512                        .iter()
513                        .filter_map(|&(start, end)| {
514                            if end <= insert_position {
515                                // Range ends before insert - unchanged
516                                Some((start, end))
517                            } else if start >= insert_position {
518                                // Range starts at or after insert - shift forward
519                                Some((start + insert_len, end + insert_len))
520                            } else {
521                                // Range contains insert point - invalidate
522                                None
523                            }
524                        })
525                        .collect();
526                    *seen = adjusted;
527                }
528
529                Some((
530                    "after_insert",
531                    crate::services::plugins::hooks::HookArgs::AfterInsert {
532                        buffer_id,
533                        position: *position,
534                        text: text.clone(),
535                        // Byte range of the affected area
536                        affected_start: insert_position,
537                        affected_end: insert_position + insert_len,
538                        // Line info from pre-modification buffer
539                        start_line: line_info.start_line,
540                        end_line: line_info.end_line,
541                        lines_added: line_info.line_delta.max(0) as usize,
542                    },
543                ))
544            }
545            Event::Delete {
546                range,
547                deleted_text,
548                ..
549            } => {
550                let delete_start = range.start;
551
552                // Adjust byte ranges for the deletion
553                let delete_end = range.end;
554                let delete_len = delete_end - delete_start;
555                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
556                    // Collect adjusted ranges:
557                    // - Ranges ending before delete start: keep unchanged
558                    // - Ranges overlapping deletion: remove (content changed)
559                    // - Ranges starting after delete end: shift backward by delete_len
560                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
561                        .iter()
562                        .filter_map(|&(start, end)| {
563                            if end <= delete_start {
564                                // Range ends before delete - unchanged
565                                Some((start, end))
566                            } else if start >= delete_end {
567                                // Range starts after delete - shift backward
568                                Some((start - delete_len, end - delete_len))
569                            } else {
570                                // Range overlaps deletion - invalidate
571                                None
572                            }
573                        })
574                        .collect();
575                    *seen = adjusted;
576                }
577
578                Some((
579                    "after_delete",
580                    crate::services::plugins::hooks::HookArgs::AfterDelete {
581                        buffer_id,
582                        start: range.start,
583                        end: range.end,
584                        deleted_text: deleted_text.clone(),
585                        // Byte position and length of deleted content
586                        affected_start: delete_start,
587                        deleted_len: deleted_text.len(),
588                        // Line info from pre-modification buffer
589                        start_line: line_info.start_line,
590                        end_line: line_info.end_line,
591                        lines_removed: (-line_info.line_delta).max(0) as usize,
592                    },
593                ))
594            }
595            Event::Batch { events, .. } => {
596                // Fire hooks for each event in the batch
597                // Note: For batches, line info is approximate since buffer already modified
598                // Individual events will use the passed line_info which covers the whole batch
599                for e in events {
600                    // Use default line info for sub-events - they share the batch's line_info
601                    // This is a simplification; proper tracking would need per-event pre-calculation
602                    let sub_line_info = self.calculate_event_line_info(e);
603                    self.trigger_plugin_hooks_for_event(e, sub_line_info);
604                }
605                None
606            }
607            Event::MoveCursor {
608                cursor_id,
609                old_position,
610                new_position,
611                ..
612            } => {
613                // Get line numbers for old and new positions (1-indexed for plugins)
614                let old_line = self.active_state().buffer.get_line_number(*old_position) + 1;
615                let line = self.active_state().buffer.get_line_number(*new_position) + 1;
616                cursor_changed_lines = old_line != line;
617                let text_props = self
618                    .active_state()
619                    .text_properties
620                    .get_at(*new_position)
621                    .into_iter()
622                    .map(|tp| tp.properties.clone())
623                    .collect();
624                Some((
625                    "cursor_moved",
626                    crate::services::plugins::hooks::HookArgs::CursorMoved {
627                        buffer_id,
628                        cursor_id: *cursor_id,
629                        old_position: *old_position,
630                        new_position: *new_position,
631                        line,
632                        text_properties: text_props,
633                    },
634                ))
635            }
636            _ => None,
637        };
638
639        // Fire the hook to TypeScript plugins
640        if let Some((hook_name, ref args)) = hook_args {
641            // Update the full plugin state snapshot BEFORE firing the hook
642            // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
643            // Without this, there's a race condition where the async hook might read stale data
644            #[cfg(feature = "plugins")]
645            self.update_plugin_state_snapshot();
646
647            self.plugin_manager.run_hook(hook_name, args.clone());
648        }
649
650        // After inter-line cursor_moved, proactively refresh lines so
651        // cursor-dependent conceals (e.g. emphasis auto-expose in compose
652        // mode tables) update in the same frame. Without this, there's a
653        // one-frame lag: the cursor_moved hook fires async to the plugin
654        // which calls refreshLines() back, but that round-trip means the
655        // first render after the cursor move still shows stale conceals.
656        //
657        // Only refresh on inter-line movement: intra-line moves (e.g.
658        // Left/Right within a row) don't change which row is auto-exposed,
659        // and the plugin's async refreshLines() handles span-level changes.
660        if cursor_changed_lines {
661            self.handle_refresh_lines(buffer_id);
662        }
663    }
664
665    /// Handle scroll events using the SplitViewState's viewport
666    ///
667    /// View events (like Scroll) go to SplitViewState, not EditorState.
668    /// This correctly handles scroll limits when view transforms inject headers.
669    /// Also syncs to EditorState.viewport for the active split (used in rendering).
670    pub(super) fn handle_scroll_event(&mut self, line_offset: isize) {
671        use crate::view::ui::view_pipeline::ViewLineIterator;
672
673        let active_split = self.split_manager.active_split();
674
675        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
676        // Mark both splits to skip ensure_visible so cursor doesn't override scroll
677        // The sync_scroll_groups() at render time will sync the other split
678        if let Some(group) = self
679            .scroll_sync_manager
680            .find_group_for_split(active_split.into())
681        {
682            let left = group.left_split;
683            let right = group.right_split;
684            if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
685                vs.viewport.set_skip_ensure_visible();
686            }
687            if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
688                vs.viewport.set_skip_ensure_visible();
689            }
690            // Continue to scroll the active split normally below
691        }
692
693        // Fall back to simple sync_group (same delta to all splits)
694        let sync_group = self
695            .split_view_states
696            .get(&active_split)
697            .and_then(|vs| vs.sync_group);
698        let splits_to_scroll = if let Some(group_id) = sync_group {
699            self.split_manager
700                .get_splits_in_group(group_id, &self.split_view_states)
701        } else {
702            vec![active_split]
703        };
704
705        for split_id in splits_to_scroll {
706            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
707                id
708            } else {
709                continue;
710            };
711            let tab_size = self.config.editor.tab_size;
712
713            // Get view_transform tokens from SplitViewState (if any)
714            let view_transform_tokens = self
715                .split_view_states
716                .get(&split_id)
717                .and_then(|vs| vs.view_transform.as_ref())
718                .map(|vt| vt.tokens.clone());
719
720            // Get mutable references to both buffer and view state
721            if let Some(state) = self.buffers.get_mut(&buffer_id) {
722                // Collect plugin soft-break positions BEFORE re-borrowing the
723                // buffer so the viewport's visual-row math matches the renderer
724                // (avoids the wheel-absorbed / empty-bottom mouse-scroll bugs
725                // for compose-mode markdown — see scroll_down_visual).
726                let soft_breaks = state.collect_soft_break_positions();
727                let virtual_lines = state.collect_virtual_line_positions();
728                let buffer = &mut state.buffer;
729                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
730                    if let Some(tokens) = view_transform_tokens {
731                        // Use view-aware scrolling with the transform's tokens
732                        let view_lines: Vec<_> =
733                            ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
734                        view_state
735                            .viewport
736                            .scroll_view_lines(&view_lines, line_offset);
737                    } else {
738                        // No view transform - use traditional buffer-based scrolling
739                        if line_offset > 0 {
740                            view_state.viewport.scroll_down(
741                                buffer,
742                                &soft_breaks,
743                                &virtual_lines,
744                                line_offset as usize,
745                            );
746                        } else {
747                            view_state.viewport.scroll_up(
748                                buffer,
749                                &soft_breaks,
750                                &virtual_lines,
751                                line_offset.unsigned_abs(),
752                            );
753                        }
754                    }
755                    // Mark to skip ensure_visible on next render so the scroll isn't undone
756                    view_state.viewport.set_skip_ensure_visible();
757                }
758            }
759        }
760    }
761
762    /// Handle SetViewport event using SplitViewState's viewport
763    fn handle_set_viewport_event(&mut self, top_line: usize) {
764        let active_split = self.split_manager.active_split();
765
766        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
767        // If so, set the group's scroll_line and let render sync the viewports
768        if self
769            .scroll_sync_manager
770            .is_split_synced(active_split.into())
771        {
772            if let Some(group) = self
773                .scroll_sync_manager
774                .find_group_for_split_mut(active_split.into())
775            {
776                // Convert line to left buffer space if coming from right split
777                let scroll_line = if group.is_left_split(active_split.into()) {
778                    top_line
779                } else {
780                    group.right_to_left_line(top_line)
781                };
782                group.set_scroll_line(scroll_line);
783            }
784
785            // Mark both splits to skip ensure_visible
786            if let Some(group) = self
787                .scroll_sync_manager
788                .find_group_for_split(active_split.into())
789            {
790                let left = group.left_split;
791                let right = group.right_split;
792                if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
793                    vs.viewport.set_skip_ensure_visible();
794                }
795                if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
796                    vs.viewport.set_skip_ensure_visible();
797                }
798            }
799            return;
800        }
801
802        // Fall back to simple sync_group (same line to all splits)
803        let sync_group = self
804            .split_view_states
805            .get(&active_split)
806            .and_then(|vs| vs.sync_group);
807        let splits_to_scroll = if let Some(group_id) = sync_group {
808            self.split_manager
809                .get_splits_in_group(group_id, &self.split_view_states)
810        } else {
811            vec![active_split]
812        };
813
814        for split_id in splits_to_scroll {
815            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
816                id
817            } else {
818                continue;
819            };
820
821            if let Some(state) = self.buffers.get_mut(&buffer_id) {
822                let buffer = &mut state.buffer;
823                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
824                    view_state.viewport.scroll_to(buffer, top_line);
825                    // Mark to skip ensure_visible on next render so the scroll isn't undone
826                    view_state.viewport.set_skip_ensure_visible();
827                }
828            }
829        }
830    }
831
832    /// Handle Recenter event using SplitViewState's viewport
833    fn handle_recenter_event(&mut self) {
834        let active_split = self.split_manager.active_split();
835
836        // Find other splits in the same sync group if any
837        let sync_group = self
838            .split_view_states
839            .get(&active_split)
840            .and_then(|vs| vs.sync_group);
841        let splits_to_recenter = if let Some(group_id) = sync_group {
842            self.split_manager
843                .get_splits_in_group(group_id, &self.split_view_states)
844        } else {
845            vec![active_split]
846        };
847
848        for split_id in splits_to_recenter {
849            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
850                id
851            } else {
852                continue;
853            };
854
855            if let Some(state) = self.buffers.get_mut(&buffer_id) {
856                let buffer = &mut state.buffer;
857                let view_state = self.split_view_states.get_mut(&split_id);
858
859                if let Some(view_state) = view_state {
860                    // Recenter viewport on cursor
861                    let cursor = *view_state.cursors.primary();
862                    let viewport_height = view_state.viewport.visible_line_count();
863                    let target_rows_from_top = viewport_height / 2;
864
865                    // Move backwards from cursor position target_rows_from_top lines
866                    let mut iter = buffer.line_iterator(cursor.position, 80);
867                    for _ in 0..target_rows_from_top {
868                        if iter.prev().is_none() {
869                            break;
870                        }
871                    }
872                    let new_top_byte = iter.current_position();
873                    view_state.viewport.top_byte = new_top_byte;
874                    // Mark to skip ensure_visible on next render so the scroll isn't undone
875                    view_state.viewport.set_skip_ensure_visible();
876                }
877            }
878        }
879    }
880
881    /// Invalidate layouts for all splits viewing a specific buffer
882    ///
883    /// Called after buffer content changes (Insert/Delete) to mark
884    /// layouts as dirty, forcing rebuild on next access.
885    /// Also clears any cached view transform since its token source_offsets
886    /// become stale after buffer edits.
887    pub(super) fn invalidate_layouts_for_buffer(&mut self, buffer_id: BufferId) {
888        // Find all splits that display this buffer
889        let splits_for_buffer = self.split_manager.splits_for_buffer(buffer_id);
890
891        // Invalidate layout and clear stale view transform for each split
892        for split_id in splits_for_buffer {
893            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
894                view_state.invalidate_layout();
895                // Clear cached view transform — its token source_offsets are from
896                // before the edit and would cause conceals to be applied at wrong positions.
897                // The view_transform_request hook will fire on the next render to rebuild it.
898                view_state.view_transform = None;
899                // Mark as stale so that any pending SubmitViewTransform commands
900                // (from a previous view_transform_request) are rejected.
901                view_state.view_transform_stale = true;
902            }
903        }
904    }
905}