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        // 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}