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