1use lattice_core::Buffer;
29use lattice_protocol::position::Position;
30
31use super::{App, BufferId, BufferKind, PositionSource};
32
33impl App {
42 }
46
47impl App {
48 pub(super) fn clamp_cursor_to_buffer(&mut self) {
67 self.mutate_editor(|e| e.clamp_cursor_to_active_buffer());
69 }
70
71 pub(super) fn clamp_cursor_to_active_buffer(&mut self) {
74 self.mutate_editor(|e| e.clamp_cursor_to_active_buffer());
76 }
77
78 pub(super) fn ensure_cursor_visible(&mut self) {
81 self.mutate_editor(|e| e.ensure_cursor_visible());
83 }
84
85 pub fn set_viewport_height(&mut self, height: u32) {
86 self.apply(lattice_host::action::Action::SetViewportHeight(height));
90 }
91
92 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 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 let content_h = if pane_h >= 2 { pane_h - 1 } else { pane_h };
147 u32::from(content_h).max(1)
148 }
149
150 pub fn help_popup_inner_height(&self, buffer_height: u32) -> Option<u32> {
161 if !matches!(self.ad().buffer_kind, BufferKind::Help) {
164 return None;
165 }
166 if self.panes().tree.active().buffer == BufferKind::Help {
169 return None;
170 }
171 let help = self.popup_help()?;
172 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 pub(super) fn do_jump_mark(&mut self, name: char, exact: bool) {
212 self.mutate_editor(move |e| e.do_jump_mark(name, exact));
214 }
215
216 pub(super) fn push_position_history(&mut self, pos: Position, source: PositionSource) {
221 self.mutate_editor(move |e| e.push_position_history(pos, source));
223 }
224
225 pub fn active_buffer_id(&self) -> BufferId {
232 self.read_editor(move |e| e.active_buffer_id())
233 }
234
235 pub fn active_cursor(&self) -> Position {
237 self.read_editor(move |e| e.active_cursor())
238 }
239
240 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); 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); a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
370 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); a.apply(Action::MatchBracket);
381 assert_eq!(a.editor.cursor, Position::new(0, 8)); 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 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 a.editor.cursor = Position::new(1, 0);
415 a.apply(Action::SetMark('a'));
416 a.editor.cursor = Position::new(3, 0);
418 a.apply(invoke_motion(a.editor.builtins.goto_first_line));
419 a.apply(Action::JumpHistoryBack);
421 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 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')); a.editor.cursor = Position::new(3, 0);
433 a.apply(invoke_motion(a.editor.builtins.goto_first_line));
434 a.apply(Action::JumpHistoryBack);
439 assert_eq!(a.editor.cursor, Position::new(3, 0));
440 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 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); a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
469 assert_eq!(a.editor.cursor, Position::new(0, 8)); let last = a.editor.last_search.as_ref().unwrap();
471 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); a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
480 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); a.apply(Action::SearchWordUnderCursor(SearchDirection::Forward));
499 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); 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); 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); a.apply(Action::MatchBracket);
525 assert_eq!(a.editor.cursor, Position::new(0, 7)); }
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; a.apply(Action::MatchBracket);
533 assert_eq!(a.editor.cursor, Position::new(0, 8)); }
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 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 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 assert_eq!(a.editor.scroll, 4);
605 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 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 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; a.apply(Action::ScrollLineUp);
674 assert_eq!(a.editor.scroll, 1);
675 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); a.apply(Action::SetMark('a'));
702 a.editor.cursor = Position::ZERO;
703 a.apply(Action::JumpToMarkLine('a'));
704 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 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 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 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 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 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 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 a.editor.cursor = Position::new(0, 0);
800 a.apply(Action::NextPane);
801 assert_eq!(a.editor.pane_tree.active_index(), 1);
802 assert_eq!(a.editor.cursor, Position::new(2, 0));
804 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 a.apply(Action::NavigatePane(PaneDirection::Right));
817 assert_eq!(a.editor.pane_tree.active_index(), 1);
818 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 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 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 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 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 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}