返回 DeepSeek-TUI-2026
pager.rs
根目录 / crates / tui / src / tui / pager.rs
1 //! Full-screen pager overlay for long outputs.
2 //!
3 //! Vim-style key bindings (mirroring the codex pager_overlay):
4 //! - `j` / Down — scroll down one line
5 //! - `k` / Up — scroll up one line
6 //! - `g g` / Home — jump to top
7 //! - `G` / End — jump to bottom
8 //! - `Ctrl+D` — half-page down
9 //! - `Ctrl+U` — half-page up
10 //! - `Ctrl+F` / PageDown / Space — full page down
11 //! - `Ctrl+B` / PageUp / Shift+Space — full page up
12 //! - `/` — start search; `n` / `N` — next / previous match
13 //! - `q` / Esc — close pager
14
15 use std::cell::Cell;
16
17 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18 use ratatui::{
19 buffer::Buffer,
20 layout::Rect,
21 style::{Color, Modifier, Style},
22 text::{Line, Span},
23 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
24 };
25 use unicode_width::UnicodeWidthStr;
26
27 use crate::palette;
28 use crate::tui::views::{ModalKind, ModalView, ViewAction};
29
30 /// Footer hint shown along the bottom border of the pager. Kept short so it
31 /// fits on narrow terminals; full reference lives in the module docs.
32 const FOOTER_HINT: &str =
33 " j/k scroll Space/b page Ctrl+D/U half g/G top/bottom / search q quit ";
34
35 pub struct PagerView {
36 title: String,
37 lines: Vec<Line<'static>>,
38 plain_lines: Vec<String>,
39 scroll: usize,
40 search_input: String,
41 search_matches: Vec<usize>,
42 search_index: usize,
43 search_mode: bool,
44 pending_g: bool,
45 /// Cached visible content height from the last render. Used by paging
46 /// keys (Ctrl+D/U, Ctrl+F/B, Space, etc.) to compute scroll deltas
47 /// without access to the render area.
48 last_visible_height: Cell<usize>,
49 }
50
51 impl PagerView {
52 pub fn new(title: impl Into<String>, lines: Vec<Line<'static>>) -> Self {
53 let plain_lines = lines.iter().map(line_to_string).collect();
54 Self {
55 title: title.into(),
56 lines,
57 plain_lines,
58 scroll: 0,
59 search_input: String::new(),
60 search_matches: Vec::new(),
61 search_index: 0,
62 search_mode: false,
63 pending_g: false,
64 last_visible_height: Cell::new(0),
65 }
66 }
67
68 pub fn from_text(title: impl Into<String>, text: &str, width: u16) -> Self {
69 let mut lines = Vec::new();
70 for raw in text.lines() {
71 for wrapped in wrap_text(raw, width.max(1) as usize) {
72 lines.push(Line::from(Span::raw(wrapped)));
73 }
74 if raw.is_empty() {
75 lines.push(Line::from(""));
76 }
77 }
78 Self::new(title, lines)
79 }
80
81 fn scroll_up(&mut self, amount: usize) {
82 self.scroll = self.scroll.saturating_sub(amount);
83 }
84
85 fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
86 self.scroll = (self.scroll + amount).min(max_scroll);
87 }
88
89 fn scroll_to_top(&mut self) {
90 self.scroll = 0;
91 }
92
93 fn scroll_to_bottom(&mut self, max_scroll: usize) {
94 self.scroll = max_scroll;
95 }
96
97 /// Return the page height (in lines) used for paging keys.
98 ///
99 /// Falls back to a small constant (10) before the first render so the
100 /// pager still responds to paging keys when invoked synthetically (e.g.
101 /// in unit tests). After the first render, the cached value reflects
102 /// the actual visible content area.
103 fn page_height(&self) -> usize {
104 let cached = self.last_visible_height.get();
105 if cached == 0 { 10 } else { cached }
106 }
107
108 /// Half a page, rounded up so a single press always moves at least one line.
109 fn half_page_height(&self) -> usize {
110 let page = self.page_height();
111 page.div_ceil(2).max(1)
112 }
113
114 fn max_scroll(&self) -> usize {
115 // Match the existing 1-line scroll convention used by `j`/`k`. Render
116 // clamps `self.scroll` to `lines.len() - visible_height` for display
117 // purposes, so over-scrolling here is harmless.
118 self.lines.len().saturating_sub(1)
119 }
120
121 fn start_search(&mut self) {
122 self.search_mode = true;
123 self.search_input.clear();
124 self.search_matches.clear();
125 self.search_index = 0;
126 }
127
128 fn update_search_matches(&mut self) {
129 let query = self.search_input.trim();
130 if query.is_empty() {
131 self.search_matches.clear();
132 self.search_index = 0;
133 return;
134 }
135 let lower = query.to_ascii_lowercase();
136 self.search_matches = self
137 .plain_lines
138 .iter()
139 .enumerate()
140 .filter_map(|(idx, line)| {
141 if line.to_ascii_lowercase().contains(&lower) {
142 Some(idx)
143 } else {
144 None
145 }
146 })
147 .collect();
148 self.search_index = 0;
149 }
150
151 fn jump_to_match(&mut self) {
152 if let Some(&line) = self.search_matches.get(self.search_index) {
153 self.scroll = line;
154 }
155 }
156
157 fn next_match(&mut self) {
158 if self.search_matches.is_empty() {
159 return;
160 }
161 self.search_index = (self.search_index + 1) % self.search_matches.len();
162 self.jump_to_match();
163 }
164
165 fn prev_match(&mut self) {
166 if self.search_matches.is_empty() {
167 return;
168 }
169 if self.search_index == 0 {
170 self.search_index = self.search_matches.len().saturating_sub(1);
171 } else {
172 self.search_index = self.search_index.saturating_sub(1);
173 }
174 self.jump_to_match();
175 }
176 }
177
178 impl ModalView for PagerView {
179 fn kind(&self) -> ModalKind {
180 ModalKind::Pager
181 }
182
183 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
184 self
185 }
186
187 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
188 if self.search_mode {
189 match key.code {
190 KeyCode::Enter => {
191 self.search_mode = false;
192 self.update_search_matches();
193 self.jump_to_match();
194 return ViewAction::None;
195 }
196 KeyCode::Esc => {
197 // Bail out of search mode AND drop the current match list
198 // so the user gets back to the un-highlighted view —
199 // codex-style behavior. To resume from where they left
200 // off they re-enter `/` and re-type.
201 self.search_mode = false;
202 self.search_input.clear();
203 self.search_matches.clear();
204 self.search_index = 0;
205 return ViewAction::None;
206 }
207 KeyCode::Backspace => {
208 self.search_input.pop();
209 return ViewAction::None;
210 }
211 KeyCode::Char(c) => {
212 self.search_input.push(c);
213 return ViewAction::None;
214 }
215 _ => {}
216 }
217 }
218
219 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
220 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
221 let max_scroll = self.max_scroll();
222
223 // Ctrl+chord paging keys are matched first because their KeyCode
224 // also matches the bare `KeyCode::Char(c)` arms below.
225 if ctrl {
226 match key.code {
227 KeyCode::Char('d') | KeyCode::Char('D') => {
228 self.scroll_down(self.half_page_height(), max_scroll);
229 self.pending_g = false;
230 return ViewAction::None;
231 }
232 KeyCode::Char('u') | KeyCode::Char('U') => {
233 self.scroll_up(self.half_page_height());
234 self.pending_g = false;
235 return ViewAction::None;
236 }
237 KeyCode::Char('f') | KeyCode::Char('F') => {
238 self.scroll_down(self.page_height(), max_scroll);
239 self.pending_g = false;
240 return ViewAction::None;
241 }
242 KeyCode::Char('b') | KeyCode::Char('B') => {
243 self.scroll_up(self.page_height());
244 self.pending_g = false;
245 return ViewAction::None;
246 }
247 _ => {}
248 }
249 }
250
251 match key.code {
252 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
253 KeyCode::Up | KeyCode::Char('k') => {
254 self.scroll_up(1);
255 self.pending_g = false;
256 ViewAction::None
257 }
258 KeyCode::Down | KeyCode::Char('j') => {
259 self.scroll_down(1, max_scroll);
260 self.pending_g = false;
261 ViewAction::None
262 }
263 KeyCode::PageUp => {
264 self.scroll_up(self.page_height());
265 self.pending_g = false;
266 ViewAction::None
267 }
268 KeyCode::PageDown => {
269 self.scroll_down(self.page_height(), max_scroll);
270 self.pending_g = false;
271 ViewAction::None
272 }
273 // Vim convention: Space pages down, Shift+Space pages up. Match
274 // Shift+Space first so it is not absorbed by the bare ' ' arm.
275 KeyCode::Char(' ') if shift => {
276 self.scroll_up(self.page_height());
277 self.pending_g = false;
278 ViewAction::None
279 }
280 KeyCode::Char(' ') => {
281 self.scroll_down(self.page_height(), max_scroll);
282 self.pending_g = false;
283 ViewAction::None
284 }
285 KeyCode::Home => {
286 self.scroll_to_top();
287 self.pending_g = false;
288 ViewAction::None
289 }
290 KeyCode::End => {
291 self.scroll_to_bottom(max_scroll);
292 self.pending_g = false;
293 ViewAction::None
294 }
295 KeyCode::Char('g') => {
296 if self.pending_g {
297 self.scroll_to_top();
298 self.pending_g = false;
299 } else {
300 self.pending_g = true;
301 }
302 ViewAction::None
303 }
304 KeyCode::Char('G') => {
305 self.scroll_to_bottom(max_scroll);
306 self.pending_g = false;
307 ViewAction::None
308 }
309 KeyCode::Char('/') => {
310 self.start_search();
311 self.pending_g = false;
312 ViewAction::None
313 }
314 KeyCode::Char('n') => {
315 self.next_match();
316 self.pending_g = false;
317 ViewAction::None
318 }
319 KeyCode::Char('N') => {
320 self.prev_match();
321 self.pending_g = false;
322 ViewAction::None
323 }
324 _ => ViewAction::None,
325 }
326 }
327
328 fn render(&self, area: Rect, buf: &mut Buffer) {
329 let popup_width = area.width.saturating_sub(2).max(1);
330 let popup_height = area.height.saturating_sub(2).max(1);
331 let popup_area = Rect {
332 x: 1,
333 y: 1,
334 width: popup_width,
335 height: popup_height,
336 };
337
338 Clear.render(popup_area, buf);
339
340 // Borders eat 1 row top + 1 row bottom; the block's `Padding::uniform(1)`
341 // eats 1 more on each side. Net: 4 rows of overhead to subtract from
342 // `popup_area.height` before we know how many lines fit.
343 let mut visible_height = popup_area.height.saturating_sub(4) as usize;
344 if self.search_mode {
345 // Reserve a row for the search prompt that gets pushed below.
346 visible_height = visible_height.saturating_sub(1);
347 } else if !self.search_matches.is_empty() {
348 // Reserve a row for the "match X/Y (n/N)" status; without this
349 // the status line gets clipped on small popup heights and the
350 // user can't see how many matches there are.
351 visible_height = visible_height.saturating_sub(1);
352 }
353 // Cache for paging keys; the value is treated as advisory and
354 // clamped at use-time.
355 self.last_visible_height.set(visible_height);
356 let max_scroll = self.lines.len().saturating_sub(visible_height);
357 let scroll = self.scroll.min(max_scroll);
358 let end = (scroll + visible_height).min(self.lines.len());
359 let mut visible_lines = if self.lines.is_empty() {
360 vec![Line::from("")]
361 } else {
362 self.lines[scroll..end].to_vec()
363 };
364
365 // Highlight matched lines while the search prompt is closed and the
366 // user is navigating with `n` / `N`. Other matches get a subtle
367 // background; the current match gets a louder one. Per-substring
368 // highlighting is deferred to a follow-up — preserving the pre-styled
369 // spans (assistant / system colors) through a substring re-style is
370 // a separate concern.
371 if !self.search_mode && !self.search_matches.is_empty() {
372 let current_match_line = self.search_matches.get(self.search_index).copied();
373 for (visible_idx, line) in visible_lines.iter_mut().enumerate() {
374 let absolute_idx = scroll + visible_idx;
375 if absolute_idx >= self.lines.len() {
376 break;
377 }
378 if !self.search_matches.contains(&absolute_idx) {
379 continue;
380 }
381 let is_current = current_match_line == Some(absolute_idx);
382 let bg = if is_current {
383 Color::Yellow
384 } else {
385 Color::DarkGray
386 };
387 let fg = if is_current {
388 Color::Reset
389 } else {
390 Color::Yellow
391 };
392 let highlight = Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD);
393 for span in line.spans.iter_mut() {
394 span.style = highlight;
395 }
396 }
397 }
398
399 if self.search_mode {
400 let prompt = format!("/{}", self.search_input);
401 visible_lines.push(Line::from(Span::styled(
402 prompt,
403 Style::default()
404 .fg(palette::DEEPSEEK_SKY)
405 .add_modifier(Modifier::BOLD),
406 )));
407 } else if !self.search_matches.is_empty() {
408 let status = format!(
409 "match {}/{} (n/N)",
410 self.search_index + 1,
411 self.search_matches.len()
412 );
413 visible_lines.push(Line::from(Span::styled(
414 status,
415 Style::default().fg(palette::TEXT_MUTED),
416 )));
417 }
418
419 let footer = Line::from(Span::styled(
420 FOOTER_HINT,
421 Style::default().fg(palette::TEXT_HINT),
422 ));
423 let block = Block::default()
424 .title(self.title.clone())
425 .title_bottom(footer)
426 .borders(Borders::ALL)
427 .border_style(Style::default().fg(palette::BORDER_COLOR))
428 .padding(Padding::uniform(1));
429
430 let paragraph = Paragraph::new(visible_lines)
431 .block(block)
432 .wrap(Wrap { trim: false });
433 paragraph.render(popup_area, buf);
434 }
435 }
436
437 fn line_to_string(line: &Line<'static>) -> String {
438 line.spans
439 .iter()
440 .map(|span| span.content.to_string())
441 .collect::<String>()
442 }
443
444 fn wrap_text(text: &str, width: usize) -> Vec<String> {
445 if width == 0 {
446 return vec![text.to_string()];
447 }
448 let mut lines = Vec::new();
449 let mut current = String::new();
450 let mut current_width = 0usize;
451
452 for word in text.split_whitespace() {
453 let word_width = word.width();
454 let additional = if current.is_empty() {
455 word_width
456 } else {
457 word_width + 1
458 };
459 if current_width + additional > width && !current.is_empty() {
460 lines.push(current);
461 current = word.to_string();
462 current_width = word_width;
463 } else {
464 if !current.is_empty() {
465 current.push(' ');
466 current_width += 1;
467 }
468 current.push_str(word);
469 current_width += word_width;
470 }
471 }
472
473 if current.is_empty() {
474 lines.push(String::new());
475 } else {
476 lines.push(current);
477 }
478
479 lines
480 }
481
482 #[cfg(test)]
483 mod tests {
484 use super::*;
485 use ratatui::text::Line;
486
487 fn make_pager(lines: usize) -> PagerView {
488 let lines: Vec<Line<'static>> = (0..lines)
489 .map(|i| Line::from(format!("line-{i:03}")))
490 .collect();
491 PagerView::new("T", lines)
492 }
493
494 fn key(code: KeyCode) -> KeyEvent {
495 KeyEvent::new(code, KeyModifiers::NONE)
496 }
497
498 fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
499 KeyEvent::new(code, mods)
500 }
501
502 fn ctrl(code: KeyCode) -> KeyEvent {
503 KeyEvent::new(code, KeyModifiers::CONTROL)
504 }
505
506 /// Drive a render once so `last_visible_height` is populated and paging
507 /// keys use a deterministic page size.
508 fn prime_layout(view: &mut PagerView, height: u16) {
509 let area = Rect::new(0, 0, 40, height);
510 let mut buf = Buffer::empty(area);
511 view.render(area, &mut buf);
512 }
513
514 #[test]
515 fn j_scrolls_down_one_line() {
516 let mut p = make_pager(50);
517 let _ = p.handle_key(key(KeyCode::Char('j')));
518 assert_eq!(p.scroll, 1);
519 }
520
521 #[test]
522 fn k_scrolls_up_one_line() {
523 let mut p = make_pager(50);
524 p.scroll = 5;
525 let _ = p.handle_key(key(KeyCode::Char('k')));
526 assert_eq!(p.scroll, 4);
527 }
528
529 #[test]
530 fn gg_jumps_to_top() {
531 let mut p = make_pager(50);
532 p.scroll = 30;
533 let _ = p.handle_key(key(KeyCode::Char('g')));
534 assert!(p.pending_g, "first 'g' should arm pending_g");
535 assert_eq!(p.scroll, 30, "first 'g' alone must not scroll");
536 let _ = p.handle_key(key(KeyCode::Char('g')));
537 assert_eq!(p.scroll, 0);
538 assert!(!p.pending_g);
539 }
540
541 #[test]
542 fn home_jumps_to_top() {
543 let mut p = make_pager(50);
544 p.scroll = 30;
545 let _ = p.handle_key(key(KeyCode::Home));
546 assert_eq!(p.scroll, 0);
547 }
548
549 #[test]
550 fn shift_g_jumps_to_bottom() {
551 let mut p = make_pager(50);
552 let _ = p.handle_key(key(KeyCode::Char('G')));
553 assert_eq!(p.scroll, p.max_scroll());
554 }
555
556 #[test]
557 fn end_jumps_to_bottom() {
558 let mut p = make_pager(50);
559 let _ = p.handle_key(key(KeyCode::End));
560 assert_eq!(p.scroll, p.max_scroll());
561 }
562
563 #[test]
564 fn ctrl_d_half_page_down() {
565 let mut p = make_pager(200);
566 prime_layout(&mut p, 22);
567 let half = p.half_page_height();
568 assert!(half >= 1, "half-page must move at least one line");
569 let _ = p.handle_key(ctrl(KeyCode::Char('d')));
570 assert_eq!(p.scroll, half);
571 }
572
573 #[test]
574 fn ctrl_u_half_page_up() {
575 let mut p = make_pager(200);
576 prime_layout(&mut p, 22);
577 p.scroll = 50;
578 let half = p.half_page_height();
579 let _ = p.handle_key(ctrl(KeyCode::Char('u')));
580 assert_eq!(p.scroll, 50 - half);
581 }
582
583 #[test]
584 fn ctrl_f_full_page_down() {
585 let mut p = make_pager(200);
586 prime_layout(&mut p, 22);
587 let page = p.page_height();
588 let _ = p.handle_key(ctrl(KeyCode::Char('f')));
589 assert_eq!(p.scroll, page);
590 }
591
592 #[test]
593 fn ctrl_b_full_page_up() {
594 let mut p = make_pager(200);
595 prime_layout(&mut p, 22);
596 p.scroll = 80;
597 let page = p.page_height();
598 let _ = p.handle_key(ctrl(KeyCode::Char('b')));
599 assert_eq!(p.scroll, 80 - page);
600 }
601
602 #[test]
603 fn space_pages_down() {
604 let mut p = make_pager(200);
605 prime_layout(&mut p, 22);
606 let page = p.page_height();
607 let _ = p.handle_key(key(KeyCode::Char(' ')));
608 assert_eq!(p.scroll, page);
609 }
610
611 #[test]
612 fn shift_space_pages_up() {
613 let mut p = make_pager(200);
614 prime_layout(&mut p, 22);
615 p.scroll = 80;
616 let page = p.page_height();
617 let _ = p.handle_key(key_mod(KeyCode::Char(' '), KeyModifiers::SHIFT));
618 assert_eq!(p.scroll, 80 - page);
619 }
620
621 #[test]
622 fn page_down_uses_cached_visible_height() {
623 let mut p = make_pager(200);
624 prime_layout(&mut p, 22);
625 let page = p.page_height();
626 let _ = p.handle_key(key(KeyCode::PageDown));
627 assert_eq!(p.scroll, page);
628 }
629
630 #[test]
631 fn q_closes_pager() {
632 let mut p = make_pager(10);
633 let action = p.handle_key(key(KeyCode::Char('q')));
634 assert!(matches!(action, ViewAction::Close));
635 }
636
637 #[test]
638 fn esc_closes_pager() {
639 let mut p = make_pager(10);
640 let action = p.handle_key(key(KeyCode::Esc));
641 assert!(matches!(action, ViewAction::Close));
642 }
643
644 #[test]
645 fn g_does_not_consume_search_input() {
646 // While in search mode, 'g' must be treated as a search character,
647 // not as the half of a `gg` jump-to-top sequence.
648 let mut p = make_pager(50);
649 p.scroll = 10;
650 let _ = p.handle_key(key(KeyCode::Char('/')));
651 assert!(p.search_mode);
652 let _ = p.handle_key(key(KeyCode::Char('g')));
653 assert_eq!(p.search_input, "g");
654 assert_eq!(p.scroll, 10);
655 }
656
657 #[test]
658 fn footer_hint_includes_new_bindings() {
659 // The rendered pager must surface the new vim-style bindings to
660 // the user; check the footer string covers the headline keys.
661 for needle in &["j/k", "g/G", "Space", "Ctrl+D", "/ search", "q quit"] {
662 assert!(
663 FOOTER_HINT.contains(needle),
664 "footer hint missing {needle:?}: {FOOTER_HINT}"
665 );
666 }
667 }
668
669 #[test]
670 fn footer_hint_is_rendered_in_buffer() {
671 let p = make_pager(5);
672 let area = Rect::new(0, 0, 100, 10);
673 let mut buf = Buffer::empty(area);
674 p.render(area, &mut buf);
675 // The pager renders into an inset popup_area = (1, 1, w-2, h-2),
676 // so the bottom border lives at y = popup_area.bottom() - 1, not
677 // at the outer area's last row.
678 let popup_bottom_y = (area.height as usize).saturating_sub(2);
679 let mut bottom = String::new();
680 for x in 1..area.right().saturating_sub(1) {
681 bottom.push_str(buf[(x, popup_bottom_y as u16)].symbol());
682 }
683 assert!(
684 bottom.contains("j/k") || bottom.contains("scroll"),
685 "expected footer hint on bottom border row {popup_bottom_y}, got: {bottom:?}"
686 );
687 }
688
689 /// `/` opens the search prompt; typing chars accumulates them; Enter
690 /// commits and jumps to the first match. The matches index/count line
691 /// must surface in the rendered buffer afterwards.
692 #[test]
693 fn search_finds_matches_and_renders_match_counter() {
694 let mut p = make_pager(20);
695 prime_layout(&mut p, 16);
696
697 // Open search.
698 let _ = p.handle_key(key(KeyCode::Char('/')));
699 // Type "5" to match line-005, line-015 (any line whose number contains
700 // a 5 — make_pager produced "line-NNN" with three-digit indices).
701 for ch in "5".chars() {
702 let _ = p.handle_key(key(KeyCode::Char(ch)));
703 }
704 // Commit.
705 let _ = p.handle_key(key(KeyCode::Enter));
706
707 // Render and look for the "match X/Y" status line.
708 let area = Rect::new(0, 0, 60, 16);
709 let mut buf = Buffer::empty(area);
710 p.render(area, &mut buf);
711 let mut full = String::new();
712 for y in 0..area.height {
713 for x in 0..area.width {
714 full.push_str(buf[(x, y)].symbol());
715 }
716 full.push('\n');
717 }
718 assert!(
719 full.contains("match 1/2") || full.contains("match 1/3"),
720 "expected match counter; got buffer:\n{full}"
721 );
722 }
723
724 /// Esc while in search mode bails out AND clears the highlighted matches
725 /// so the un-highlighted view returns. (Codex parity.)
726 #[test]
727 fn esc_in_search_mode_clears_matches() {
728 let mut p = make_pager(20);
729 prime_layout(&mut p, 16);
730
731 let _ = p.handle_key(key(KeyCode::Char('/')));
732 let _ = p.handle_key(key(KeyCode::Char('5')));
733 let _ = p.handle_key(key(KeyCode::Enter));
734 assert!(!p.search_matches.is_empty());
735
736 // Re-enter search mode and Esc out — matches must clear.
737 let _ = p.handle_key(key(KeyCode::Char('/')));
738 let _ = p.handle_key(key(KeyCode::Esc));
739 assert!(p.search_matches.is_empty());
740 assert_eq!(p.search_input, "");
741 assert!(!p.search_mode);
742 }
743
744 /// `n` and `N` cycle forward and backward through matches, wrapping at
745 /// the ends without panicking on out-of-bounds index.
746 #[test]
747 fn n_and_capital_n_cycle_matches_with_wrap() {
748 let mut p = make_pager(50);
749 prime_layout(&mut p, 16);
750
751 // Search "1" — matches every line whose printed index contains a 1.
752 let _ = p.handle_key(key(KeyCode::Char('/')));
753 let _ = p.handle_key(key(KeyCode::Char('1')));
754 let _ = p.handle_key(key(KeyCode::Enter));
755 let total = p.search_matches.len();
756 assert!(total > 1, "test needs multiple matches, got {total}");
757
758 let start = p.search_index;
759 let _ = p.handle_key(key(KeyCode::Char('n')));
760 assert_eq!(p.search_index, (start + 1) % total);
761 let _ = p.handle_key(key(KeyCode::Char('N')));
762 assert_eq!(p.search_index, start);
763
764 // Wrap backwards from 0 → last.
765 let _ = p.handle_key(key(KeyCode::Char('N')));
766 assert_eq!(p.search_index, total - 1);
767 let _ = p.handle_key(key(KeyCode::Char('n')));
768 assert_eq!(p.search_index, 0);
769 }
770
771 /// While search matches exist and the prompt is closed, the matched
772 /// lines are visually distinguished in the rendered buffer by their
773 /// background color. We sample directly across the matched-line text
774 /// columns rather than the whole row width because Paragraph leaves
775 /// the trailing-area cells at the default style.
776 #[test]
777 fn matched_lines_get_highlight_background() {
778 let mut p = make_pager(20);
779 prime_layout(&mut p, 16);
780
781 let _ = p.handle_key(key(KeyCode::Char('/')));
782 let _ = p.handle_key(key(KeyCode::Char('5')));
783 let _ = p.handle_key(key(KeyCode::Enter));
784 assert!(!p.search_matches.is_empty());
785
786 let area = Rect::new(0, 0, 40, 16);
787 let mut buf = Buffer::empty(area);
788 p.render(area, &mut buf);
789
790 // Text starts at popup_area.x + block_border_left + padding_left
791 // = 1 + 1 + 1 = 3. The fixture text is "line-NNN" (8 chars) so we
792 // sample 3..11. The current-match row is the top of the visible
793 // window because `jump_to_match` set scroll = match_line.
794 let popup_top_y = 1 /* outer popup */ + 1 /* block top border */ + 1 /* padding top */;
795 let mut found_highlight = false;
796 for x in 3..11 {
797 let bg = buf[(x, popup_top_y)].style().bg;
798 if matches!(bg, Some(Color::Yellow) | Some(Color::DarkGray)) {
799 found_highlight = true;
800 break;
801 }
802 }
803 assert!(
804 found_highlight,
805 "expected a Yellow/DarkGray highlight cell on the matched-line text columns"
806 );
807 }
808 }
809
809 lines RUST