Skip to main content

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}