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 let buffer_id = self.active_buffer();
46 let state = self.buffers.get_mut(&buffer_id).unwrap();
47
48 // Use per-buffer settings which respect language overrides and user changes
49 let tab_size = state.buffer_settings.tab_size;
50 let auto_close = state.buffer_settings.auto_close;
51 let auto_surround = state.buffer_settings.auto_surround;
52
53 let cursors = &mut self
54 .split_view_states
55 .get_mut(&active_split)
56 .unwrap()
57 .cursors;
58 convert_action_to_events(
59 state,
60 cursors,
61 action,
62 tab_size,
63 auto_indent,
64 auto_close,
65 auto_surround,
66 estimated_line_length,
67 viewport_height,
68 )
69 }
70
71 /// Handle visual line movement actions using the cached layout
72 /// Returns Some(events) if the action was handled, None if it should fall through
73 fn handle_visual_line_movement(
74 &mut self,
75 action: &Action,
76 split_id: LeafId,
77 _estimated_line_length: usize,
78 ) -> Option<Vec<Event>> {
79 // Classify the action
80 enum VisualAction {
81 UpDown { direction: i8, is_select: bool },
82 LineEnd { is_select: bool },
83 LineStart { is_select: bool },
84 }
85
86 // Note: We don't intercept BlockSelectUp/Down because block selection has
87 // special semantics (setting block_anchor) that require the default handler
88 let visual_action = match action {
89 Action::MoveUp => VisualAction::UpDown {
90 direction: -1,
91 is_select: false,
92 },
93 Action::MoveDown => VisualAction::UpDown {
94 direction: 1,
95 is_select: false,
96 },
97 Action::SelectUp => VisualAction::UpDown {
98 direction: -1,
99 is_select: true,
100 },
101 Action::SelectDown => VisualAction::UpDown {
102 direction: 1,
103 is_select: true,
104 },
105 // When line wrapping is off, Home/End should move to the physical line
106 // start/end, not the visual (horizontally-scrolled) row boundary.
107 // Fall through to the standard handler which uses line_iterator.
108 Action::MoveLineEnd if self.config.editor.line_wrap => {
109 VisualAction::LineEnd { is_select: false }
110 }
111 Action::SelectLineEnd if self.config.editor.line_wrap => {
112 VisualAction::LineEnd { is_select: true }
113 }
114 Action::MoveLineStart if self.config.editor.line_wrap => {
115 VisualAction::LineStart { is_select: false }
116 }
117 Action::SelectLineStart if self.config.editor.line_wrap => {
118 VisualAction::LineStart { is_select: true }
119 }
120 _ => return None, // Not a visual line action
121 };
122
123 // First, collect cursor data we need (to avoid borrow conflicts).
124 // Use the *effective* active split + buffer so that cursor motion in
125 // a focused buffer-group panel reads the panel's own cursors and
126 // buffer instead of the group host's.
127 let cursor_data: Vec<_> = {
128 let active_split = self.effective_active_split();
129 let active_buffer = self.active_buffer();
130 let cursors = &self.split_view_states.get(&active_split).unwrap().cursors;
131 let state = self.buffers.get(&active_buffer).unwrap();
132 cursors
133 .iter()
134 .map(|(cursor_id, cursor)| {
135 // Check if cursor is at a physical line boundary:
136 // - at_line_ending: byte at cursor position is a newline or at buffer end
137 // - at_line_start: cursor is at position 0 or preceded by a newline
138 let at_line_ending = if cursor.position < state.buffer.len() {
139 let bytes = state
140 .buffer
141 .slice_bytes(cursor.position..cursor.position + 1);
142 bytes.first() == Some(&b'\n') || bytes.first() == Some(&b'\r')
143 } else {
144 true // end of buffer is a boundary
145 };
146 let at_line_start = if cursor.position == 0 {
147 true
148 } else {
149 let prev = state
150 .buffer
151 .slice_bytes(cursor.position - 1..cursor.position);
152 prev.first() == Some(&b'\n')
153 };
154 (
155 cursor_id,
156 cursor.position,
157 cursor.anchor,
158 cursor.sticky_column,
159 cursor.deselect_on_move,
160 at_line_ending,
161 at_line_start,
162 )
163 })
164 .collect()
165 };
166
167 let mut events = Vec::new();
168
169 for (
170 cursor_id,
171 position,
172 anchor,
173 sticky_column,
174 deselect_on_move,
175 at_line_ending,
176 at_line_start,
177 ) in cursor_data
178 {
179 let (new_pos, new_sticky) = match &visual_action {
180 VisualAction::UpDown {
181 direction,
182 is_select,
183 } => {
184 // When a selection is active, plain (non-selecting) vertical
185 // motion starts from the selection's edge closest to the
186 // motion direction (top edge for Up, bottom edge for Down),
187 // matching VSCode/Sublime/browser behavior (issue #1566).
188 // Emacs mark-mode (`deselect_on_move == false`) is unaffected.
189 let from_pos = if deselect_on_move && !*is_select {
190 if let Some(anchor) = anchor {
191 if *direction < 0 {
192 position.min(anchor)
193 } else {
194 position.max(anchor)
195 }
196 } else {
197 position
198 }
199 } else {
200 position
201 };
202
203 // Calculate current visual column from cached layout
204 let current_visual_col = self
205 .cached_layout
206 .byte_to_visual_column(split_id, from_pos)?;
207
208 let goal_visual_col = if sticky_column > 0 {
209 sticky_column
210 } else {
211 current_visual_col
212 };
213
214 match self.cached_layout.move_visual_line(
215 split_id,
216 from_pos,
217 goal_visual_col,
218 *direction,
219 ) {
220 Some(result) => result,
221 None => {
222 // Target visual row is past the cached view-line
223 // mappings — the destination row isn't in the
224 // currently-rendered viewport slice. In wrap mode
225 // that means the next visual row belongs to a
226 // logical line (or wrapped segment) that is
227 // off-screen. Compute its position directly from
228 // the buffer + wrap config so we don't fall
229 // through to the byte-based MoveDown handler,
230 // which would treat `goal_visual_col` as a
231 // *logical* column on the whole next logical
232 // line and teleport the cursor deep into a
233 // wrapped paragraph (issue #1574, jump variant).
234 match self.compute_wrap_aware_visual_move_fallback(
235 from_pos,
236 goal_visual_col,
237 *direction,
238 _estimated_line_length,
239 ) {
240 Some(result) => result,
241 None => continue, // Genuinely at buffer boundary
242 }
243 }
244 }
245 }
246 VisualAction::LineEnd { .. } => {
247 // Allow advancing to next visual segment only if not at a physical line ending
248 let allow_advance = !at_line_ending;
249 match self
250 .cached_layout
251 .visual_line_end(split_id, position, allow_advance)
252 {
253 Some(end_pos) => (end_pos, 0),
254 None => return None,
255 }
256 }
257 VisualAction::LineStart { .. } => {
258 // Allow advancing to previous visual segment only if not at a physical line start
259 let allow_advance = !at_line_start;
260 match self
261 .cached_layout
262 .visual_line_start(split_id, position, allow_advance)
263 {
264 Some(start_pos) => (start_pos, 0),
265 None => return None,
266 }
267 }
268 };
269
270 let is_select = match &visual_action {
271 VisualAction::UpDown { is_select, .. } => *is_select,
272 VisualAction::LineEnd { is_select } => *is_select,
273 VisualAction::LineStart { is_select } => *is_select,
274 };
275
276 let new_anchor = if is_select {
277 Some(anchor.unwrap_or(position))
278 } else if deselect_on_move {
279 None
280 } else {
281 anchor
282 };
283
284 events.push(Event::MoveCursor {
285 cursor_id,
286 old_position: position,
287 new_position: new_pos,
288 old_anchor: anchor,
289 new_anchor,
290 old_sticky_column: sticky_column,
291 new_sticky_column: new_sticky,
292 });
293 }
294
295 if events.is_empty() {
296 None // Let the default handler deal with it
297 } else {
298 Some(events)
299 }
300 }
301
302 /// Compute a wrap-aware target position when the cached view-line
303 /// mappings don't cover the requested direction.
304 ///
305 /// `move_visual_line` returns `None` when the target visual row is
306 /// past the currently-rendered viewport — typically because the
307 /// destination line wraps off-screen below (for Down) or above (for
308 /// Up). The generic MoveDown/MoveUp fallback that normally kicks in
309 /// when the intercept returns None treats `goal_visual_col` as a
310 /// column on the whole next logical line, which is wrong for wrap
311 /// mode: if the next logical line is a long wrapped paragraph, the
312 /// cursor lands several visual rows deep (issue #1574, jump variant).
313 ///
314 /// This helper uses the current row's `line_end_byte` (which the
315 /// cached layout does know) to find the byte position just past the
316 /// current visual row, and lands the cursor at the *start* of the
317 /// next visual row. That's conservative (the sticky visual column
318 /// from the previous row isn't preserved across an off-screen jump)
319 /// but it reliably places the cursor on the first visual row of
320 /// the next logical line / wrapped segment instead of somewhere
321 /// deep inside it. Preserving sticky precisely when the target row
322 /// is off-screen would require re-running the full token-based
323 /// wrapping pipeline for the target line, which the editor doesn't
324 /// currently expose outside of the render pipeline.
325 ///
326 /// Returns `Some((new_position, new_sticky))` on success, or `None`
327 /// if wrap mode is off (delegate to caller default) or we're at a
328 /// genuine buffer boundary.
329 fn compute_wrap_aware_visual_move_fallback(
330 &mut self,
331 from_pos: usize,
332 goal_visual_col: usize,
333 direction: i8,
334 estimated_line_length: usize,
335 ) -> Option<(usize, usize)> {
336 if !self.config.editor.line_wrap {
337 // Non-wrap mode: the byte-based fallback is correct, let it run.
338 return None;
339 }
340
341 let active_split = self.effective_active_split();
342 let active_buffer = self.active_buffer();
343
344 if direction > 0 {
345 // Find current row's end byte via cached layout — this is the
346 // authoritative "end of current visual row" position that the
347 // renderer itself uses.
348 let cur_row_line_end = {
349 let mappings = self.cached_layout.view_line_mappings.get(&active_split)?;
350 let row_idx = self.cached_layout.find_visual_row(active_split, from_pos)?;
351 mappings.get(row_idx)?.line_end_byte
352 };
353
354 let state = self.buffers.get_mut(&active_buffer)?;
355 let buffer = &mut state.buffer;
356 let buffer_len = buffer.len();
357 if cur_row_line_end >= buffer_len {
358 return None; // Genuine end of buffer
359 }
360
361 // Step past the newline at `cur_row_line_end`, mirroring the
362 // tokenization logic in `build_base_tokens`: CRLF (`\r\n`) is a
363 // SINGLE logical line break and the next logical line starts two
364 // bytes past the `\r`, not one. Falling back to `+ 1` lands the
365 // cursor on the `\n` inside the CRLF pair, which
366 // `find_view_line_for_byte` resolves back to the SAME row — so
367 // pressing Down from an empty separator line on a CRLF file
368 // appears to jump the cursor to the wrong visual row (issue
369 // #1574, Windows-CRLF variant). When `cur_row_line_end` isn't a
370 // newline the current row is a wrapped continuation and the
371 // next visual row starts at the same byte position.
372 let target_pos = step_past_line_break(buffer, cur_row_line_end, buffer_len);
373 if target_pos > buffer_len {
374 return None;
375 }
376
377 // Preserve goal_visual_col as the new sticky column so if the
378 // user keeps pressing Down the normal cached-layout path will
379 // honor it once the target row is rendered.
380 let _ = estimated_line_length;
381 Some((target_pos, goal_visual_col))
382 } else {
383 // Up-direction fallback: mirror the Down logic. Use the
384 // cached layout to locate the current visual row's "anchor"
385 // byte (the row start for rows with visible content, or
386 // `line_end_byte` for empty rows which have no source
387 // mapping), then step back one byte so the cursor lands on
388 // the *end* of the preceding visual row.
389 //
390 // For a row whose start is a logical-line-start, stepping
391 // back one byte lands on the trailing newline of the
392 // previous logical line — the renderer shows this as the
393 // end of the last visual row of that line, which is exactly
394 // where the cursor should land when walking Up.
395 //
396 // For a wrapped continuation row, the "start" is already a
397 // byte within the same logical line; stepping back one byte
398 // keeps us inside the line on the previous wrapped segment.
399 //
400 // For empty rows (no char_source_bytes, common at paragraph
401 // separators), `line_end_byte` is the empty line's newline;
402 // stepping back one byte lands on the previous line's
403 // trailing newline — again the end of its last visual row.
404 let (cur_row_anchor, row_is_empty) = {
405 let mappings = self.cached_layout.view_line_mappings.get(&active_split)?;
406 let row_idx = self.cached_layout.find_visual_row(active_split, from_pos)?;
407 let row = mappings.get(row_idx)?;
408 match row.char_source_bytes.iter().find_map(|b| *b) {
409 Some(start) => (start, false),
410 None => (row.line_end_byte, true),
411 }
412 };
413
414 if cur_row_anchor == 0 {
415 return None; // At the very beginning of the buffer
416 }
417
418 // Step back across the newline preceding `cur_row_anchor`,
419 // mirroring the tokenization logic in `build_base_tokens`:
420 // CRLF is a SINGLE logical line break so we must step back
421 // two bytes over it, not one. Blindly subtracting 1 on a
422 // CRLF file lands the cursor on the `\n` INSIDE the CRLF
423 // pair, which `find_view_line_for_byte` resolves to a row
424 // the user wouldn't expect (issue #1574, Windows-CRLF
425 // variant). For LF or a lone CR the byte arithmetic falls
426 // through to a one-byte step.
427 let state = self.buffers.get_mut(&active_buffer)?;
428 let buffer = &mut state.buffer;
429 let _ = row_is_empty;
430 let target_pos = step_before_line_break(buffer, cur_row_anchor);
431 let _ = estimated_line_length;
432 Some((target_pos, goal_visual_col))
433 }
434 }
435}
436
437/// Advance past the line break at `pos`, matching the CRLF handling in
438/// `build_base_tokens` (where `\r\n` is a single logical line break
439/// represented by one `Newline` token at the `\r`). When `pos` is on a
440/// `\r` immediately followed by `\n` we step two bytes; on a lone `\n`
441/// or `\r` we step one; otherwise (`pos` isn't on a newline, i.e. a
442/// wrapped-continuation boundary) we return `pos` unchanged so the next
443/// visual row starts at the same byte. Without this, pressing Down
444/// across a CRLF newline lands the cursor on the `\n` inside the pair,
445/// which `find_view_line_for_byte` resolves back to the *same* row
446/// (issue #1574, Windows-CRLF variant).
447fn step_past_line_break(
448 buffer: &crate::model::buffer::Buffer,
449 pos: usize,
450 buffer_len: usize,
451) -> usize {
452 if pos >= buffer_len {
453 return pos;
454 }
455 let end = (pos + 2).min(buffer_len);
456 let bytes = buffer.slice_bytes(pos..end);
457 match (bytes.first(), bytes.get(1)) {
458 (Some(b'\r'), Some(b'\n')) => pos + 2,
459 (Some(b'\r'), _) | (Some(b'\n'), _) => pos + 1,
460 _ => pos,
461 }
462}
463
464/// Step back across the line break immediately preceding `pos`, mirror
465/// of [`step_past_line_break`]. Two bytes for CRLF (`\r\n`), one for
466/// LF or a lone CR, zero if `pos == 0`. Callers use this to land the
467/// cursor at the *end* of the previous visual row when moving Up across
468/// a newline — landing mid-CRLF would place the cursor on the `\n` and
469/// re-resolve to the same row (issue #1574, Windows-CRLF variant).
470fn step_before_line_break(buffer: &crate::model::buffer::Buffer, pos: usize) -> usize {
471 if pos == 0 {
472 return pos;
473 }
474 if pos >= 2 {
475 let bytes = buffer.slice_bytes((pos - 2)..pos);
476 if bytes.first() == Some(&b'\r') && bytes.get(1) == Some(&b'\n') {
477 return pos - 2;
478 }
479 }
480 pos - 1
481}