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