fresh/app/action_events.rs
1//! Action -> Event conversion on `Editor`.
2//!
3//! `action_to_events` is the bridge between the Action enum (what a key
4//! press *means* in editor terms) and the Event stream (what actually
5//! gets applied to the active buffer). For movement actions on
6//! soft-wrapped lines it routes through `handle_visual_line_movement`,
7//! which walks the cached layout to translate visual-row movement into
8//! the right buffer byte offset.
9
10use crate::input::actions::action_to_events as convert_action_to_events;
11use crate::input::keybindings::Action;
12use crate::model::event::{Event, LeafId};
13
14impl crate::app::window::Window {
15 /// Convert an action into a list of events to apply to the active buffer
16 /// Returns None for actions that don't generate events (like Quit)
17 pub fn action_to_events(&mut self, action: Action) -> Option<Vec<Event>> {
18 let auto_indent = self.config().editor.auto_indent;
19 let estimated_line_length = self.config().editor.estimated_line_length;
20
21 // Use the *effective* active split: when the user is focused on an
22 // inner panel of a grouped buffer (e.g. a magit-style review panel),
23 // its leaf id lives in `split_view_states` but is not in the main
24 // split tree. `effective_active_split` returns that inner leaf, so
25 // motion targets the panel's own buffer/cursors instead of the
26 // group host's.
27 let active_split = self.effective_active_split();
28 let viewport_height = self
29 .buffers
30 .splits()
31 .map(|(_, vs)| vs)
32 .expect("active window must have a populated split layout")
33 .get(&active_split)
34 .map(|vs| vs.viewport.height)
35 .unwrap_or(24);
36
37 // Always try visual line movement first — it uses the cached layout to
38 // move through soft-wrapped rows. Returns None when the layout can't
39 // resolve the movement, falling through to logical movement below.
40 if let Some(events) =
41 self.handle_visual_line_movement(&action, active_split, estimated_line_length)
42 {
43 return Some(events);
44 }
45
46 // Page motion: drive the viewport via the same view-line-aware
47 // scroll primitive the ScrollUp/ScrollDown actions use, then land
48 // the cursor at the new viewport top so it stays visible.
49 //
50 // `Viewport::scroll_down` / `scroll_up` already walk view rows in
51 // wrap mode and logical lines in no-wrap mode, so one code path
52 // handles both: the cursor advance matches whatever the viewport
53 // just did, page-for-page. Doing this here (rather than appending
54 // an `Event::Scroll` to the normal MovePageDown MoveCursor event)
55 // avoids a stall where the logical-line cursor move lands inside
56 // the current viewport and `ensure_visible` has no reason to
57 // scroll.
58 if let Some(events) = self.handle_page_motion(&action, active_split, viewport_height) {
59 return Some(events);
60 }
61
62 let buffer_id = self.active_buffer();
63 self.buffers
64 .with_buffer_and_split(buffer_id, active_split, |state, vs| {
65 let tab_size = state.buffer_settings.tab_size;
66 let auto_close = state.buffer_settings.auto_close;
67 let auto_surround = state.buffer_settings.auto_surround;
68 convert_action_to_events(
69 state,
70 &mut vs.cursors,
71 action,
72 tab_size,
73 auto_indent,
74 auto_close,
75 auto_surround,
76 estimated_line_length,
77 viewport_height,
78 )
79 })
80 .expect("active window must have a populated split layout")
81 }
82
83 /// Handle PageUp/PageDown (and their select variants) by scrolling the
84 /// viewport a page of view rows and landing the cursor at the new top.
85 ///
86 /// Returns `None` for non-page actions, or when the viewport couldn't
87 /// scroll at all (buffer shorter than a page and already at the edge) —
88 /// in that case the caller falls through to the default handler which
89 /// still moves the cursor to the buffer boundary.
90 fn handle_page_motion(
91 &mut self,
92 action: &Action,
93 split_id: LeafId,
94 viewport_height: u16,
95 ) -> Option<Vec<Event>> {
96 let (direction, is_select) = match action {
97 Action::MovePageDown => (1isize, false),
98 Action::MovePageUp => (-1isize, false),
99 Action::SelectPageDown => (1isize, true),
100 Action::SelectPageUp => (-1isize, true),
101 _ => return None,
102 };
103
104 // Keep a few rows of overlap between the old and new page so the
105 // reader retains context across the jump, matching vim/less/most
106 // editors. The overlap shrinks for very small viewports so every
107 // press still makes meaningful progress.
108 const PAGE_OVERLAP: u16 = 3;
109 let overlap = PAGE_OVERLAP.min(viewport_height.saturating_sub(1));
110 let delta = (viewport_height.saturating_sub(overlap).max(1) as isize) * direction;
111
112 // Track the viewport position as BOTH `top_byte` and
113 // `top_view_line_offset`. In wrap mode a single long logical line
114 // scrolls by advancing `top_view_line_offset` (the wrap-segment
115 // index) while `top_byte` stays pinned at the line's start — so a
116 // `top_byte`-only "did it move?" check wrongly concludes the
117 // viewport is stuck and falls through to the logical-line handler,
118 // which clamps the cursor to EOF on a one-line document (the
119 // PageDown-overshoots bug on minified files).
120 let viewport_pos = |w: &Self| -> Option<(usize, usize)> {
121 let vp = &w
122 .buffers
123 .splits()
124 .map(|(_, vs)| vs)
125 .expect("active window must have a populated split layout")
126 .get(&split_id)?
127 .viewport;
128 Some((vp.top_byte, vp.top_view_line_offset))
129 };
130
131 let old_pos = viewport_pos(self)?;
132 self.handle_scroll_event(delta);
133 let new_pos = viewport_pos(self)?;
134
135 if new_pos == old_pos {
136 // Viewport couldn't move (already at top/bottom of buffer). Fall
137 // back to the default cursor-only handler so PageDown at EOF
138 // still clamps the cursor to the last line (and PageUp at BOF
139 // clamps to byte 0), matching the historical behaviour.
140 return None;
141 }
142
143 // Byte of the visual row now shown at the very top of the viewport.
144 // For a soft-wrapped line this is `top_byte` advanced by
145 // `top_view_line_offset` wrap segments — NOT `top_byte` itself,
146 // which on a single hugely-wrapped line is always the document
147 // start (landing the cursor there would re-introduce the overshoot
148 // / jump-to-top bugs).
149 let target_byte = {
150 let buffer_id = self
151 .buffers
152 .splits()
153 .map(|(mgr, _)| mgr)
154 .expect("active window must have a populated split layout")
155 .buffer_for_split(split_id)?;
156 self.buffers
157 .with_buffer_and_split(buffer_id, split_id, |state, vs| {
158 let soft_breaks = state.collect_soft_break_positions();
159 let virtual_lines = state.collect_virtual_line_positions();
160 vs.viewport.top_visual_row_source_byte(
161 &mut state.buffer,
162 &soft_breaks,
163 &virtual_lines,
164 )
165 })?
166 };
167
168 // Emit a MoveCursor event placing each cursor at the new viewport
169 // top. The cursor is guaranteed visible (it's at row 0 of the new
170 // viewport) and each press advances by exactly a full page of view
171 // rows — the same way it does when line wrap is off.
172 let cursors = &self
173 .buffers
174 .splits()
175 .map(|(_, vs)| vs)
176 .expect("active window must have a populated split layout")
177 .get(&split_id)?
178 .cursors;
179 let events: Vec<Event> = cursors
180 .iter()
181 .map(|(cursor_id, cursor)| {
182 let new_anchor = if is_select {
183 Some(cursor.anchor.unwrap_or(cursor.position))
184 } else if cursor.deselect_on_move {
185 None
186 } else {
187 cursor.anchor
188 };
189 Event::MoveCursor {
190 cursor_id,
191 old_position: cursor.position,
192 new_position: target_byte,
193 old_anchor: cursor.anchor,
194 new_anchor,
195 old_sticky_column: cursor.sticky_column,
196 new_sticky_column: cursor.sticky_column,
197 }
198 })
199 .collect();
200
201 Some(events)
202 }
203
204 /// Handle visual line movement actions using the cached layout
205 /// Returns Some(events) if the action was handled, None if it should fall through
206 fn handle_visual_line_movement(
207 &mut self,
208 action: &Action,
209 split_id: LeafId,
210 _estimated_line_length: usize,
211 ) -> Option<Vec<Event>> {
212 // Classify the action
213 enum VisualAction {
214 UpDown { direction: i8, is_select: bool },
215 LineEnd { is_select: bool },
216 LineStart { is_select: bool },
217 }
218
219 // Note: We don't intercept BlockSelectUp/Down because block selection has
220 // special semantics (setting block_anchor) that require the default handler
221 let visual_action = match action {
222 Action::MoveUp => VisualAction::UpDown {
223 direction: -1,
224 is_select: false,
225 },
226 Action::MoveDown => VisualAction::UpDown {
227 direction: 1,
228 is_select: false,
229 },
230 Action::SelectUp => VisualAction::UpDown {
231 direction: -1,
232 is_select: true,
233 },
234 Action::SelectDown => VisualAction::UpDown {
235 direction: 1,
236 is_select: true,
237 },
238 // When line wrapping is off, Home/End should move to the physical line
239 // start/end, not the visual (horizontally-scrolled) row boundary.
240 // Fall through to the standard handler which uses line_iterator.
241 Action::MoveLineEnd if self.config().editor.line_wrap => {
242 VisualAction::LineEnd { is_select: false }
243 }
244 Action::SelectLineEnd if self.config().editor.line_wrap => {
245 VisualAction::LineEnd { is_select: true }
246 }
247 Action::MoveLineStart if self.config().editor.line_wrap => {
248 VisualAction::LineStart { is_select: false }
249 }
250 Action::SelectLineStart if self.config().editor.line_wrap => {
251 VisualAction::LineStart { is_select: true }
252 }
253 _ => return None, // Not a visual line action
254 };
255
256 // First, collect cursor data we need (to avoid borrow conflicts).
257 // Use the *effective* active split + buffer so that cursor motion in
258 // a focused buffer-group panel reads the panel's own cursors and
259 // buffer instead of the group host's.
260 let cursor_data: Vec<_> = {
261 let active_split = self.effective_active_split();
262 let active_buffer = self.active_buffer();
263 let cursors = &self
264 .buffers
265 .splits()
266 .map(|(_, vs)| vs)
267 .expect("active window must have a populated split layout")
268 .get(&active_split)
269 .unwrap()
270 .cursors;
271 let state = (&self.buffers).get(&active_buffer).unwrap();
272 cursors
273 .iter()
274 .map(|(cursor_id, cursor)| {
275 // Check if cursor is at a physical line boundary:
276 // - at_line_ending: byte at cursor position is a newline or at buffer end
277 // - at_line_start: cursor is at position 0 or preceded by a newline
278 let at_line_ending = if cursor.position < state.buffer.len() {
279 let bytes = state
280 .buffer
281 .slice_bytes(cursor.position..cursor.position + 1);
282 bytes.first() == Some(&b'\n') || bytes.first() == Some(&b'\r')
283 } else {
284 true // end of buffer is a boundary
285 };
286 let at_line_start = if cursor.position == 0 {
287 true
288 } else {
289 let prev = state
290 .buffer
291 .slice_bytes(cursor.position - 1..cursor.position);
292 prev.first() == Some(&b'\n')
293 };
294 (
295 cursor_id,
296 cursor.position,
297 cursor.anchor,
298 cursor.sticky_column,
299 cursor.deselect_on_move,
300 at_line_ending,
301 at_line_start,
302 )
303 })
304 .collect()
305 };
306
307 let mut events = Vec::new();
308
309 for (
310 cursor_id,
311 position,
312 anchor,
313 sticky_column,
314 deselect_on_move,
315 at_line_ending,
316 at_line_start,
317 ) in cursor_data
318 {
319 let (new_pos, new_sticky) = match &visual_action {
320 VisualAction::UpDown {
321 direction,
322 is_select,
323 } => {
324 // When a selection is active, plain (non-selecting) vertical
325 // motion starts from the selection's edge closest to the
326 // motion direction (top edge for Up, bottom edge for Down),
327 // matching VSCode/Sublime/browser behavior (issue #1566).
328 // Emacs mark-mode (`deselect_on_move == false`) is unaffected.
329 let from_pos = if deselect_on_move && !*is_select {
330 if let Some(anchor) = anchor {
331 if *direction < 0 {
332 position.min(anchor)
333 } else {
334 position.max(anchor)
335 }
336 } else {
337 position
338 }
339 } else {
340 position
341 };
342
343 // Calculate current visual column from cached layout
344 let current_visual_col = self
345 .layout_cache
346 .byte_to_visual_column(split_id, from_pos)?;
347
348 let goal_visual_col = if sticky_column > 0 {
349 sticky_column
350 } else {
351 current_visual_col
352 };
353
354 match self.layout_cache.move_visual_line(
355 split_id,
356 from_pos,
357 goal_visual_col,
358 *direction,
359 ) {
360 Some(result) => result,
361 None => {
362 // Target visual row is past the cached view-line
363 // mappings — the destination row isn't in the
364 // currently-rendered viewport slice. In wrap mode
365 // that means the next visual row belongs to a
366 // logical line (or wrapped segment) that is
367 // off-screen. Compute its position directly from
368 // the buffer + wrap config so we don't fall
369 // through to the byte-based MoveDown handler,
370 // which would treat `goal_visual_col` as a
371 // *logical* column on the whole next logical
372 // line and teleport the cursor deep into a
373 // wrapped paragraph (issue #1574, jump variant).
374 match self.compute_wrap_aware_visual_move_fallback(
375 from_pos,
376 goal_visual_col,
377 *direction,
378 _estimated_line_length,
379 ) {
380 Some(result) => result,
381 None => continue, // Genuinely at buffer boundary
382 }
383 }
384 }
385 }
386 VisualAction::LineEnd { .. } => {
387 // Allow advancing to next visual segment only if not at a physical line ending
388 let allow_advance = !at_line_ending;
389 match self
390 .layout_cache
391 .visual_line_end(split_id, position, allow_advance)
392 {
393 Some(end_pos) => (end_pos, 0),
394 None => return None,
395 }
396 }
397 VisualAction::LineStart { .. } => {
398 // Allow advancing to previous visual segment only if not at a physical line start
399 let allow_advance = !at_line_start;
400 match self
401 .layout_cache
402 .visual_line_start(split_id, position, allow_advance)
403 {
404 Some(start_pos) => (start_pos, 0),
405 None => return None,
406 }
407 }
408 };
409
410 let is_select = match &visual_action {
411 VisualAction::UpDown { is_select, .. } => *is_select,
412 VisualAction::LineEnd { is_select } => *is_select,
413 VisualAction::LineStart { is_select } => *is_select,
414 };
415
416 let new_anchor = if is_select {
417 Some(anchor.unwrap_or(position))
418 } else if deselect_on_move {
419 None
420 } else {
421 anchor
422 };
423
424 events.push(Event::MoveCursor {
425 cursor_id,
426 old_position: position,
427 new_position: new_pos,
428 old_anchor: anchor,
429 new_anchor,
430 old_sticky_column: sticky_column,
431 new_sticky_column: new_sticky,
432 });
433 }
434
435 if events.is_empty() {
436 None // Let the default handler deal with it
437 } else {
438 Some(events)
439 }
440 }
441
442 /// Compute a wrap-aware target position when the cached view-line
443 /// mappings don't cover the requested direction.
444 ///
445 /// `move_visual_line` returns `None` when the target visual row is
446 /// past the currently-rendered viewport — typically because the
447 /// destination line wraps off-screen below (for Down) or above (for
448 /// Up). The generic MoveDown/MoveUp fallback that normally kicks in
449 /// when the intercept returns None treats `goal_visual_col` as a
450 /// column on the whole next logical line, which is wrong for wrap
451 /// mode: if the next logical line is a long wrapped paragraph, the
452 /// cursor lands several visual rows deep (issue #1574, jump variant).
453 ///
454 /// This helper uses the current row's `line_end_byte` (which the
455 /// cached layout does know) to find the byte position just past the
456 /// current visual row, and lands the cursor at the *start* of the
457 /// next visual row. That's conservative (the sticky visual column
458 /// from the previous row isn't preserved across an off-screen jump)
459 /// but it reliably places the cursor on the first visual row of
460 /// the next logical line / wrapped segment instead of somewhere
461 /// deep inside it. Preserving sticky precisely when the target row
462 /// is off-screen would require re-running the full token-based
463 /// wrapping pipeline for the target line, which the editor doesn't
464 /// currently expose outside of the render pipeline.
465 ///
466 /// Returns `Some((new_position, new_sticky))` on success, or `None`
467 /// if wrap mode is off (delegate to caller default) or we're at a
468 /// genuine buffer boundary.
469 fn compute_wrap_aware_visual_move_fallback(
470 &mut self,
471 from_pos: usize,
472 goal_visual_col: usize,
473 direction: i8,
474 estimated_line_length: usize,
475 ) -> Option<(usize, usize)> {
476 if !self.config().editor.line_wrap {
477 // Non-wrap mode: the byte-based fallback is correct, let it run.
478 return None;
479 }
480
481 let active_split = self.effective_active_split();
482 let active_buffer = self.active_buffer();
483
484 if direction > 0 {
485 // Find current row's end byte via cached layout — this is the
486 // authoritative "end of current visual row" position that the
487 // renderer itself uses.
488 let cur_row_line_end = {
489 let mappings = self.layout_cache.view_line_mappings.get(&active_split)?;
490 let row_idx = self.layout_cache.find_visual_row(active_split, from_pos)?;
491 mappings.get(row_idx)?.line_end_byte
492 };
493
494 let state = (&mut self.buffers).get_mut(&active_buffer)?;
495 let buffer = &mut state.buffer;
496 let buffer_len = buffer.len();
497 if cur_row_line_end >= buffer_len {
498 return None; // Genuine end of buffer
499 }
500
501 // Step past the newline at `cur_row_line_end`, mirroring the
502 // tokenization logic in `build_base_tokens`: CRLF (`\r\n`) is a
503 // SINGLE logical line break and the next logical line starts two
504 // bytes past the `\r`, not one. Falling back to `+ 1` lands the
505 // cursor on the `\n` inside the CRLF pair, which
506 // `find_view_line_for_byte` resolves back to the SAME row — so
507 // pressing Down from an empty separator line on a CRLF file
508 // appears to jump the cursor to the wrong visual row (issue
509 // #1574, Windows-CRLF variant). When `cur_row_line_end` isn't a
510 // newline the current row is a wrapped continuation and the
511 // next visual row starts at the same byte position.
512 let target_pos = step_past_line_break(buffer, cur_row_line_end, buffer_len);
513 if target_pos > buffer_len {
514 return None;
515 }
516
517 // Preserve goal_visual_col as the new sticky column so if the
518 // user keeps pressing Down the normal cached-layout path will
519 // honor it once the target row is rendered.
520 let _ = estimated_line_length;
521 Some((target_pos, goal_visual_col))
522 } else {
523 // Up-direction fallback: mirror the Down logic. Use the
524 // cached layout to locate the current visual row's "anchor"
525 // byte (the row start for rows with visible content, or
526 // `line_end_byte` for empty rows which have no source
527 // mapping), then step back one byte so the cursor lands on
528 // the *end* of the preceding visual row.
529 //
530 // For a row whose start is a logical-line-start, stepping
531 // back one byte lands on the trailing newline of the
532 // previous logical line — the renderer shows this as the
533 // end of the last visual row of that line, which is exactly
534 // where the cursor should land when walking Up.
535 //
536 // For a wrapped continuation row, the "start" is already a
537 // byte within the same logical line; stepping back one byte
538 // keeps us inside the line on the previous wrapped segment.
539 //
540 // For empty rows (no char_source_bytes, common at paragraph
541 // separators), `line_end_byte` is the empty line's newline;
542 // stepping back one byte lands on the previous line's
543 // trailing newline — again the end of its last visual row.
544 let (cur_row_anchor, row_is_empty) = {
545 let mappings = self.layout_cache.view_line_mappings.get(&active_split)?;
546 let row_idx = self.layout_cache.find_visual_row(active_split, from_pos)?;
547 let row = mappings.get(row_idx)?;
548 match row.char_source_bytes.iter().find_map(|b| *b) {
549 Some(start) => (start, false),
550 None => (row.line_end_byte, true),
551 }
552 };
553
554 if cur_row_anchor == 0 {
555 return None; // At the very beginning of the buffer
556 }
557
558 // Step back across the newline preceding `cur_row_anchor`,
559 // mirroring the tokenization logic in `build_base_tokens`:
560 // CRLF is a SINGLE logical line break so we must step back
561 // two bytes over it, not one. Blindly subtracting 1 on a
562 // CRLF file lands the cursor on the `\n` INSIDE the CRLF
563 // pair, which `find_view_line_for_byte` resolves to a row
564 // the user wouldn't expect (issue #1574, Windows-CRLF
565 // variant). For LF or a lone CR the byte arithmetic falls
566 // through to a one-byte step.
567 let state = (&mut self.buffers).get_mut(&active_buffer)?;
568 let buffer = &mut state.buffer;
569 let _ = row_is_empty;
570 let target_pos = step_before_line_break(buffer, cur_row_anchor);
571 let _ = estimated_line_length;
572 Some((target_pos, goal_visual_col))
573 }
574 }
575}
576
577/// Advance past the line break at `pos`, matching the CRLF handling in
578/// `build_base_tokens` (where `\r\n` is a single logical line break
579/// represented by one `Newline` token at the `\r`). When `pos` is on a
580/// `\r` immediately followed by `\n` we step two bytes; on a lone `\n`
581/// or `\r` we step one; otherwise (`pos` isn't on a newline, i.e. a
582/// wrapped-continuation boundary) we return `pos` unchanged so the next
583/// visual row starts at the same byte. Without this, pressing Down
584/// across a CRLF newline lands the cursor on the `\n` inside the pair,
585/// which `find_view_line_for_byte` resolves back to the *same* row
586/// (issue #1574, Windows-CRLF variant).
587fn step_past_line_break(
588 buffer: &crate::model::buffer::Buffer,
589 pos: usize,
590 buffer_len: usize,
591) -> usize {
592 if pos >= buffer_len {
593 return pos;
594 }
595 let end = (pos + 2).min(buffer_len);
596 let bytes = buffer.slice_bytes(pos..end);
597 match (bytes.first(), bytes.get(1)) {
598 (Some(b'\r'), Some(b'\n')) => pos + 2,
599 (Some(b'\r'), _) | (Some(b'\n'), _) => pos + 1,
600 _ => pos,
601 }
602}
603
604/// Step back across the line break immediately preceding `pos`, mirror
605/// of [`step_past_line_break`]. Two bytes for CRLF (`\r\n`), one for
606/// LF or a lone CR, zero if `pos == 0`. Callers use this to land the
607/// cursor at the *end* of the previous visual row when moving Up across
608/// a newline — landing mid-CRLF would place the cursor on the `\n` and
609/// re-resolve to the same row (issue #1574, Windows-CRLF variant).
610fn step_before_line_break(buffer: &crate::model::buffer::Buffer, pos: usize) -> usize {
611 if pos == 0 {
612 return pos;
613 }
614 if pos >= 2 {
615 let bytes = buffer.slice_bytes((pos - 2)..pos);
616 if bytes.first() == Some(&b'\r') && bytes.get(1) == Some(&b'\n') {
617 return pos - 2;
618 }
619 }
620 pos - 1
621}