Skip to main content

hjkl_engine/
cursor_move.rs

1//! Cursor moves that carry their own `curswant` semantics — phase 1 of
2//! `docs/backlog.md`.
3//!
4//! Today, moving the cursor and maintaining `sticky_col` (vim's `curswant`)
5//! are two separate actions, and ~186 call sites do only the first. [`Move`]
6//! makes "what does this do to `curswant`?" a required argument of moving
7//! rather than a follow-up call that can be forgotten: a new motion must name
8//! a variant to compile.
9//!
10//! This module adds the API only. It is implemented over the existing
11//! primitives so behaviour is bit-identical, and nothing is migrated onto it
12//! yet — phases 2–4 do that, phase 5 then seals the raw primitives
13//! (`buf_set_cursor_rc`, `View::set_cursor`) behind it.
14
15use crate::Editor;
16use crate::buf_helpers::{buf_line, buf_line_chars};
17use hjkl_buffer::visual_col_to_char_col;
18
19/// How a cursor move relates to `sticky_col` (vim's `curswant`) — the column
20/// `j` / `k` aim at.
21///
22/// Vim's rule has exactly two halves, and every variant here encodes one of
23/// them (or, for [`Move::Raw`], deliberately opts out):
24///
25/// - **vertical** motions READ `curswant`, clamp to the target row's length,
26///   and leave `curswant` alone, so a run of `j` through short lines returns
27///   to the original column on the far side;
28/// - **everything else** that moves the cursor SETS `curswant` to the column
29///   it landed on, so the next `j` aims there.
30///
31/// Pick by asking what the move *is*, not by what is convenient: the whole
32/// point of the enum is that the answer gets recorded at the call site.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Move {
35    /// `j` / `k` and their screen-line equivalents (`<C-e>` / `<C-y>`).
36    ///
37    /// READS `sticky_col`, clamps the landing column to `row`'s length, and
38    /// leaves `sticky_col` holding the *un-clamped* want — that is what lets
39    /// the column survive a short line and reappear on the next long one.
40    /// Bootstraps `sticky_col` from the current column when nothing has set
41    /// it yet.
42    Vertical { row: usize },
43
44    /// An explicit jump to a position: a search hit, `gg` / `G`, a mark, a
45    /// mouse click, a picker `<CR>`, `]d`, a jumplist entry.
46    ///
47    /// SETS `sticky_col` to `col`. Vim resets `curswant` on every explicit
48    /// jump, so the next `j` aims at the landed column rather than at
49    /// wherever the cursor sat beforehand — the bug fixed in `c022a3a4`.
50    Jump { row: usize, col: usize },
51
52    /// A move within the current row: `h`, `l`, `w`, `b`, `e`, `f` / `t`,
53    /// `0`, `^`, `$`.
54    ///
55    /// SETS `sticky_col` to `col`, same half of the rule as [`Move::Jump`];
56    /// the separate variant exists so horizontal call sites cannot silently
57    /// pass the wrong row.
58    Horizontal { col: usize },
59
60    /// Place the cursor without touching `sticky_col` at all.
61    ///
62    /// This is the *conspicuous* variant on purpose. It is not the default
63    /// landing zone for a site whose semantics are unclear — a mechanical
64    /// migration that picked `Raw` everywhere would compile, pass, and
65    /// preserve the entire bug class. It is legitimate only where some other
66    /// code owns `curswant` for the duration, i.e.:
67    ///
68    /// - the clamped write inside a vertical motion itself
69    ///   ([`Move::Vertical`]'s internal write, which must preserve the
70    ///   un-clamped want it just stored);
71    /// - restoring a position saved earlier in the same operation (operator
72    ///   bodies that park the cursor, run an edit, then put it back), where
73    ///   the cursor is semantically where it already was;
74    /// - host-side state restores — viewport sync, snapshot replay — where
75    ///   the host's own sticky tracking is authoritative.
76    ///
77    /// Any other use wants [`Move::Jump`]. Each `Raw` should carry a one-line
78    /// reason at its call site, and it stays greppable for review.
79    Raw { row: usize, col: usize },
80}
81
82impl<H: crate::types::Host> Editor<hjkl_buffer::View, H> {
83    /// Move the cursor, applying the `curswant` rule the [`Move`] variant
84    /// names. See [`Move`] for which variant a given motion is.
85    pub fn move_cursor(&mut self, m: Move) {
86        match m {
87            Move::Vertical { row } => {
88                // Bootstrap from the current column on the very first vertical
89                // motion, exactly as `apply_sticky_col` does.
90                let (current_row, current_col) = self.cursor();
91                let want = self
92                    .sticky_col()
93                    .unwrap_or_else(|| self.cursor_want(current_row, current_col));
94                // Record the un-clamped want first so the next vertical motion
95                // still sees it even though this one may clamp short.
96                self.set_sticky_col(Some(want));
97                // Clamp to the last char on non-empty rows — vim normal mode
98                // never parks one past end of line; empty rows collapse to 0.
99                // Visual mode (with the default 'selection') may stand on the
100                // line's NUL, one past it (vim's `coladvance` `one_more`).
101                let line = buf_line(&self.buffer, row).unwrap_or_default();
102                let char_col = visual_col_to_char_col(&line, want, self.settings().tabstop);
103                let len = buf_line_chars(&self.buffer, row);
104                let selecting = matches!(
105                    self.coarse_mode(),
106                    crate::CoarseMode::Select
107                        | crate::CoarseMode::SelectLine
108                        | crate::CoarseMode::SelectBlock
109                );
110                let max_col = if selecting {
111                    len
112                } else {
113                    len.saturating_sub(1)
114                };
115                // Quiet write: `jump_cursor` would overwrite the want stored
116                // above with the clamped column.
117                self.set_cursor_quiet(row, char_col.min(max_col));
118            }
119            Move::Jump { row, col } => self.jump_cursor(row, col),
120            Move::Horizontal { col } => {
121                let row = self.cursor().0;
122                self.jump_cursor(row, col);
123            }
124            Move::Raw { row, col } => self.set_cursor_quiet(row, col),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::Move;
132    use crate::Editor;
133    use crate::types::{DefaultHost, Options};
134    use hjkl_buffer::View;
135
136    fn editor(text: &str) -> Editor<View, DefaultHost> {
137        Editor::new(View::from_str(text), DefaultHost::new(), Options::default())
138    }
139
140    /// `Vertical` reads `sticky_col`, clamps to the row, and leaves the
141    /// un-clamped want in place so a later long row can restore it.
142    #[test]
143    fn vertical_clamps_but_preserves_the_unclamped_want() {
144        // row 0 is long, row 1 is short, row 2 is long again.
145        let mut e = editor("abcdefghij\nab\nabcdefghij");
146        e.move_cursor(Move::Horizontal { col: 7 });
147        assert_eq!(e.cursor(), (0, 7));
148        assert_eq!(e.sticky_col(), Some(7));
149
150        // Down onto the short row: the cursor clamps to its last char...
151        e.move_cursor(Move::Vertical { row: 1 });
152        assert_eq!(e.cursor(), (1, 1));
153        // ...but the want survives un-clamped.
154        assert_eq!(e.sticky_col(), Some(7));
155
156        // Down again onto a long row: the original column comes back.
157        e.move_cursor(Move::Vertical { row: 2 });
158        assert_eq!(e.cursor(), (2, 7));
159        assert_eq!(e.sticky_col(), Some(7));
160    }
161
162    /// An empty row collapses to column 0 and still keeps the want.
163    #[test]
164    fn vertical_over_an_empty_row_collapses_to_col_zero() {
165        let mut e = editor("abcdef\n\nabcdef");
166        e.move_cursor(Move::Horizontal { col: 4 });
167        e.move_cursor(Move::Vertical { row: 1 });
168        assert_eq!(e.cursor(), (1, 0));
169        assert_eq!(e.sticky_col(), Some(4));
170        e.move_cursor(Move::Vertical { row: 2 });
171        assert_eq!(e.cursor(), (2, 4));
172    }
173
174    /// With no `sticky_col` yet, `Vertical` bootstraps from the current column.
175    #[test]
176    fn vertical_bootstraps_the_want_from_the_current_column() {
177        let mut e = editor("abcdef\nabcdef");
178        e.move_cursor(Move::Raw { row: 0, col: 3 });
179        assert_eq!(e.sticky_col(), None);
180        e.move_cursor(Move::Vertical { row: 1 });
181        assert_eq!(e.cursor(), (1, 3));
182        assert_eq!(e.sticky_col(), Some(3));
183    }
184
185    /// `Jump` sets the want to the column it landed on — the search-hit /
186    /// `gg` / mark rule, and the fix `c022a3a4` made structural.
187    #[test]
188    fn jump_sets_the_want_to_the_landed_column() {
189        let mut e = editor("abcdefghij\nabcdefghij");
190        e.move_cursor(Move::Horizontal { col: 9 });
191        assert_eq!(e.sticky_col(), Some(9));
192
193        e.move_cursor(Move::Jump { row: 1, col: 2 });
194        assert_eq!(e.cursor(), (1, 2));
195        assert_eq!(e.sticky_col(), Some(2), "a jump resets curswant");
196    }
197
198    /// `Horizontal` keeps the row and sets the want to the new column.
199    #[test]
200    fn horizontal_keeps_the_row_and_sets_the_want() {
201        let mut e = editor("abcdefghij\nabcdefghij");
202        e.move_cursor(Move::Jump { row: 1, col: 8 });
203        e.move_cursor(Move::Horizontal { col: 3 });
204        assert_eq!(e.cursor(), (1, 3));
205        assert_eq!(e.sticky_col(), Some(3));
206    }
207
208    /// `Raw` moves the cursor and leaves `sticky_col` exactly as it was —
209    /// including leaving it `None`.
210    #[test]
211    fn raw_leaves_the_want_untouched() {
212        let mut e = editor("abcdefghij\nabcdefghij");
213        e.move_cursor(Move::Horizontal { col: 6 });
214        assert_eq!(e.sticky_col(), Some(6));
215
216        e.move_cursor(Move::Raw { row: 1, col: 1 });
217        assert_eq!(e.cursor(), (1, 1));
218        assert_eq!(e.sticky_col(), Some(6), "Raw must not disturb curswant");
219
220        // And a `Raw` before anything has set a want leaves it unset.
221        let mut fresh = editor("abcdefghij");
222        fresh.move_cursor(Move::Raw { row: 0, col: 4 });
223        assert_eq!(fresh.cursor(), (0, 4));
224        assert_eq!(fresh.sticky_col(), None);
225    }
226
227    #[test]
228    fn vertical_bootstraps_display_want_from_the_current_column() {
229        let mut e = editor("\tabcdef\n\txyz");
230        e.move_cursor(Move::Raw { row: 0, col: 2 });
231        assert_eq!(e.sticky_col(), None);
232
233        e.move_cursor(Move::Vertical { row: 1 });
234        assert_eq!(e.cursor(), (1, 2));
235        assert_eq!(e.sticky_col(), Some(5));
236    }
237
238    /// Display columns are converted back to character columns before clamping,
239    /// so tabs do not make the cursor land too far right.
240    #[test]
241    fn vertical_converts_display_want_to_a_character_column() {
242        let mut e = editor("\tabcdef\n\txyz");
243        e.move_cursor(Move::Horizontal { col: 2 });
244        assert_eq!(e.sticky_col(), Some(5));
245
246        e.move_cursor(Move::Vertical { row: 1 });
247        assert_eq!(e.cursor(), (1, 2));
248        assert_eq!(e.sticky_col(), Some(5));
249    }
250
251    /// `Vertical` reproduces `apply_sticky_col`'s vertical branch exactly —
252    /// the property phases 2–4 rely on when they swap call sites over.
253    #[test]
254    fn vertical_matches_the_apply_sticky_col_clamp() {
255        for (want, row, expected) in [(7usize, 1usize, 1usize), (0, 1, 0), (1, 1, 1), (99, 0, 9)] {
256            let mut e = editor("abcdefghij\nab");
257            e.move_cursor(Move::Horizontal { col: 0 });
258            e.set_sticky_col(Some(want));
259            e.move_cursor(Move::Vertical { row });
260            assert_eq!(e.cursor(), (row, expected), "want {want} onto row {row}");
261            assert_eq!(e.sticky_col(), Some(want), "want {want} must survive");
262        }
263    }
264
265    /// `Vertical` onto a line containing double-width chars must land on the
266    /// char whose PAINTED cells contain the wanted column, not on the Nth
267    /// char. Oracle: neovim 0.12.4, buffer `abcdefgh` / `ab世界cd`, `j` from
268    /// each byte column of row 0 (1-based cols → 0-based here):
269    ///
270    /// ```text
271    ///   col 1 -> bytecol 1 (a)    col 5 -> bytecol 6 (界)
272    ///   col 2 -> bytecol 2 (b)    col 6 -> bytecol 6 (界)
273    ///   col 3 -> bytecol 3 (世)   col 7 -> bytecol 9 (c)
274    ///   col 4 -> bytecol 3 (世)   col 8 -> bytecol 10 (d)
275    /// ```
276    #[test]
277    fn vertical_onto_wide_chars_matches_nvim() {
278        // 0-based (want, char index on `ab世界cd`)
279        for (want, expected) in [
280            (0usize, 0usize), // a
281            (1, 1),           // b
282            (2, 2),           // 世, leading cell
283            (3, 2),           // 世, trailing cell
284            (4, 3),           // 界, leading cell
285            (5, 3),           // 界, trailing cell
286            (6, 4),           // c
287            (7, 5),           // d
288        ] {
289            let mut e = editor("abcdefgh\nab世界cd");
290            e.move_cursor(Move::Horizontal { col: want });
291            assert_eq!(e.sticky_col(), Some(want));
292            e.move_cursor(Move::Vertical { row: 1 });
293            assert_eq!(e.cursor(), (1, expected), "want {want} onto the wide row");
294        }
295    }
296
297    /// The other direction: leaving a wide line carries the char's LEADING
298    /// visual cell as `curswant`. nvim, `ab世界cd` / `abcdefgh`: with the
299    /// cursor on `界` (bytecol 6) `getcurpos()[4]` is 5 and `j` lands on
300    /// bytecol 5 of the ASCII row — 0-based column 4.
301    #[test]
302    fn vertical_off_a_wide_line_uses_the_leading_cell() {
303        for (start_char, expected_ascii) in [(2usize, 2usize), (3, 4), (4, 6), (5, 7)] {
304            let mut e = editor("ab世界cd\nabcdefgh");
305            e.move_cursor(Move::Horizontal { col: start_char });
306            e.move_cursor(Move::Vertical { row: 1 });
307            assert_eq!(
308                e.cursor(),
309                (1, expected_ascii),
310                "from wide char {start_char}"
311            );
312        }
313    }
314
315    /// A combining mark is never a landing site. nvim, `abcdefgh` /
316    /// `ae\u{301}b`: `j` from col 3 (0-based 2) lands on bytecol 5 — the `b`,
317    /// char index 3 — skipping the mark at char index 2.
318    #[test]
319    fn vertical_onto_a_combining_mark_lands_on_the_next_char() {
320        let mut e = editor("abcdefgh\nae\u{301}b");
321        e.move_cursor(Move::Horizontal { col: 2 });
322        e.move_cursor(Move::Vertical { row: 1 });
323        assert_eq!(e.cursor(), (1, 3));
324    }
325}