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        // Use `effective_active_split` rather than `split_manager.active_split()`
220        // so we get the leaf whose `SplitViewState` actually owns the active
221        // buffer's keyed_states. They diverge whenever a buffer-group panel
222        // is focused (e.g. the Theme Editor): `active_buffer()` resolves to
223        // the inner panel's buffer via `effective_active_pair`, while the
224        // outer split has no entry for it. Without this, a paste with >1
225        // event in the Theme Editor unwraps `None` and panics.
226        let split_id = self.effective_active_split();
227
228        // Capture old cursor states from SplitViewState (sole source of truth)
229        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
230            .split_view_states
231            .get(&split_id)
232            .unwrap()
233            .keyed_states
234            .get(&active_buf)
235            .unwrap()
236            .cursors
237            .iter()
238            .map(|(id, c)| (id, c.position, c.anchor))
239            .collect();
240
241        let state = self.buffers.get_mut(&active_buf).unwrap();
242
243        // Snapshot buffer state for undo (piece tree + buffers)
244        let old_snapshot = state.buffer.snapshot_buffer_state();
245
246        // Convert events to edit tuples: (position, delete_len, insert_text)
247        // Events must be sorted by position descending (later positions first)
248        // This ensures earlier edits don't shift positions of later edits
249        let mut edits: Vec<(usize, usize, String)> = Vec::new();
250
251        for event in &events {
252            match event {
253                Event::Insert { position, text, .. } => {
254                    edits.push((*position, 0, text.clone()));
255                }
256                Event::Delete { range, .. } => {
257                    edits.push((range.start, range.len(), String::new()));
258                }
259                _ => {}
260            }
261        }
262
263        // Sort edits by position descending (required by apply_bulk_edits)
264        edits.sort_by(|a, b| b.0.cmp(&a.0));
265
266        // Convert to references for apply_bulk_edits
267        let edit_refs: Vec<(usize, usize, &str)> = edits
268            .iter()
269            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
270            .collect();
271
272        // Snapshot displaced markers before edits so undo can restore them exactly.
273        let displaced_markers = state.capture_displaced_markers_bulk(&edits);
274
275        // Apply bulk edits
276        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
277
278        // Convert edit list to lengths-only for marker replay.
279        // Merge edits at the same position into a single (pos, del_len, ins_len)
280        // tuple. This is necessary because delete+insert at the same position
281        // (e.g., line move: delete block, insert rearranged block) should be
282        // treated as a replacement, not two independent adjustments.
283        let edit_lengths: Vec<(usize, usize, usize)> = {
284            let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
285            for (pos, del_len, text) in &edits {
286                if let Some(last) = lengths.last_mut() {
287                    if last.0 == *pos {
288                        // Same position: merge del and ins lengths
289                        last.1 += del_len;
290                        last.2 += text.len();
291                        continue;
292                    }
293                }
294                lengths.push((*pos, *del_len, text.len()));
295            }
296            lengths
297        };
298
299        // Adjust markers and margins using the merged edit lengths.
300        // Using merged edits (net delta for same-position replacements) avoids
301        // the marker-at-boundary problem where sequential delete+insert at the
302        // same position pushes markers incorrectly.
303        for &(pos, del_len, ins_len) in &edit_lengths {
304            if del_len > 0 && ins_len > 0 {
305                // Replacement: adjust by net delta only
306                if ins_len > del_len {
307                    state.marker_list.adjust_for_insert(pos, ins_len - del_len);
308                    state.margins.adjust_for_insert(pos, ins_len - del_len);
309                } else if del_len > ins_len {
310                    state.marker_list.adjust_for_delete(pos, del_len - ins_len);
311                    state.margins.adjust_for_delete(pos, del_len - ins_len);
312                }
313                // Equal: net delta 0, no adjustment needed
314            } else if del_len > 0 {
315                state.marker_list.adjust_for_delete(pos, del_len);
316                state.margins.adjust_for_delete(pos, del_len);
317            } else if ins_len > 0 {
318                state.marker_list.adjust_for_insert(pos, ins_len);
319                state.margins.adjust_for_insert(pos, ins_len);
320            }
321        }
322
323        // Snapshot buffer state after edits (for redo)
324        let new_snapshot = state.buffer.snapshot_buffer_state();
325
326        // Calculate new cursor positions based on events
327        // Process cursor movements from the original events
328        let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
329
330        // Calculate position adjustments from edits (sorted ascending by position)
331        // Each entry is (edit_position, delta) where delta = insert_len - delete_len
332        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
333        for (pos, del_len, text) in &edits {
334            let delta = text.len() as isize - *del_len as isize;
335            position_deltas.push((*pos, delta));
336        }
337        position_deltas.sort_by_key(|(pos, _)| *pos);
338
339        // Helper: calculate cumulative shift for a position based on edits at lower positions
340        let calc_shift = |original_pos: usize| -> isize {
341            let mut shift: isize = 0;
342            for (edit_pos, delta) in &position_deltas {
343                if *edit_pos < original_pos {
344                    shift += delta;
345                }
346            }
347            shift
348        };
349
350        // Apply adjustments to cursor positions
351        // First check for explicit MoveCursor events (e.g., from indent operations)
352        // These take precedence over implicit cursor updates from Insert/Delete
353        for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
354            let mut found_move_cursor = false;
355            // Save original position before any modifications - needed for shift calculation
356            let original_pos = *pos;
357
358            // Check if this cursor has an Insert at its original position (auto-close pattern).
359            // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
360            // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
361            let insert_at_cursor_pos = events.iter().any(|e| {
362                matches!(e, Event::Insert { position, cursor_id: c, .. }
363                    if *c == *cursor_id && *position == original_pos)
364            });
365
366            // First pass: look for explicit MoveCursor events for this cursor
367            for event in &events {
368                if let Event::MoveCursor {
369                    cursor_id: event_cursor,
370                    new_position,
371                    new_anchor,
372                    ..
373                } = event
374                {
375                    if event_cursor == cursor_id {
376                        // Only adjust for shifts if the Insert was at the cursor's original position
377                        // (like auto-close). For other operations (like indent where Insert is at
378                        // line start), the MoveCursor already accounts for the shift.
379                        let shift = if insert_at_cursor_pos {
380                            calc_shift(original_pos)
381                        } else {
382                            0
383                        };
384                        *pos = (*new_position as isize + shift).max(0) as usize;
385                        *anchor = *new_anchor;
386                        found_move_cursor = true;
387                    }
388                }
389            }
390
391            // If no explicit MoveCursor, derive position from Insert/Delete
392            if !found_move_cursor {
393                let mut found_edit = false;
394                for event in &events {
395                    match event {
396                        Event::Insert {
397                            position,
398                            text,
399                            cursor_id: event_cursor,
400                        } if event_cursor == cursor_id => {
401                            // For insert, cursor moves to end of inserted text
402                            // Account for shifts from edits at lower positions
403                            let shift = calc_shift(*position);
404                            let adjusted_pos = (*position as isize + shift).max(0) as usize;
405                            *pos = adjusted_pos.saturating_add(text.len());
406                            *anchor = None;
407                            found_edit = true;
408                        }
409                        Event::Delete {
410                            range,
411                            cursor_id: event_cursor,
412                            ..
413                        } if event_cursor == cursor_id => {
414                            // For delete, cursor moves to start of deleted range
415                            // Account for shifts from edits at lower positions
416                            let shift = calc_shift(range.start);
417                            *pos = (range.start as isize + shift).max(0) as usize;
418                            *anchor = None;
419                            found_edit = true;
420                        }
421                        _ => {}
422                    }
423                }
424
425                // If this cursor had no events at all (e.g., cursor at end of buffer
426                // during Delete, or at start during Backspace), still adjust its position
427                // for shifts caused by other cursors' edits.
428                if !found_edit {
429                    let shift = calc_shift(original_pos);
430                    *pos = (original_pos as isize + shift).max(0) as usize;
431                }
432            }
433        }
434
435        // Update cursors in SplitViewState (sole source of truth)
436        {
437            let cursors = &mut self
438                .split_view_states
439                .get_mut(&split_id)
440                .unwrap()
441                .keyed_states
442                .get_mut(&active_buf)
443                .unwrap()
444                .cursors;
445            for (cursor_id, position, anchor) in &new_cursors {
446                if let Some(cursor) = cursors.get_mut(*cursor_id) {
447                    cursor.position = *position;
448                    cursor.anchor = *anchor;
449                }
450            }
451        }
452
453        // Invalidate highlighter
454        self.buffers
455            .get_mut(&active_buf)
456            .unwrap()
457            .highlighter
458            .invalidate_all();
459
460        // Create BulkEdit event with both buffer snapshots
461        let bulk_edit = Event::BulkEdit {
462            old_snapshot: Some(old_snapshot),
463            new_snapshot: Some(new_snapshot),
464            old_cursors,
465            new_cursors,
466            description,
467            edits: edit_lengths,
468            displaced_markers,
469        };
470
471        // Post-processing (layout invalidation, split cursor sync, etc.)
472        self.invalidate_layouts_for_buffer(self.active_buffer());
473        self.adjust_other_split_cursors_for_event(&bulk_edit);
474        // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
475
476        // Notify LSP of the change using full document replacement.
477        // Bulk edits combine multiple Delete+Insert operations into a single tree pass,
478        // so computing individual incremental LSP changes is not feasible. Instead,
479        // send the full document content which is always correct.
480        let buffer_id = self.active_buffer();
481        let full_content_change = self
482            .buffers
483            .get(&buffer_id)
484            .and_then(|s| s.buffer.to_string())
485            .map(|text| {
486                vec![TextDocumentContentChangeEvent {
487                    range: None,
488                    range_length: None,
489                    text,
490                }]
491            })
492            .unwrap_or_default();
493        if !full_content_change.is_empty() {
494            self.send_lsp_changes_for_buffer(buffer_id, full_content_change);
495        }
496
497        Some(bulk_edit)
498    }
499
500    /// Trigger plugin hooks for an event (if any)
501    /// line_info contains pre-calculated line numbers from BEFORE buffer modification
502    fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
503        let buffer_id = self.active_buffer();
504
505        // Convert event to hook args and fire the appropriate hook
506        let mut cursor_changed_lines = false;
507        let hook_args = match event {
508            Event::Insert { position, text, .. } => {
509                let insert_position = *position;
510                let insert_len = text.len();
511
512                // Adjust byte ranges for the insertion
513                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
514                    // Collect adjusted ranges:
515                    // - Ranges ending before insert: keep unchanged
516                    // - Ranges containing insert point: remove (content changed)
517                    // - Ranges starting after insert: shift by insert_len
518                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
519                        .iter()
520                        .filter_map(|&(start, end)| {
521                            if end <= insert_position {
522                                // Range ends before insert - unchanged
523                                Some((start, end))
524                            } else if start >= insert_position {
525                                // Range starts at or after insert - shift forward
526                                Some((start + insert_len, end + insert_len))
527                            } else {
528                                // Range contains insert point - invalidate
529                                None
530                            }
531                        })
532                        .collect();
533                    *seen = adjusted;
534                }
535
536                Some((
537                    "after_insert",
538                    crate::services::plugins::hooks::HookArgs::AfterInsert {
539                        buffer_id,
540                        position: *position,
541                        text: text.clone(),
542                        // Byte range of the affected area
543                        affected_start: insert_position,
544                        affected_end: insert_position + insert_len,
545                        // Line info from pre-modification buffer
546                        start_line: line_info.start_line,
547                        end_line: line_info.end_line,
548                        lines_added: line_info.line_delta.max(0) as usize,
549                    },
550                ))
551            }
552            Event::Delete {
553                range,
554                deleted_text,
555                ..
556            } => {
557                let delete_start = range.start;
558
559                // Adjust byte ranges for the deletion
560                let delete_end = range.end;
561                let delete_len = delete_end - delete_start;
562                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
563                    // Collect adjusted ranges:
564                    // - Ranges ending before delete start: keep unchanged
565                    // - Ranges overlapping deletion: remove (content changed)
566                    // - Ranges starting after delete end: shift backward by delete_len
567                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
568                        .iter()
569                        .filter_map(|&(start, end)| {
570                            if end <= delete_start {
571                                // Range ends before delete - unchanged
572                                Some((start, end))
573                            } else if start >= delete_end {
574                                // Range starts after delete - shift backward
575                                Some((start - delete_len, end - delete_len))
576                            } else {
577                                // Range overlaps deletion - invalidate
578                                None
579                            }
580                        })
581                        .collect();
582                    *seen = adjusted;
583                }
584
585                Some((
586                    "after_delete",
587                    crate::services::plugins::hooks::HookArgs::AfterDelete {
588                        buffer_id,
589                        start: range.start,
590                        end: range.end,
591                        deleted_text: deleted_text.clone(),
592                        // Byte position and length of deleted content
593                        affected_start: delete_start,
594                        deleted_len: deleted_text.len(),
595                        // Line info from pre-modification buffer
596                        start_line: line_info.start_line,
597                        end_line: line_info.end_line,
598                        lines_removed: (-line_info.line_delta).max(0) as usize,
599                    },
600                ))
601            }
602            Event::Batch { events, .. } => {
603                // Fire hooks for each event in the batch
604                // Note: For batches, line info is approximate since buffer already modified
605                // Individual events will use the passed line_info which covers the whole batch
606                for e in events {
607                    // Use default line info for sub-events - they share the batch's line_info
608                    // This is a simplification; proper tracking would need per-event pre-calculation
609                    let sub_line_info = self.calculate_event_line_info(e);
610                    self.trigger_plugin_hooks_for_event(e, sub_line_info);
611                }
612                None
613            }
614            Event::MoveCursor {
615                cursor_id,
616                old_position,
617                new_position,
618                ..
619            } => {
620                // Get line numbers for old and new positions (1-indexed for plugins)
621                let old_line = self.active_state().buffer.get_line_number(*old_position) + 1;
622                let line = self.active_state().buffer.get_line_number(*new_position) + 1;
623                cursor_changed_lines = old_line != line;
624                let text_props = self
625                    .active_state()
626                    .text_properties
627                    .get_at(*new_position)
628                    .into_iter()
629                    .map(|tp| tp.properties.clone())
630                    .collect();
631                Some((
632                    "cursor_moved",
633                    crate::services::plugins::hooks::HookArgs::CursorMoved {
634                        buffer_id,
635                        cursor_id: *cursor_id,
636                        old_position: *old_position,
637                        new_position: *new_position,
638                        line,
639                        text_properties: text_props,
640                    },
641                ))
642            }
643            _ => None,
644        };
645
646        // Fire the hook to TypeScript plugins
647        if let Some((hook_name, ref args)) = hook_args {
648            // Update the full plugin state snapshot BEFORE firing the hook
649            // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
650            // Without this, there's a race condition where the async hook might read stale data
651            #[cfg(feature = "plugins")]
652            self.update_plugin_state_snapshot();
653
654            self.plugin_manager.run_hook(hook_name, args.clone());
655        }
656
657        // After inter-line cursor_moved, proactively refresh lines so
658        // cursor-dependent conceals (e.g. emphasis auto-expose in compose
659        // mode tables) update in the same frame. Without this, there's a
660        // one-frame lag: the cursor_moved hook fires async to the plugin
661        // which calls refreshLines() back, but that round-trip means the
662        // first render after the cursor move still shows stale conceals.
663        //
664        // Only refresh on inter-line movement: intra-line moves (e.g.
665        // Left/Right within a row) don't change which row is auto-exposed,
666        // and the plugin's async refreshLines() handles span-level changes.
667        if cursor_changed_lines {
668            self.handle_refresh_lines(buffer_id);
669        }
670    }
671
672    /// Handle scroll events using the SplitViewState's viewport
673    ///
674    /// View events (like Scroll) go to SplitViewState, not EditorState.
675    /// This correctly handles scroll limits when view transforms inject headers.
676    /// Also syncs to EditorState.viewport for the active split (used in rendering).
677    pub(super) fn handle_scroll_event(&mut self, line_offset: isize) {
678        use crate::view::ui::view_pipeline::ViewLineIterator;
679
680        let active_split = self.split_manager.active_split();
681
682        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
683        // Mark both splits to skip ensure_visible so cursor doesn't override scroll
684        // The sync_scroll_groups() at render time will sync the other split
685        if let Some(group) = self
686            .scroll_sync_manager
687            .find_group_for_split(active_split.into())
688        {
689            let left = group.left_split;
690            let right = group.right_split;
691            if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
692                vs.viewport.set_skip_ensure_visible();
693            }
694            if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
695                vs.viewport.set_skip_ensure_visible();
696            }
697            // Continue to scroll the active split normally below
698        }
699
700        // Fall back to simple sync_group (same delta to all splits)
701        let sync_group = self
702            .split_view_states
703            .get(&active_split)
704            .and_then(|vs| vs.sync_group);
705        let splits_to_scroll = if let Some(group_id) = sync_group {
706            self.split_manager
707                .get_splits_in_group(group_id, &self.split_view_states)
708        } else {
709            vec![active_split]
710        };
711
712        for split_id in splits_to_scroll {
713            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
714                id
715            } else {
716                continue;
717            };
718            let tab_size = self.config.editor.tab_size;
719
720            // Get view_transform tokens from SplitViewState (if any)
721            let view_transform_tokens = self
722                .split_view_states
723                .get(&split_id)
724                .and_then(|vs| vs.view_transform.as_ref())
725                .map(|vt| vt.tokens.clone());
726
727            // Get mutable references to both buffer and view state
728            if let Some(state) = self.buffers.get_mut(&buffer_id) {
729                // Collect plugin soft-break positions BEFORE re-borrowing the
730                // buffer so the viewport's visual-row math matches the renderer
731                // (avoids the wheel-absorbed / empty-bottom mouse-scroll bugs
732                // for compose-mode markdown — see scroll_down_visual).
733                let soft_breaks = state.collect_soft_break_positions();
734                let virtual_lines = state.collect_virtual_line_positions();
735                let buffer = &mut state.buffer;
736                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
737                    if let Some(tokens) = view_transform_tokens {
738                        // Use view-aware scrolling with the transform's tokens
739                        let view_lines: Vec<_> =
740                            ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
741                        view_state
742                            .viewport
743                            .scroll_view_lines(&view_lines, line_offset);
744                    } else {
745                        // No view transform - use traditional buffer-based scrolling
746                        if line_offset > 0 {
747                            view_state.viewport.scroll_down(
748                                buffer,
749                                &soft_breaks,
750                                &virtual_lines,
751                                line_offset as usize,
752                            );
753                        } else {
754                            view_state.viewport.scroll_up(
755                                buffer,
756                                &soft_breaks,
757                                &virtual_lines,
758                                line_offset.unsigned_abs(),
759                            );
760                        }
761                    }
762                    // Mark to skip ensure_visible on next render so the scroll isn't undone
763                    view_state.viewport.set_skip_ensure_visible();
764                }
765            }
766        }
767    }
768
769    /// Handle SetViewport event using SplitViewState's viewport
770    fn handle_set_viewport_event(&mut self, top_line: usize) {
771        let active_split = self.split_manager.active_split();
772
773        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
774        // If so, set the group's scroll_line and let render sync the viewports
775        if self
776            .scroll_sync_manager
777            .is_split_synced(active_split.into())
778        {
779            if let Some(group) = self
780                .scroll_sync_manager
781                .find_group_for_split_mut(active_split.into())
782            {
783                // Convert line to left buffer space if coming from right split
784                let scroll_line = if group.is_left_split(active_split.into()) {
785                    top_line
786                } else {
787                    group.right_to_left_line(top_line)
788                };
789                group.set_scroll_line(scroll_line);
790            }
791
792            // Mark both splits to skip ensure_visible
793            if let Some(group) = self
794                .scroll_sync_manager
795                .find_group_for_split(active_split.into())
796            {
797                let left = group.left_split;
798                let right = group.right_split;
799                if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
800                    vs.viewport.set_skip_ensure_visible();
801                }
802                if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
803                    vs.viewport.set_skip_ensure_visible();
804                }
805            }
806            return;
807        }
808
809        // Fall back to simple sync_group (same line to all splits)
810        let sync_group = self
811            .split_view_states
812            .get(&active_split)
813            .and_then(|vs| vs.sync_group);
814        let splits_to_scroll = if let Some(group_id) = sync_group {
815            self.split_manager
816                .get_splits_in_group(group_id, &self.split_view_states)
817        } else {
818            vec![active_split]
819        };
820
821        for split_id in splits_to_scroll {
822            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
823                id
824            } else {
825                continue;
826            };
827
828            if let Some(state) = self.buffers.get_mut(&buffer_id) {
829                let buffer = &mut state.buffer;
830                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
831                    view_state.viewport.scroll_to(buffer, top_line);
832                    // Mark to skip ensure_visible on next render so the scroll isn't undone
833                    view_state.viewport.set_skip_ensure_visible();
834                }
835            }
836        }
837    }
838
839    /// Handle Recenter event using SplitViewState's viewport
840    fn handle_recenter_event(&mut self) {
841        let active_split = self.split_manager.active_split();
842
843        // Find other splits in the same sync group if any
844        let sync_group = self
845            .split_view_states
846            .get(&active_split)
847            .and_then(|vs| vs.sync_group);
848        let splits_to_recenter = if let Some(group_id) = sync_group {
849            self.split_manager
850                .get_splits_in_group(group_id, &self.split_view_states)
851        } else {
852            vec![active_split]
853        };
854
855        for split_id in splits_to_recenter {
856            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
857                id
858            } else {
859                continue;
860            };
861
862            if let Some(state) = self.buffers.get_mut(&buffer_id) {
863                let buffer = &mut state.buffer;
864                let view_state = self.split_view_states.get_mut(&split_id);
865
866                if let Some(view_state) = view_state {
867                    // Recenter viewport on cursor
868                    let cursor = *view_state.cursors.primary();
869                    let viewport_height = view_state.viewport.visible_line_count();
870                    let target_rows_from_top = viewport_height / 2;
871
872                    // Move backwards from cursor position target_rows_from_top lines
873                    let mut iter = buffer.line_iterator(cursor.position, 80);
874                    for _ in 0..target_rows_from_top {
875                        if iter.prev().is_none() {
876                            break;
877                        }
878                    }
879                    let new_top_byte = iter.current_position();
880                    view_state.viewport.top_byte = new_top_byte;
881                    // Mark to skip ensure_visible on next render so the scroll isn't undone
882                    view_state.viewport.set_skip_ensure_visible();
883                }
884            }
885        }
886    }
887
888    /// Invalidate layouts for all splits viewing a specific buffer
889    ///
890    /// Called after buffer content changes (Insert/Delete) to mark
891    /// layouts as dirty, forcing rebuild on next access.
892    /// Also clears any cached view transform since its token source_offsets
893    /// become stale after buffer edits.
894    pub(super) fn invalidate_layouts_for_buffer(&mut self, buffer_id: BufferId) {
895        // Find all splits that display this buffer
896        let splits_for_buffer = self.split_manager.splits_for_buffer(buffer_id);
897
898        // Invalidate layout and clear stale view transform for each split
899        for split_id in splits_for_buffer {
900            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
901                view_state.invalidate_layout();
902                // Clear cached view transform — its token source_offsets are from
903                // before the edit and would cause conceals to be applied at wrong positions.
904                // The view_transform_request hook will fire on the next render to rebuild it.
905                view_state.view_transform = None;
906                // Mark as stale so that any pending SubmitViewTransform commands
907                // (from a previous view_transform_request) are rejected.
908                view_state.view_transform_stale = true;
909            }
910        }
911    }
912}