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 let old_top_byte = self
113 .buffers
114 .splits()
115 .map(|(_, vs)| vs)
116 .expect("active window must have a populated split layout")
117 .get(&split_id)?
118 .viewport
119 .top_byte;
120 self.handle_scroll_event(delta);
121 let new_top_byte = self
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 .top_byte;
129
130 if new_top_byte == old_top_byte {
131 // Viewport couldn't move (already at top/bottom of buffer). Fall
132 // back to the default cursor-only handler so PageDown at EOF
133 // still clamps the cursor to the last line (and PageUp at BOF
134 // clamps to byte 0), matching the historical behaviour.
135 return None;
136 }
137
138 // Emit a MoveCursor event placing each cursor at the new viewport
139 // top. The cursor is guaranteed visible (it's at row 0 of the new
140 // viewport) and each press advances by exactly a full page of view
141 // rows — the same way it does when line wrap is off.
142 let cursors = &self
143 .buffers
144 .splits()
145 .map(|(_, vs)| vs)
146 .expect("active window must have a populated split layout")
147 .get(&split_id)?
148 .cursors;
149 let events: Vec<Event> = cursors
150 .iter()
151 .map(|(cursor_id, cursor)| {
152 let new_anchor = if is_select {
153 Some(cursor.anchor.unwrap_or(cursor.position))
154 } else if cursor.deselect_on_move {
155 None
156 } else {
157 cursor.anchor
158 };
159 Event::MoveCursor {
160 cursor_id,
161 old_position: cursor.position,
162 new_position: new_top_byte,
163 old_anchor: cursor.anchor,
164 new_anchor,
165 old_sticky_column: cursor.sticky_column,
166 new_sticky_column: cursor.sticky_column,
167 }
168 })
169 .collect();
170
171 Some(events)
172 }
173
174 /// Handle visual line movement actions using the cached layout
175 /// Returns Some(events) if the action was handled, None if it should fall through
176 fn handle_visual_line_movement(
177 &mut self,
178 action: &Action,
179 split_id: LeafId,
180 _estimated_line_length: usize,
181 ) -> Option<Vec<Event>> {
182 // Classify the action
183 enum VisualAction {
184 UpDown { direction: i8, is_select: bool },
185 LineEnd { is_select: bool },
186 LineStart { is_select: bool },
187 }
188
189 // Note: We don't intercept BlockSelectUp/Down because block selection has
190 // special semantics (setting block_anchor) that require the default handler
191 let visual_action = match action {
192 Action::MoveUp => VisualAction::UpDown {
193 direction: -1,
194 is_select: false,
195 },
196 Action::MoveDown => VisualAction::UpDown {
197 direction: 1,
198 is_select: false,
199 },
200 Action::SelectUp => VisualAction::UpDown {
201 direction: -1,
202 is_select: true,
203 },
204 Action::SelectDown => VisualAction::UpDown {
205 direction: 1,
206 is_select: true,
207 },
208 // When line wrapping is off, Home/End should move to the physical line
209 // start/end, not the visual (horizontally-scrolled) row boundary.
210 // Fall through to the standard handler which uses line_iterator.
211 Action::MoveLineEnd if self.config().editor.line_wrap => {
212 VisualAction::LineEnd { is_select: false }
213 }
214 Action::SelectLineEnd if self.config().editor.line_wrap => {
215 VisualAction::LineEnd { is_select: true }
216 }
217 Action::MoveLineStart if self.config().editor.line_wrap => {
218 VisualAction::LineStart { is_select: false }
219 }
220 Action::SelectLineStart if self.config().editor.line_wrap => {
221 VisualAction::LineStart { is_select: true }
222 }
223 _ => return None, // Not a visual line action
224 };
225
226 // First, collect cursor data we need (to avoid borrow conflicts).
227 // Use the *effective* active split + buffer so that cursor motion in
228 // a focused buffer-group panel reads the panel's own cursors and
229 // buffer instead of the group host's.
230 let cursor_data: Vec<_> = {
231 let active_split = self.effective_active_split();
232 let active_buffer = self.active_buffer();
233 let cursors = &self
234 .buffers
235 .splits()
236 .map(|(_, vs)| vs)
237 .expect("active window must have a populated split layout")
238 .get(&active_split)
239 .unwrap()
240 .cursors;
241 let state = (&self.buffers).get(&active_buffer).unwrap();
242 cursors
243 .iter()
244 .map(|(cursor_id, cursor)| {
245 // Check if cursor is at a physical line boundary:
246 // - at_line_ending: byte at cursor position is a newline or at buffer end
247 // - at_line_start: cursor is at position 0 or preceded by a newline
248 let at_line_ending = if cursor.position < state.buffer.len() {
249 let bytes = state
250 .buffer
251 .slice_bytes(cursor.position..cursor.position + 1);
252 bytes.first() == Some(&b'\n') || bytes.first() == Some(&b'\r')
253 } else {
254 true // end of buffer is a boundary
255 };
256 let at_line_start = if cursor.position == 0 {
257 true
258 } else {
259 let prev = state
260 .buffer
261 .slice_bytes(cursor.position - 1..cursor.position);
262 prev.first() == Some(&b'\n')
263 };
264 (
265 cursor_id,
266 cursor.position,
267 cursor.anchor,
268 cursor.sticky_column,
269 cursor.deselect_on_move,
270 at_line_ending,
271 at_line_start,
272 )
273 })
274 .collect()
275 };
276
277 let mut events = Vec::new();
278
279 for (
280 cursor_id,
281 position,
282 anchor,
283 sticky_column,
284 deselect_on_move,
285 at_line_ending,
286 at_line_start,
287 ) in cursor_data
288 {
289 let (new_pos, new_sticky) = match &visual_action {
290 VisualAction::UpDown {
291 direction,
292 is_select,
293 } => {
294 // When a selection is active, plain (non-selecting) vertical
295 // motion starts from the selection's edge closest to the
296 // motion direction (top edge for Up, bottom edge for Down),
297 // matching VSCode/Sublime/browser behavior (issue #1566).
298 // Emacs mark-mode (`deselect_on_move == false`) is unaffected.
299 let from_pos = if deselect_on_move && !*is_select {
300 if let Some(anchor) = anchor {
301 if *direction < 0 {
302 position.min(anchor)
303 } else {
304 position.max(anchor)
305 }
306 } else {
307 position
308 }
309 } else {
310 position
311 };
312
313 // Calculate current visual column from cached layout
314 let current_visual_col = self
315 .layout_cache
316 .byte_to_visual_column(split_id, from_pos)?;
317
318 let goal_visual_col = if sticky_column > 0 {
319 sticky_column
320 } else {
321 current_visual_col
322 };
323
324 match self.layout_cache.move_visual_line(
325 split_id,
326 from_pos,
327 goal_visual_col,
328 *direction,
329 ) {
330 Some(result) => result,
331 None => {
332 // Target visual row is past the cached view-line
333 // mappings — the destination row isn't in the
334 // currently-rendered viewport slice. In wrap mode
335 // that means the next visual row belongs to a
336 // logical line (or wrapped segment) that is
337 // off-screen. Compute its position directly from
338 // the buffer + wrap config so we don't fall
339 // through to the byte-based MoveDown handler,
340 // which would treat `goal_visual_col` as a
341 // *logical* column on the whole next logical
342 // line and teleport the cursor deep into a
343 // wrapped paragraph (issue #1574, jump variant).
344 match self.compute_wrap_aware_visual_move_fallback(
345 from_pos,
346 goal_visual_col,
347 *direction,
348 _estimated_line_length,
349 ) {
350 Some(result) => result,
351 None => continue, // Genuinely at buffer boundary
352 }
353 }
354 }
355 }
356 VisualAction::LineEnd { .. } => {
357 // Allow advancing to next visual segment only if not at a physical line ending
358 let allow_advance = !at_line_ending;
359 match self
360 .layout_cache
361 .visual_line_end(split_id, position, allow_advance)
362 {
363 Some(end_pos) => (end_pos, 0),
364 None => return None,
365 }
366 }
367 VisualAction::LineStart { .. } => {
368 // Allow advancing to previous visual segment only if not at a physical line start
369 let allow_advance = !at_line_start;
370 match self
371 .layout_cache
372 .visual_line_start(split_id, position, allow_advance)
373 {
374 Some(start_pos) => (start_pos, 0),
375 None => return None,
376 }
377 }
378 };
379
380 let is_select = match &visual_action {
381 VisualAction::UpDown { is_select, .. } => *is_select,
382 VisualAction::LineEnd { is_select } => *is_select,
383 VisualAction::LineStart { is_select } => *is_select,
384 };
385
386 let new_anchor = if is_select {
387 Some(anchor.unwrap_or(position))
388 } else if deselect_on_move {
389 None
390 } else {
391 anchor
392 };
393
394 events.push(Event::MoveCursor {
395 cursor_id,
396 old_position: position,
397 new_position: new_pos,
398 old_anchor: anchor,
399 new_anchor,
400 old_sticky_column: sticky_column,
401 new_sticky_column: new_sticky,
402 });
403 }
404
405 if events.is_empty() {
406 None // Let the default handler deal with it
407 } else {
408 Some(events)
409 }
410 }
411
412 /// Compute a wrap-aware target position when the cached view-line
413 /// mappings don't cover the requested direction.
414 ///
415 /// `move_visual_line` returns `None` when the target visual row is
416 /// past the currently-rendered viewport — typically because the
417 /// destination line wraps off-screen below (for Down) or above (for
418 /// Up). The generic MoveDown/MoveUp fallback that normally kicks in
419 /// when the intercept returns None treats `goal_visual_col` as a
420 /// column on the whole next logical line, which is wrong for wrap
421 /// mode: if the next logical line is a long wrapped paragraph, the
422 /// cursor lands several visual rows deep (issue #1574, jump variant).
423 ///
424 /// This helper uses the current row's `line_end_byte` (which the
425 /// cached layout does know) to find the byte position just past the
426 /// current visual row, and lands the cursor at the *start* of the
427 /// next visual row. That's conservative (the sticky visual column
428 /// from the previous row isn't preserved across an off-screen jump)
429 /// but it reliably places the cursor on the first visual row of
430 /// the next logical line / wrapped segment instead of somewhere
431 /// deep inside it. Preserving sticky precisely when the target row
432 /// is off-screen would require re-running the full token-based
433 /// wrapping pipeline for the target line, which the editor doesn't
434 /// currently expose outside of the render pipeline.
435 ///
436 /// Returns `Some((new_position, new_sticky))` on success, or `None`
437 /// if wrap mode is off (delegate to caller default) or we're at a
438 /// genuine buffer boundary.
439 fn compute_wrap_aware_visual_move_fallback(
440 &mut self,
441 from_pos: usize,
442 goal_visual_col: usize,
443 direction: i8,
444 estimated_line_length: usize,
445 ) -> Option<(usize, usize)> {
446 if !self.config().editor.line_wrap {
447 // Non-wrap mode: the byte-based fallback is correct, let it run.
448 return None;
449 }
450
451 let active_split = self.effective_active_split();
452 let active_buffer = self.active_buffer();
453
454 if direction > 0 {
455 // Find current row's end byte via cached layout — this is the
456 // authoritative "end of current visual row" position that the
457 // renderer itself uses.
458 let cur_row_line_end = {
459 let mappings = self.layout_cache.view_line_mappings.get(&active_split)?;
460 let row_idx = self.layout_cache.find_visual_row(active_split, from_pos)?;
461 mappings.get(row_idx)?.line_end_byte
462 };
463
464 let state = (&mut self.buffers).get_mut(&active_buffer)?;
465 let buffer = &mut state.buffer;
466 let buffer_len = buffer.len();
467 if cur_row_line_end >= buffer_len {
468 return None; // Genuine end of buffer
469 }
470
471 // Step past the newline at `cur_row_line_end`, mirroring the
472 // tokenization logic in `build_base_tokens`: CRLF (`\r\n`) is a
473 // SINGLE logical line break and the next logical line starts two
474 // bytes past the `\r`, not one. Falling back to `+ 1` lands the
475 // cursor on the `\n` inside the CRLF pair, which
476 // `find_view_line_for_byte` resolves back to the SAME row — so
477 // pressing Down from an empty separator line on a CRLF file
478 // appears to jump the cursor to the wrong visual row (issue
479 // #1574, Windows-CRLF variant). When `cur_row_line_end` isn't a
480 // newline the current row is a wrapped continuation and the
481 // next visual row starts at the same byte position.
482 let target_pos = step_past_line_break(buffer, cur_row_line_end, buffer_len);
483 if target_pos > buffer_len {
484 return None;
485 }
486
487 // Preserve goal_visual_col as the new sticky column so if the
488 // user keeps pressing Down the normal cached-layout path will
489 // honor it once the target row is rendered.
490 let _ = estimated_line_length;
491 Some((target_pos, goal_visual_col))
492 } else {
493 // Up-direction fallback: mirror the Down logic. Use the
494 // cached layout to locate the current visual row's "anchor"
495 // byte (the row start for rows with visible content, or
496 // `line_end_byte` for empty rows which have no source
497 // mapping), then step back one byte so the cursor lands on
498 // the *end* of the preceding visual row.
499 //
500 // For a row whose start is a logical-line-start, stepping
501 // back one byte lands on the trailing newline of the
502 // previous logical line — the renderer shows this as the
503 // end of the last visual row of that line, which is exactly
504 // where the cursor should land when walking Up.
505 //
506 // For a wrapped continuation row, the "start" is already a
507 // byte within the same logical line; stepping back one byte
508 // keeps us inside the line on the previous wrapped segment.
509 //
510 // For empty rows (no char_source_bytes, common at paragraph
511 // separators), `line_end_byte` is the empty line's newline;
512 // stepping back one byte lands on the previous line's
513 // trailing newline — again the end of its last visual row.
514 let (cur_row_anchor, row_is_empty) = {
515 let mappings = self.layout_cache.view_line_mappings.get(&active_split)?;
516 let row_idx = self.layout_cache.find_visual_row(active_split, from_pos)?;
517 let row = mappings.get(row_idx)?;
518 match row.char_source_bytes.iter().find_map(|b| *b) {
519 Some(start) => (start, false),
520 None => (row.line_end_byte, true),
521 }
522 };
523
524 if cur_row_anchor == 0 {
525 return None; // At the very beginning of the buffer
526 }
527
528 // Step back across the newline preceding `cur_row_anchor`,
529 // mirroring the tokenization logic in `build_base_tokens`:
530 // CRLF is a SINGLE logical line break so we must step back
531 // two bytes over it, not one. Blindly subtracting 1 on a
532 // CRLF file lands the cursor on the `\n` INSIDE the CRLF
533 // pair, which `find_view_line_for_byte` resolves to a row
534 // the user wouldn't expect (issue #1574, Windows-CRLF
535 // variant). For LF or a lone CR the byte arithmetic falls
536 // through to a one-byte step.
537 let state = (&mut self.buffers).get_mut(&active_buffer)?;
538 let buffer = &mut state.buffer;
539 let _ = row_is_empty;
540 let target_pos = step_before_line_break(buffer, cur_row_anchor);
541 let _ = estimated_line_length;
542 Some((target_pos, goal_visual_col))
543 }
544 }
545}
546
547/// Advance past the line break at `pos`, matching the CRLF handling in
548/// `build_base_tokens` (where `\r\n` is a single logical line break
549/// represented by one `Newline` token at the `\r`). When `pos` is on a
550/// `\r` immediately followed by `\n` we step two bytes; on a lone `\n`
551/// or `\r` we step one; otherwise (`pos` isn't on a newline, i.e. a
552/// wrapped-continuation boundary) we return `pos` unchanged so the next
553/// visual row starts at the same byte. Without this, pressing Down
554/// across a CRLF newline lands the cursor on the `\n` inside the pair,
555/// which `find_view_line_for_byte` resolves back to the *same* row
556/// (issue #1574, Windows-CRLF variant).
557fn step_past_line_break(
558 buffer: &crate::model::buffer::Buffer,
559 pos: usize,
560 buffer_len: usize,
561) -> usize {
562 if pos >= buffer_len {
563 return pos;
564 }
565 let end = (pos + 2).min(buffer_len);
566 let bytes = buffer.slice_bytes(pos..end);
567 match (bytes.first(), bytes.get(1)) {
568 (Some(b'\r'), Some(b'\n')) => pos + 2,
569 (Some(b'\r'), _) | (Some(b'\n'), _) => pos + 1,
570 _ => pos,
571 }
572}
573
574/// Step back across the line break immediately preceding `pos`, mirror
575/// of [`step_past_line_break`]. Two bytes for CRLF (`\r\n`), one for
576/// LF or a lone CR, zero if `pos == 0`. Callers use this to land the
577/// cursor at the *end* of the previous visual row when moving Up across
578/// a newline — landing mid-CRLF would place the cursor on the `\n` and
579/// re-resolve to the same row (issue #1574, Windows-CRLF variant).
580fn step_before_line_break(buffer: &crate::model::buffer::Buffer, pos: usize) -> usize {
581 if pos == 0 {
582 return pos;
583 }
584 if pos >= 2 {
585 let bytes = buffer.slice_bytes((pos - 2)..pos);
586 if bytes.first() == Some(&b'\r') && bytes.get(1) == Some(&b'\n') {
587 return pos - 2;
588 }
589 }
590 pos - 1
591}