Skip to main content

lattice_ui_tui/app/
motions.rs

1//! Motion-feature App methods that aren't already
2//! implemented by the grammar layer in `lattice-grammar`.
3//! Most motions evaluate via the grammar's `Motion` type;
4//! this module is for the App-side cases that need state
5//! beyond the buffer (history pushes, fold auto-open,
6//! multi-step scans).
7//!
8//! Methods that live here:
9//! - `do_match_bracket` (`%` -- scan from cursor for the
10//!   first bracket on the current line, then walk to its
11//!   match; pushes position history and auto-opens folds).
12//! - `scan_forward_for_match` /
13//!   `scan_backward_for_match` (file-private helpers for
14//!   the bracket walk).
15//! - `push_position_history` (the write side of the
16//!   position-history ring; pairs with `do_walk_history`
17//!   here). `POSITION_HISTORY_CAP` lives alongside it.
18//! - `clamp_cursor_to_buffer` /
19//!   `clamp_cursor_to_active_buffer` /
20//!   `ensure_cursor_visible` -- foundational primitives the
21//!   viewport / scroll family depends on.
22//!
23//! What does NOT live here: the motion *grammar* (rules and
24//! parser) -- that lives in `lattice-grammar`. This module
25//! is the App-side glue for motions that need richer state
26//! than the grammar provides.
27
28use lattice_core::Buffer;
29use lattice_protocol::position::Position;
30
31use super::{App, BufferId, BufferKind, PositionSource};
32
33// Cap on entries in the position-history ring. The write side
34// (`push_position_history`) drops the oldest entry when this
35// is exceeded; the walkers (`do_walk_history` etc.) clamp
36// against the live length.
37// 5.5.F.4.2: `POSITION_HISTORY_CAP` relocated to
38// `lattice_host::dispatch::POSITION_HISTORY_CAP` alongside
39// `Editor::push_position_history`. No App-side consumer remains.
40
41impl App {
42    // 5.5.G.4: `do_match_bracket` (+ private `scan_forward_for_match`
43    // / `scan_backward_for_match` helpers) migrated to
44    // [`lattice_host::dispatch::Editor`].
45}
46
47impl App {
48    // 5.5.G.7: `do_jump_history` migrated to
49    // [`lattice_host::dispatch::Editor::do_jump_history`] (zero
50    // App callers; deleted).
51
52    // 5.5.G.6: body migrated to
53    // [`lattice_host::dispatch::Editor::do_mark_history`]. App
54    // callers (none today; `Action::WalkMark*` arms now route
55    // host-side) eliminated.
56
57    // 5.5.H: `do_walk_history` App-side delegate retired (zero
58    // callers; `pop_popup_back` + `do_jump_history` migrated
59    // host-side in 5.5.G.7, removing the last App caller). Host
60    // copy at [`lattice_host::dispatch::Editor::do_walk_history`].
61
62    /// 5.5.D: cursor clamp moved to
63    /// [`lattice_host::editor::Editor::clamp_cursor_to_active_buffer`].
64    /// Renderer call sites keep the thin wrapper for now; 5.5.G
65    /// removes it when App's match collapses.
66    pub(super) fn clamp_cursor_to_buffer(&mut self) {
67        // Slice 3c.final.E.2: route through `mutate_editor`.
68        self.mutate_editor(|e| e.clamp_cursor_to_active_buffer());
69    }
70
71    /// 5.5.D: see [`Self::clamp_cursor_to_buffer`]. Delegates to the
72    /// host-side implementation.
73    pub(super) fn clamp_cursor_to_active_buffer(&mut self) {
74        // Slice 3c.final.E.2: route through `mutate_editor`.
75        self.mutate_editor(|e| e.clamp_cursor_to_active_buffer());
76    }
77
78    /// 5.5.D: viewport-scroll-to-cursor logic moved to
79    /// [`lattice_host::editor::Editor::ensure_cursor_visible`].
80    pub(super) fn ensure_cursor_visible(&mut self) {
81        // Slice 3c.final.E.2: route through `mutate_editor`.
82        self.mutate_editor(|e| e.ensure_cursor_visible());
83    }
84
85    pub fn set_viewport_height(&mut self, height: u32) {
86        // Slice 3c.final.C: route through `Action::SetViewportHeight`
87        // so the renderer no longer mutates editor state directly.
88        // Dispatch tail publishes RS — no manual publish needed.
89        self.apply(lattice_host::action::Action::SetViewportHeight(height));
90    }
91
92    /// Compute the active pane's *content* height inside a buffer
93    /// area of `buffer_height` rows. Mirrors the renderer's per-pane
94    /// layout: the pane tree splits the area evenly; with more than
95    /// one pane, the bottom row of each pane is reserved for the
96    /// status line. Returns at least 1 so callers can multiply / use
97    /// without checking for zero.
98    ///
99    /// Used by the runtime to feed `set_viewport_height` the
100    /// **active pane's** content height -- not the full buffer area
101    /// -- so motions, scroll, fold-aware ensure_cursor_visible all
102    /// agree with what's actually drawn. Without this, a horizontal
103    /// split clips the lower half of the upper pane: the App thinks
104    /// it has the whole screen, the renderer only paints half.
105    ///
106    /// **Help-popup overlay (State B).** When the focus has moved
107    /// into a hover/help popup that paints as a centred overlay
108    /// (active_buffer == Help, but the active pane still shows a
109    /// Document underneath), the popup -- not the pane -- is the
110    /// surface receiving motion. Returning the *popup's inner
111    /// height* here keeps `ensure_cursor_visible` and the renderer
112    /// in sync: without it, `j` past the last *visible* popup row
113    /// silently advanced `cursor.line` (the pane viewport is much
114    /// taller than the popup, so the App thought the cursor was
115    /// fine) and the renderer pinned the cursor visually to the
116    /// last drawn row -- so subsequent `k` had to "unwind" the
117    /// phantom overshoot before any visible motion. Help-as-buffer
118    /// (in-pane help, where pane.buffer == Help) doesn't take this
119    /// branch -- the pane content height is the right answer.
120    pub fn active_pane_content_height(&self, buffer_height: u32) -> u32 {
121        if let Some(h) = self.help_popup_inner_height(buffer_height) {
122            return h;
123        }
124        let area = crate::pane::PaneRect {
125            x: 0,
126            y: 0,
127            width: 1,
128            height: buffer_height as u16,
129        };
130        // Slice 3c.final.E.5i: pane_tree reads now route through
131        // the published `panes()` sub-state (RS-backed accessor;
132        // wait-free `Arc<PaneTree>` clone). Tests that mutate
133        // `editor.pane_tree` directly are updated to call
134        // `publish_render_state()` after the mutation so the
135        // accessor reflects the change.
136        let panes = self.panes();
137        let rects = panes.tree.compute_rects(area);
138        let active_idx = panes.tree.active_index();
139        let pane_h = rects
140            .iter()
141            .find(|(idx, _)| *idx == active_idx)
142            .map(|(_, r)| r.height)
143            .unwrap_or(buffer_height as u16);
144        // Option A: every pane has a 1-row status footer; subtract
145        // unconditionally (mirrors draw_panes and runtime.rs).
146        let content_h = if pane_h >= 2 { pane_h - 1 } else { pane_h };
147        u32::from(content_h).max(1)
148    }
149
150    /// Inner height of the hover/help popup overlay when one is
151    /// active in State B (focused popup, doc still showing in the
152    /// pane below). `None` when no overlay is active or help fills
153    /// the pane (in which case the regular pane-content-height
154    /// path applies).
155    ///
156    /// Sizing matches `render::position_help_popup` exactly so the
157    /// motion engine and the renderer agree on the popup viewport.
158    /// Border rows (top + bottom) are subtracted; the result is
159    /// the row count `Paragraph` actually paints into.
160    pub fn help_popup_inner_height(&self, buffer_height: u32) -> Option<u32> {
161        // 3c.atomic.E: renderer-side read through the published
162        // `buffer_kind` mirror of `editor.active_buffer`.
163        if !matches!(self.ad().buffer_kind, BufferKind::Help) {
164            return None;
165        }
166        // Slice 3c.final.E.5i: pane_tree + popup_placement reads
167        // route through `panes()` / `popup()` RS accessors.
168        if self.panes().tree.active().buffer == BufferKind::Help {
169            return None;
170        }
171        let help = self.popup_help()?;
172        // Single source of truth for popup sizing -- keeps the
173        // renderer's painted viewport and the motion engine's
174        // scroll bounds in lockstep regardless of placement.
175        // Buffer width is unknown here (the caller passes height
176        // only); pass a generous synthetic width since the
177        // helper's height calc doesn't depend on width.
178        let line_count = u16::try_from(help.line_count().max(1)).unwrap_or(u16::MAX);
179        let buffer_h = u16::try_from(buffer_height.max(1)).unwrap_or(u16::MAX);
180        let (_w, height) = lattice_core::ui::popup::popup_outer_size(
181            u16::MAX,
182            buffer_h,
183            line_count,
184            self.popup().placement,
185        );
186        Some(u32::from(height).saturating_sub(2).max(1))
187    }
188
189    // Jump the cursor to a viewport-relative line. `H` -> top of view,
190    // `M` -> middle, `L` -> bottom. Column is preserved (clamped to the
191    // destination line's length).
192    // 5.5.G.4: `do_jump_viewport`, `do_scroll_cursor_to`, `do_page`,
193    // `do_scroll_line` migrated to [`lattice_host::dispatch::Editor`].
194
195    /// `<C-t>` -- pop the tag stack (vim's `:pop`). LIFO walk
196    /// back through the chain of `gd` / `gD` / `gy` / `gI`
197    /// drill-downs. Echoes "tag stack empty" with no entries.
198    /// Restores the recorded buffer (when cross-file) and
199    /// position; pushes the post-pop cursor onto the unified
200    /// position-history ring (PluginPush) so `<C-o>` continues
201    /// to walk the chronological jump record after a `<C-t>`
202    /// step.
203    // 5.5.G.7: `do_tag_stack_pop` migrated to
204    // [`lattice_host::dispatch::Editor::do_tag_stack_pop`] (zero
205    // App callers; deleted).
206
207    /// 5.5.G.7: body migrated to
208    /// [`lattice_host::dispatch::Editor::do_jump_mark`]. Delegate
209    /// retained because `app/picker.rs` mark-picker accept still
210    /// calls it.
211    pub(super) fn do_jump_mark(&mut self, name: char, exact: bool) {
212        // Slice 3c.final.E.2: route through `mutate_editor`.
213        self.mutate_editor(move |e| e.do_jump_mark(name, exact));
214    }
215
216    /// 5.5.F.4.2: body relocated to
217    /// [`lattice_host::dispatch::Editor::push_position_history`].
218    /// Delegate retained so the ~30 ui-tui call sites compile
219    /// unchanged across the wider `motions.rs` migration window.
220    pub(super) fn push_position_history(&mut self, pos: Position, source: PositionSource) {
221        // Slice 3c.final.E.2: route through `mutate_editor`.
222        self.mutate_editor(move |e| e.push_position_history(pos, source));
223    }
224
225    /// Id of whichever buffer is currently active. The active
226    /// pane's `buffer_id` is the source of truth -- documents and
227    /// trees both live in [`Self::buffers`] under one id space.
228    /// Help still lives outside the registry as a transient
229    /// overlay; while help is active we return its id, otherwise
230    /// the active pane's id.
231    pub fn active_buffer_id(&self) -> BufferId {
232        self.read_editor(move |e| e.active_buffer_id())
233    }
234
235    /// 5.5.D: see [`lattice_host::editor::Editor::active_cursor`].
236    pub fn active_cursor(&self) -> Position {
237        self.read_editor(move |e| e.active_cursor())
238    }
239
240    /// 5.5.D: see [`lattice_host::editor::Editor::active_text`].
241    pub fn active_text(&self) -> Buffer {
242        self.read_editor(move |e| e.active_text())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    #![allow(clippy::unwrap_used, clippy::panic)]
249
250    use crate::app::TagStackEntry;
251    use crate::app::test_helpers::{app_with, invoke_motion};
252    use crate::app::*;
253
254    #[test]
255    fn invoke_char_right_advances_cursor() {
256        let mut a = app_with("abc", 10);
257        let id = a.editor.builtins.char_right;
258        a.apply(invoke_motion(id));
259        assert_eq!(a.editor.cursor, Position::new(0, 1));
260    }
261
262    #[test]
263    fn invoke_char_left_at_origin_does_not_underflow() {
264        let mut a = app_with("abc", 10);
265        let id = a.editor.builtins.char_left;
266        a.apply(invoke_motion(id));
267        assert_eq!(a.editor.cursor, Position::ZERO);
268    }
269
270    #[test]
271    fn invoke_line_down_then_line_up() {
272        let mut a = app_with("hello\nworld", 10);
273        let down = a.editor.builtins.line_down;
274        let up = a.editor.builtins.line_up;
275        a.apply(invoke_motion(down));
276        assert_eq!(a.editor.cursor.line, 1);
277        a.apply(invoke_motion(up));
278        assert_eq!(a.editor.cursor.line, 0);
279    }
280
281    #[test]
282    fn invoke_goto_last_line_jumps_to_last_line() {
283        let mut a = app_with("a\nb\nc", 10);
284        let id = a.editor.builtins.goto_last_line;
285        a.apply(invoke_motion(id));
286        assert_eq!(a.editor.cursor.line, 2);
287    }
288
289    #[test]
290    fn invoke_goto_first_line_returns_to_origin() {
291        let mut a = app_with("a\nb\nc", 10);
292        let last = a.editor.builtins.goto_last_line;
293        let first = a.editor.builtins.goto_first_line;
294        a.apply(invoke_motion(last));
295        a.apply(invoke_motion(first));
296        assert_eq!(a.editor.cursor, Position::ZERO);
297    }
298
299    #[test]
300    fn invoke_line_end_moves_to_eol() {
301        let mut a = app_with("hello world", 10);
302        let id = a.editor.builtins.line_end;
303        a.apply(invoke_motion(id));
304        assert_eq!(a.editor.cursor, Position::new(0, 11));
305    }
306
307    #[test]
308    fn ensure_visible_scrolls_when_cursor_goes_off_bottom() {
309        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 3);
310        let id = a.editor.builtins.goto_last_line;
311        a.apply(invoke_motion(id));
312        assert_eq!(a.editor.cursor.line, 9);
313        assert_eq!(a.editor.scroll, 9 - 3 + 1);
314    }
315
316    #[test]
317    fn ensure_visible_scrolls_back_to_top_on_goto_first() {
318        let mut a = app_with("0\n1\n2\n3\n4", 2);
319        let last = a.editor.builtins.goto_last_line;
320        let first = a.editor.builtins.goto_first_line;
321        a.apply(invoke_motion(last));
322        a.apply(invoke_motion(first));
323        assert_eq!(a.editor.scroll, 0);
324    }
325
326    #[test]
327    fn set_mark_clears_partial_chord() {
328        let mut a = app_with("hello", 10);
329        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
330            'm',
331        )));
332        a.apply(Action::SetMark('a'));
333        assert!(a.editor.partial_chord.is_empty());
334    }
335
336    #[test]
337    fn jump_to_mark_clears_partial_chord() {
338        let mut a = app_with("hello\nworld", 10);
339        a.apply(Action::SetMark('a'));
340        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
341            '`',
342        )));
343        a.apply(Action::JumpToMarkExact('a'));
344        assert!(a.editor.partial_chord.is_empty());
345    }
346
347    #[test]
348    fn jump_history_with_no_jumps_emits_error() {
349        let mut a = app_with("hello", 10);
350        a.apply(Action::JumpHistoryBack);
351        let msg = a.editor.last_message.as_ref().unwrap();
352        assert_eq!(msg.level, EchoLevel::Error);
353    }
354
355    #[test]
356    fn gg_pushes_jump_history_and_ctrl_o_returns() {
357        let mut a = app_with("a\nb\nc\nd\ne", 10);
358        a.editor.cursor = Position::new(3, 0); // line 3 ('d')
359        a.apply(invoke_motion(a.editor.builtins.goto_first_line));
360        assert_eq!(a.editor.cursor, Position::ZERO);
361        a.apply(Action::JumpHistoryBack);
362        assert_eq!(a.editor.cursor, Position::new(3, 0));
363    }
364
365    #[test]
366    fn star_pushes_position_history() {
367        let mut a = app_with("foo bar foo", 10);
368        a.editor.cursor = Position::new(0, 1); // on 'o' of first "foo"
369        a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
370        // Cursor now on second "foo" at byte 8.
371        assert_eq!(a.editor.cursor, Position::new(0, 8));
372        a.apply(Action::JumpHistoryBack);
373        assert_eq!(a.editor.cursor, Position::new(0, 1));
374    }
375
376    #[test]
377    fn percent_pushes_position_history() {
378        let mut a = app_with("call(arg)", 10);
379        a.editor.cursor = Position::new(0, 4); // on '('
380        a.apply(Action::MatchBracket);
381        assert_eq!(a.editor.cursor, Position::new(0, 8)); // ')'
382        a.apply(Action::JumpHistoryBack);
383        assert_eq!(a.editor.cursor, Position::new(0, 4));
384    }
385
386    #[test]
387    fn mark_jump_pushes_position_history() {
388        let mut a = app_with("hello\nworld", 10);
389        a.editor.cursor = Position::new(1, 2);
390        a.apply(Action::SetMark('a'));
391        a.editor.cursor = Position::ZERO;
392        a.apply(Action::JumpToMarkExact('a'));
393        assert_eq!(a.editor.cursor, Position::new(1, 2));
394        a.apply(Action::JumpHistoryBack);
395        assert_eq!(a.editor.cursor, Position::ZERO);
396    }
397
398    #[test]
399    fn set_mark_pushes_named_mark_into_position_history() {
400        let mut a = app_with("hello\nworld", 10);
401        a.editor.cursor = Position::new(1, 2);
402        a.apply(Action::SetMark('a'));
403        // Last entry is a NamedMark.
404        let last = a.editor.position_history.last().unwrap();
405        assert_eq!(last.position, Position::new(1, 2));
406        assert!(matches!(last.source, PositionSource::NamedMark('a')));
407    }
408
409    #[test]
410    fn jump_history_filters_to_jump_class_only() {
411        let mut a = app_with("aaa\nbbb\nccc\nddd", 10);
412        // mX (NamedMark) followed by gg (AutoJump). Ctrl-O should walk
413        // to the AutoJump entry, NOT the NamedMark.
414        a.editor.cursor = Position::new(1, 0);
415        a.apply(Action::SetMark('a'));
416        // Position history now has [NamedMark('a') at (1,0)].
417        a.editor.cursor = Position::new(3, 0);
418        a.apply(invoke_motion(a.editor.builtins.goto_first_line));
419        // Now history: [NamedMark('a'), AutoJump (3,0)].
420        a.apply(Action::JumpHistoryBack);
421        // Ctrl-O lands on the AutoJump entry, not the named mark.
422        assert_eq!(a.editor.cursor, Position::new(3, 0));
423    }
424
425    #[test]
426    fn jump_and_mark_walks_share_the_same_ring_cursor() {
427        // After Ctrl-O moves cursor through the ring, g; should pick
428        // up from the new cursor position when scanning for marks.
429        let mut a = app_with("a\nb\nc\nd\ne", 10);
430        a.editor.cursor = Position::new(1, 0);
431        a.apply(Action::SetMark('a')); // ring [NamedMark a@(1,0)] cursor=1
432        a.editor.cursor = Position::new(3, 0);
433        a.apply(invoke_motion(a.editor.builtins.goto_first_line));
434        // ring [NamedMark a, AutoJump (3,0)] cursor=2
435        // Ctrl-O jumps to AutoJump (3,0). Snapshot of (0,0) pushed.
436        // Actually: with snapshot pre-step, ring [a, (3,0), (0,0)],
437        // cursor walks from 3 backward to find jump -> index 1 ((3,0)).
438        a.apply(Action::JumpHistoryBack);
439        assert_eq!(a.editor.cursor, Position::new(3, 0));
440        // g; from current ring cursor (1) walks back to find NamedMark
441        // at index 0.
442        a.apply(Action::WalkMarkHistoryBack);
443        assert_eq!(a.editor.cursor, Position::new(1, 0));
444    }
445
446    #[test]
447    fn position_history_dedups_consecutive_same() {
448        let mut a = app_with("a\nb\nc", 10);
449        a.push_position_history(Position::new(2, 0), PositionSource::AutoJump);
450        a.push_position_history(Position::new(2, 0), PositionSource::AutoJump);
451        // Pushing the same position-and-source twice in a row -> single entry.
452        assert_eq!(a.editor.position_history.len(), 1);
453    }
454
455    #[test]
456    fn position_history_capped_at_max() {
457        let mut a = app_with("a\nb\nc", 10);
458        for i in 0..200 {
459            a.push_position_history(Position::new(i % 3, 0), PositionSource::AutoJump);
460        }
461        assert!(a.editor.position_history.len() <= 100);
462    }
463
464    #[test]
465    fn star_finds_next_occurrence_of_word_under_cursor() {
466        let mut a = app_with("foo bar foo bar", 10);
467        a.editor.cursor = Position::new(0, 1); // on 'o' of first "foo"
468        a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
469        assert_eq!(a.editor.cursor, Position::new(0, 8)); // start of second "foo"
470        let last = a.editor.last_search.as_ref().unwrap();
471        // c3b4bfca: `*` matches the WHOLE word, as vim's `\<foo\>` does.
472        assert_eq!(last.pattern, r"\bfoo\b");
473    }
474
475    #[test]
476    fn star_when_cursor_not_on_word_scans_forward() {
477        let mut a = app_with("  hello world", 10);
478        a.editor.cursor = Position::new(0, 0); // on space
479        a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
480        // The first word "hello" appears once in the buffer; pattern is
481        // recorded but no match is found beyond it (no second "hello").
482        let last = a.editor.last_search.as_ref().unwrap();
483        assert_eq!(last.pattern, r"\bhello\b");
484    }
485
486    #[test]
487    fn star_with_no_word_on_line_emits_error() {
488        let mut a = app_with("   ", 10);
489        a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
490        let msg = a.editor.last_message.as_ref().unwrap();
491        assert_eq!(msg.level, EchoLevel::Error);
492    }
493
494    #[test]
495    fn star_records_pattern_even_on_no_other_match() {
496        let mut a = app_with("only hello", 10);
497        a.editor.cursor = Position::new(0, 5); // on 'h'
498        a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
499        // Only one occurrence; wrap puts us at the same place.
500        let last = a.editor.last_search.as_ref().unwrap();
501        assert_eq!(last.pattern, r"\bhello\b");
502    }
503
504    #[test]
505    fn percent_jumps_from_open_to_close_paren() {
506        let mut a = app_with("call(arg1, arg2)", 10);
507        a.editor.cursor = Position::new(0, 4); // on '('
508        a.apply(Action::MatchBracket);
509        assert_eq!(a.editor.cursor, Position::new(0, 15));
510    }
511
512    #[test]
513    fn percent_jumps_from_close_to_open_paren() {
514        let mut a = app_with("call(arg1, arg2)", 10);
515        a.editor.cursor = Position::new(0, 15); // on ')'
516        a.apply(Action::MatchBracket);
517        assert_eq!(a.editor.cursor, Position::new(0, 4));
518    }
519
520    #[test]
521    fn percent_with_nested_picks_correct_match() {
522        let mut a = app_with("a(b(c)d)e", 10);
523        a.editor.cursor = Position::new(0, 1); // on outer '('
524        a.apply(Action::MatchBracket);
525        assert_eq!(a.editor.cursor, Position::new(0, 7)); // outer ')'
526    }
527
528    #[test]
529    fn percent_searches_forward_for_first_bracket_when_cursor_off() {
530        let mut a = app_with("call(arg)", 10);
531        a.editor.cursor = Position::ZERO; // 'c'; first bracket on line is '(' at byte 4
532        a.apply(Action::MatchBracket);
533        assert_eq!(a.editor.cursor, Position::new(0, 8)); // ')'
534    }
535
536    #[test]
537    fn percent_with_no_bracket_on_line_emits_error() {
538        let mut a = app_with("plain text only", 10);
539        a.apply(Action::MatchBracket);
540        let msg = a.editor.last_message.as_ref().unwrap();
541        assert_eq!(msg.level, EchoLevel::Error);
542    }
543
544    #[test]
545    fn percent_with_unmatched_bracket_emits_error() {
546        let mut a = app_with("foo(bar", 10);
547        a.editor.cursor = Position::new(0, 3);
548        a.apply(Action::MatchBracket);
549        let msg = a.editor.last_message.as_ref().unwrap();
550        assert_eq!(msg.level, EchoLevel::Error);
551    }
552
553    #[test]
554    fn percent_works_for_brackets_and_braces() {
555        let mut a = app_with("[a, b, c]", 10);
556        a.editor.cursor = Position::ZERO;
557        a.apply(Action::MatchBracket);
558        assert_eq!(a.editor.cursor, Position::new(0, 8));
559    }
560
561    #[test]
562    fn jump_viewport_top_lands_on_scroll_line() {
563        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
564        a.editor.scroll = 3;
565        a.editor.cursor = Position::new(7, 0);
566        a.apply(Action::JumpViewport(ViewportPos::Top));
567        assert_eq!(a.editor.cursor.line, 3);
568    }
569
570    #[test]
571    fn jump_viewport_middle_lands_at_vims_middle() {
572        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 6);
573        a.editor.scroll = 0;
574        a.apply(Action::JumpViewport(ViewportPos::Middle));
575        // VM.3f: vim's `M` is top + (lines shown - 1) / 2, NOT half the window
576        // height — a 6-row window puts it on line 2 (checked in vim 9.2, where
577        // a 22-row window lands top + 10). The action path answers from the
578        // same `ShownLines` rule as the `M` motion, so the two can't diverge.
579        assert_eq!(a.editor.cursor.line, 2);
580    }
581
582    #[test]
583    fn jump_viewport_bottom_lands_at_height_minus_one() {
584        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
585        a.editor.scroll = 2;
586        a.apply(Action::JumpViewport(ViewportPos::Bottom));
587        // 2 + 5 - 1 = 6.
588        assert_eq!(a.editor.cursor.line, 6);
589    }
590
591    #[test]
592    fn jump_viewport_clamps_to_last_addressable_line() {
593        let mut a = app_with("a\nb", 50);
594        a.apply(Action::JumpViewport(ViewportPos::Bottom));
595        assert_eq!(a.editor.cursor.line, 1);
596    }
597
598    #[test]
599    fn scroll_cursor_to_center_centers_cursor() {
600        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
601        a.editor.cursor = Position::new(6, 0);
602        a.apply(Action::ScrollCursorTo(ScrollPos::Center));
603        // cursor.line - height/2 = 6 - 2 = 4.
604        assert_eq!(a.editor.scroll, 4);
605        // Cursor itself unchanged.
606        assert_eq!(a.editor.cursor.line, 6);
607    }
608
609    #[test]
610    fn scroll_cursor_to_top_aligns_scroll_with_cursor() {
611        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
612        a.editor.cursor = Position::new(6, 0);
613        a.apply(Action::ScrollCursorTo(ScrollPos::Top));
614        assert_eq!(a.editor.scroll, 6);
615    }
616
617    #[test]
618    fn scroll_cursor_to_bottom_pulls_scroll_up_by_height_minus_one() {
619        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
620        a.editor.cursor = Position::new(8, 0);
621        a.apply(Action::ScrollCursorTo(ScrollPos::Bottom));
622        // 8 - (5 - 1) = 4.
623        assert_eq!(a.editor.scroll, 4);
624    }
625
626    #[test]
627    fn page_down_advances_by_viewport_height_minus_two() {
628        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
629        a.editor.cursor = Position::ZERO;
630        a.apply(Action::PageDown);
631        assert_eq!(a.editor.cursor.line, 3);
632    }
633
634    #[test]
635    fn page_down_clamps_to_last_addressable_line() {
636        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
637        a.editor.cursor = Position::new(8, 0);
638        a.apply(Action::PageDown);
639        assert_eq!(a.editor.cursor.line, 9);
640    }
641
642    #[test]
643    fn page_up_steps_back_by_viewport_height_minus_two() {
644        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 5);
645        a.editor.cursor = Position::new(7, 0);
646        a.apply(Action::PageUp);
647        assert_eq!(a.editor.cursor.line, 4);
648    }
649
650    #[test]
651    fn page_up_at_top_stays_at_top() {
652        let mut a = app_with("0\n1\n2", 5);
653        a.apply(Action::PageUp);
654        assert_eq!(a.editor.cursor.line, 0);
655    }
656
657    #[test]
658    fn scroll_line_down_advances_scroll_and_pulls_cursor_if_off_top() {
659        let mut a = app_with("0\n1\n2\n3\n4\n5\n6", 3);
660        a.editor.cursor = Position::ZERO;
661        a.editor.scroll = 0;
662        a.apply(Action::ScrollLineDown);
663        assert_eq!(a.editor.scroll, 1);
664        // Cursor was at line 0; now it's off the top, so it follows.
665        assert_eq!(a.editor.cursor.line, 1);
666    }
667
668    #[test]
669    fn scroll_line_up_decreases_scroll_and_pushes_cursor_if_off_bottom() {
670        let mut a = app_with("0\n1\n2\n3\n4\n5\n6", 3);
671        a.editor.cursor = Position::new(4, 0);
672        a.editor.scroll = 2; // viewport covers lines 2,3,4.
673        a.apply(Action::ScrollLineUp);
674        assert_eq!(a.editor.scroll, 1);
675        // Bottom of new viewport is line 3; cursor was at 4, gets pushed up.
676        assert_eq!(a.editor.cursor.line, 3);
677    }
678
679    #[test]
680    fn set_mark_records_cursor_position() {
681        let mut a = app_with("hello\nworld", 10);
682        a.editor.cursor = Position::new(1, 2);
683        a.apply(Action::SetMark('a'));
684        assert_eq!(a.editor.marks.get(&'a'), Some(&Position::new(1, 2)));
685    }
686
687    #[test]
688    fn jump_mark_exact_restores_cursor_position() {
689        let mut a = app_with("hello\nworld\nfoo", 10);
690        a.editor.cursor = Position::new(0, 3);
691        a.apply(Action::SetMark('m'));
692        a.editor.cursor = Position::new(2, 0);
693        a.apply(Action::JumpToMarkExact('m'));
694        assert_eq!(a.editor.cursor, Position::new(0, 3));
695    }
696
697    #[test]
698    fn jump_mark_line_lands_on_first_non_blank() {
699        let mut a = app_with("hello\n    indented\nfoo", 10);
700        a.editor.cursor = Position::new(1, 8); // mid-word on the indented line
701        a.apply(Action::SetMark('a'));
702        a.editor.cursor = Position::ZERO;
703        a.apply(Action::JumpToMarkLine('a'));
704        // Line 1, byte 4 = 'i' (after 4 leading spaces).
705        assert_eq!(a.editor.cursor, Position::new(1, 4));
706    }
707
708    #[test]
709    fn jump_to_unset_mark_emits_error() {
710        let mut a = app_with("hello", 10);
711        a.apply(Action::JumpToMarkExact('z'));
712        let msg = a.editor.last_message.as_ref().unwrap();
713        assert_eq!(msg.level, EchoLevel::Error);
714    }
715
716    #[test]
717    fn count_with_line_motion_advances_count_lines() {
718        let mut a = app_with("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 20);
719        a.apply(Action::Invoke(
720            CommandInvocation::of(a.editor.builtins.line_down.0)
721                .with_count(lattice_grammar::command::Count(5)),
722        ));
723        assert_eq!(a.editor.cursor.line, 5);
724    }
725
726    #[test]
727    fn count_with_dd_deletes_n_lines_as_single_undo() {
728        // `2dd`: count=2 expands Range::CurrentLine to span 2 lines.
729        // The whole deletion MUST land as a single undo unit -- a
730        // single `u` should restore the original buffer.
731        let mut a = app_with("one\ntwo\nthree\nfour", 10);
732        a.editor.cursor = Position::new(0, 0);
733        a.editor.op_count = 2;
734        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
735            .with_range(lattice_grammar::Range::CurrentLine)
736            .with_count(lattice_grammar::command::Count(2));
737        a.apply(Action::Invoke(inv));
738        // Lines 0 and 1 ("one" and "two") deleted; line 2 ("three") survives.
739        let text = a.editor.document.text();
740        assert!(!text.contains("one"));
741        assert!(!text.contains("two"));
742        assert!(text.contains("three"));
743        assert!(text.contains("four"));
744
745        // One undo should fully restore.
746        let _ = a.undo_blocking();
747        assert_eq!(a.editor.document.text(), "one\ntwo\nthree\nfour");
748    }
749
750    #[test]
751    fn count_with_indent_right_indents_n_lines_as_single_undo() {
752        // `2>>`: count=2 expands Range::CurrentLine to span 2 lines.
753        // The whole indent MUST land as a single undo unit -- the
754        // operator builds the per-line edits up front and commits
755        // via apply_edit_batch.
756        let mut a = app_with("one\ntwo\nthree\nfour", 10);
757        a.editor.cursor = Position::new(0, 0);
758        a.editor.op_count = 2;
759        let inv = CommandInvocation::of(a.editor.builtins.indent_right.0)
760            .with_range(lattice_grammar::Range::CurrentLine)
761            .with_count(lattice_grammar::command::Count(2));
762        a.apply(Action::Invoke(inv));
763        assert_eq!(a.editor.document.text(), "    one\n    two\nthree\nfour");
764        // Single undo restores the original buffer.
765        let _ = a.undo_blocking();
766        assert_eq!(a.editor.document.text(), "one\ntwo\nthree\nfour");
767    }
768
769    #[test]
770    fn count_with_indent_left_dedents_n_lines_as_single_undo() {
771        let mut a = app_with("    one\n    two\nthree\nfour", 10);
772        a.editor.cursor = Position::new(0, 0);
773        a.editor.op_count = 2;
774        let inv = CommandInvocation::of(a.editor.builtins.indent_left.0)
775            .with_range(lattice_grammar::Range::CurrentLine)
776            .with_count(lattice_grammar::command::Count(2));
777        a.apply(Action::Invoke(inv));
778        assert_eq!(a.editor.document.text(), "one\ntwo\nthree\nfour");
779        let _ = a.undo_blocking();
780        assert_eq!(a.editor.document.text(), "    one\n    two\nthree\nfour");
781    }
782
783    #[test]
784    fn count_zero_through_pending_count_is_ignored_by_motion() {
785        // pending_count remains 0 after no digit; motion uses default 1.
786        let mut a = app_with("hello world", 10);
787        let id = a.editor.builtins.word_forward;
788        a.apply(invoke_motion(id));
789        assert_eq!(a.editor.cursor, Position::new(0, 6));
790    }
791
792    #[test]
793    fn next_pane_cycles_active() {
794        let mut a = app_with("first\nsecond\nthird", 10);
795        a.editor.cursor = Position::new(2, 0);
796        a.apply(Action::SplitPaneVertical);
797        // After split: 2 panes, both seeded with cursor (2, 0).
798        // Move cursor in the active pane.
799        a.editor.cursor = Position::new(0, 0);
800        a.apply(Action::NextPane);
801        assert_eq!(a.editor.pane_tree.active_index(), 1);
802        // Pane 1 should still hold its stashed cursor (2, 0).
803        assert_eq!(a.editor.cursor, Position::new(2, 0));
804        // Cycle back -- pane 0 holds (0, 0) per the in-active mutation.
805        a.apply(Action::NextPane);
806        assert_eq!(a.editor.pane_tree.active_index(), 0);
807        assert_eq!(a.editor.cursor, Position::new(0, 0));
808    }
809
810    #[test]
811    fn navigate_pane_walks_to_spatial_neighbour() {
812        let mut a = app_with("xx", 10);
813        a.editor.terminal_width = Some(80);
814        a.apply(Action::SplitPaneVertical);
815        // Active=0 (left). Navigate Right -> active=1.
816        a.apply(Action::NavigatePane(PaneDirection::Right));
817        assert_eq!(a.editor.pane_tree.active_index(), 1);
818        // Navigate Left -> active=0.
819        a.apply(Action::NavigatePane(PaneDirection::Left));
820        assert_eq!(a.editor.pane_tree.active_index(), 0);
821    }
822
823    #[test]
824    fn tag_stack_pop_on_empty_echoes_message() {
825        let mut a = app_with("xx", 10);
826        a.apply(Action::TagStackPop);
827        let msg = a.editor.last_message.as_ref().expect("echo");
828        assert!(msg.text.contains("tag stack empty"));
829    }
830
831    #[test]
832    fn ctrl_o_from_help_buffer_swaps_pane_back_to_document() {
833        // Regression: do_walk_history's Document arm previously only
834        // updated `active_buffer` + `self.editor.cursor` and forgot to update
835        // `pane.buffer` / `pane.buffer_id`. The renderer + modeline read
836        // pane.buffer_id, so <C-o> from a help-in-pane buffer back to a
837        // document position left the renderer painting the help buffer.
838        let mut a = app_with("alpha\nbeta\ngamma\n", 10);
839        let doc_id = a.editor.document_buffer_id;
840        a.editor.cursor = Position::new(1, 2);
841        // Open a help buffer in-pane; activate_help_in_pane pushes an
842        // AutoJump entry recording the pre-activation Document cursor.
843        let help =
844            crate::help::HelpContent::from_lines("regression", vec!["help body".to_string()]);
845        a.open_help_in_pane(help);
846        assert_eq!(a.editor.active_buffer, BufferKind::Help);
847        let active_pane = a.editor.pane_tree.active();
848        assert_eq!(active_pane.buffer, BufferKind::Help);
849        assert_ne!(active_pane.buffer_id, doc_id);
850        // <C-o> walks back to the AutoJump entry pointing at the doc.
851        a.apply(Action::JumpHistoryBack);
852        assert_eq!(a.editor.active_buffer, BufferKind::Document);
853        let active_pane = a.editor.pane_tree.active();
854        assert_eq!(
855            active_pane.buffer,
856            BufferKind::Document,
857            "pane.buffer must follow active_buffer back to Document"
858        );
859        assert_eq!(
860            active_pane.buffer_id, doc_id,
861            "pane.buffer_id must point at the original document so the renderer paints it"
862        );
863        assert_eq!(a.editor.cursor, Position::new(1, 2));
864    }
865
866    #[test]
867    fn tag_stack_drives_pop_back_to_origin() {
868        let mut a = app_with("alpha\nbeta\ngamma\ndelta\n", 10);
869        // Pretend we drilled down from line 0 col 2 to line 3
870        // col 1 (the gd-like `do_lsp_nav_request` -> drain
871        // single-result path normally pushes; we synthesise
872        // the entry directly to keep the test free of LSP wire).
873        a.editor.tag_stack.push(TagStackEntry {
874            buffer: a.editor.active_buffer,
875            buffer_id: a.active_pane_buffer_id(),
876            position: Position::new(0, 2),
877            label: "foo".into(),
878        });
879        a.editor.cursor = Position::new(3, 1);
880        a.apply(Action::TagStackPop);
881        assert_eq!(a.editor.cursor, Position::new(0, 2));
882        assert!(a.editor.tag_stack.is_empty());
883        // Pop pushes the post-pop cursor onto position history
884        // (PluginPush) so a follow-up `<C-i>` returns to (3, 1).
885        let last = a.editor.position_history.last().expect("history entry");
886        assert!(matches!(last.source, PositionSource::PluginPush));
887        assert_eq!(last.position, Position::new(3, 1));
888    }
889}