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