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}