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