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