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