1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::line_index::LineIndex;
8use crate::render::{count_rows, render_line, Cell, RenderOpts};
9use crate::source::Source;
10
11const MAX_RECONSTRUCT_LINES: usize = 256;
15
16fn reconstruct_render_state(
23 src: &dyn Source,
24 idx: &crate::line_index::LineIndex,
25 target_line: usize,
26) -> crate::render::RenderState {
27 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
28 let mut state = crate::render::RenderState::default();
29 for line_no in start..target_line {
30 let range = idx.line_range(line_no, src);
31 let raw = src.bytes(range);
32 for &b in raw.as_ref() {
33 let _ = crate::ansi::step(
34 &mut state.parse,
35 &mut state.style,
36 &mut state.hyperlink,
37 b,
38 );
39 }
40 }
41 state
42}
43
44fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
50 let mut text = String::new();
51 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
52 for (col, cell) in row.iter().enumerate() {
53 match cell {
54 Cell::Char { ch, .. } => {
55 starts.push(col);
56 text.push(*ch);
57 }
58 Cell::Empty => {
59 starts.push(col);
60 text.push(' ');
61 }
62 Cell::Continuation => {}
63 }
64 }
65 starts.push(row.len());
66 (text, starts)
67}
68
69fn line_is_blank(bytes: &[u8]) -> bool {
74 bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
75}
76
77fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
81 if row.is_empty() {
82 return Vec::new();
83 }
84 let last_content_col = row
85 .iter()
86 .enumerate()
87 .rev()
88 .find_map(|(c, cell)| match cell {
89 Cell::Char { width, .. } => Some(c + *width as usize),
90 Cell::Continuation => Some(c + 1),
91 Cell::Empty => None,
92 })
93 .unwrap_or(0);
94 if last_content_col == 0 {
95 return Vec::new();
96 }
97 let (text, starts) = row_text_and_starts(row);
98 let mut out = Vec::new();
99 for m in regex.find_iter(&text) {
100 if m.start() == m.end() {
101 continue;
102 }
103 let char_start = text[..m.start()].chars().count();
104 let char_end = text[..m.end()].chars().count();
105 if char_start >= starts.len() - 1 || char_end <= char_start {
106 continue;
107 }
108 let col_start = starts[char_start];
109 let col_end = starts[char_end].min(last_content_col);
110 if col_end > col_start {
111 out.push(col_start..col_end);
112 }
113 }
114 out
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RowStyle {
119 Normal,
120 Dim,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum SearchDirection {
127 Forward,
128 Backward,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum CaseMode {
137 Sensitive,
138 Smart,
139 Insensitive,
140}
141
142impl Default for CaseMode {
143 fn default() -> Self { CaseMode::Sensitive }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum QuitAtEof {
152 Off,
153 Second,
154 First,
155}
156
157impl Default for QuitAtEof {
158 fn default() -> Self { QuitAtEof::Off }
159}
160
161impl CaseMode {
162 pub fn apply_to_pattern(self, pattern: &str) -> String {
165 match self {
166 CaseMode::Sensitive => pattern.to_string(),
167 CaseMode::Insensitive => format!("(?i){pattern}"),
168 CaseMode::Smart => {
169 if pattern.chars().any(|c| c.is_uppercase()) {
170 pattern.to_string()
171 } else {
172 format!("(?i){pattern}")
173 }
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
180pub struct SearchState {
181 pub raw: String,
182 pub regex: Regex,
183 pub direction: SearchDirection,
184}
185
186#[derive(Debug, Clone)]
187pub struct Frame {
188 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
195 pub status: String,
196 pub status_style: crate::ansi::Style,
198 pub raw_rows: Vec<Option<Vec<u8>>>,
206}
207
208pub struct Viewport {
209 top_line: usize,
210 top_row: usize,
211 cols: u16,
212 rows: u16,
213 pub opts: RenderOpts,
214 pub show_line_numbers: bool,
215 pub source_label: String,
216 follow_mode: bool,
217 live_mode: bool,
218 prettify_label: Option<String>,
219 format_label: Option<String>,
220 filter: Option<CompiledFilter>,
221 grep: Option<GrepPredicate>,
222 dim_mode: bool,
223 visible_lines: Vec<usize>,
226 visible_scanned: usize,
229 search: Option<SearchState>,
230 display: Option<crate::format::DisplayRenderer>,
234 hex_mode: bool,
235 hex_group_size: usize,
238 prompt: Option<crate::prompt::ParsedPrompt>,
241 preprocess_failure: Option<String>,
244 file_index: Option<(usize, usize)>,
246 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
250 status_style: crate::ansi::Style,
254 status_flash: Option<(String, u32)>,
259 ticks_since_growth: u32,
264 case_mode: CaseMode,
268 hilite_search: bool,
272 quit_at_eof: QuitAtEof,
274 eof_hits: u8,
277 squeeze_blanks: bool,
281 header_lines: usize,
286 header_cols: usize,
287 page_size: Option<u16>,
291 render_state: crate::render::RenderState,
295 render_state_for: usize,
298}
299
300impl Viewport {
301 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
302 let opts = RenderOpts { cols, ..RenderOpts::default() };
303 Self {
304 top_line: 0,
305 top_row: 0,
306 cols,
307 rows,
308 opts,
309 show_line_numbers: false,
310 source_label,
311 follow_mode: false,
312 live_mode: false,
313 prettify_label: None,
314 format_label: None,
315 filter: None,
316 grep: None,
317 dim_mode: false,
318 visible_lines: Vec::new(),
319 visible_scanned: 0,
320 search: None,
321 display: None,
322 hex_mode: false,
323 hex_group_size: 2,
324 prompt: None,
325 preprocess_failure: None,
326 file_index: None,
327 tag_active: None,
328 ansi_mode: crate::render::AnsiMode::Strict,
329 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
330 status_flash: None,
331 ticks_since_growth: 0,
332 case_mode: CaseMode::default(),
333 hilite_search: true,
334 quit_at_eof: QuitAtEof::default(),
335 eof_hits: 0,
336 squeeze_blanks: false,
337 header_lines: 0,
338 header_cols: 0,
339 page_size: None,
340 render_state: crate::render::RenderState::default(),
341 render_state_for: usize::MAX,
342 }
343 }
344
345 pub fn case_mode(&self) -> CaseMode { self.case_mode }
346
347 pub fn hilite_search(&self) -> bool { self.hilite_search }
348
349 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
350
351 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
352 self.quit_at_eof = mode;
353 self.eof_hits = 0;
354 }
355
356 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
357 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
358
359 pub fn set_header(&mut self, lines: usize, cols: usize) {
360 self.header_lines = lines;
361 self.header_cols = cols;
362 if self.top_line < self.header_lines {
365 self.top_line = self.header_lines;
366 }
367 }
368 pub fn header_lines(&self) -> usize { self.header_lines }
369 pub fn header_cols(&self) -> usize { self.header_cols }
370
371 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
372 pub fn page_size(&self) -> Option<u16> { self.page_size }
373
374 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
379 match self.quit_at_eof {
380 QuitAtEof::Off => false,
381 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
382 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
383 self.eof_hits = self.eof_hits.saturating_add(1);
384 self.eof_hits >= 2
385 }
386 _ => {
387 if !forward { self.eof_hits = 0; }
388 false
389 }
390 }
391 }
392
393 pub fn set_case_mode(&mut self, mode: CaseMode) {
397 self.case_mode = mode;
398 if let Some(s) = self.search.clone() {
399 let _ = self.set_search(s.raw, s.direction);
400 }
401 }
402
403 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
404 self.status_style = style;
405 }
406
407 pub fn status_style(&self) -> crate::ansi::Style {
408 self.status_style
409 }
410
411 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
415 self.status_flash = Some((msg.into(), ticks));
416 }
417
418 pub fn tick_flash(&mut self) {
421 if let Some((_, n)) = &mut self.status_flash {
422 *n = n.saturating_sub(1);
423 if *n == 0 {
424 self.status_flash = None;
425 }
426 }
427 }
428
429 pub fn note_growth(&mut self) {
431 self.ticks_since_growth = 0;
432 }
433
434 pub fn tick_idle(&mut self) {
437 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
438 }
439
440 pub fn is_idle(&self) -> bool {
443 self.ticks_since_growth >= 20
444 }
445
446 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
447 self.display = renderer;
448 }
449
450 pub fn set_hex_mode(&mut self, on: bool) {
451 self.hex_mode = on;
452 }
453
454 pub fn hex_mode(&self) -> bool {
456 self.hex_mode
457 }
458
459 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
462 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
463 self.hex_group_size = bytes_per_group;
464 }
465 }
466
467 pub fn hex_group_size(&self) -> usize {
469 self.hex_group_size
470 }
471
472 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
473 self.prompt = prompt;
474 }
475
476 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
477 self.preprocess_failure = msg;
478 }
479
480 pub fn set_file_index(&mut self, current: usize, total: usize) {
481 self.file_index = if total > 1 {
482 Some((current, total))
483 } else {
484 None
485 };
486 }
487
488 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
489 self.tag_active = info;
490 }
491
492 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
493 self.ansi_mode = mode;
494 }
495
496 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
497 self.ansi_mode
498 }
499
500 pub fn set_source_label(&mut self, label: String) {
501 self.source_label = label;
502 }
503
504 pub fn source_label_clone(&self) -> String {
505 self.source_label.clone()
506 }
507
508 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
513 let range = idx.line_range(line_n, src);
514 let raw = src.bytes(range);
515 if let Some(r) = self.display.as_ref() {
516 if let Some(rendered) = r.render_line(&raw) {
517 return std::borrow::Cow::Owned(rendered.into_bytes());
518 }
519 }
520 raw
521 }
522
523 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
527 let compiled = self.case_mode.apply_to_pattern(&raw);
528 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
529 self.search = Some(SearchState { raw, regex, direction });
530 Ok(())
531 }
532
533 pub fn clear_search(&mut self) { self.search = None; }
534
535 pub fn search_active(&self) -> bool { self.search.is_some() }
536
537 pub fn search_direction(&self) -> SearchDirection {
538 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
539 }
540
541 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
545 if idx.records_mode() {
546 self.search_repeat_records(src, idx, reverse)
547 } else {
548 self.search_repeat_lines(src, idx, reverse)
549 }
550 }
551
552 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
554 let Some(s) = self.search.as_ref() else { return false; };
555 let forward = matches!(
556 (s.direction, reverse),
557 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
558 );
559 idx.extend_to_end(src);
560 let pattern = s.regex.clone();
561 if self.hide_mode() {
562 self.extend_visible_lines(idx, src);
563 self.search_step_in_visible(&pattern, src, idx, forward)
564 } else {
565 self.search_step_in_logical(&pattern, src, idx, forward)
566 }
567 }
568
569 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
573 let Some(s) = self.search.as_ref() else { return false; };
574 let forward = matches!(
575 (s.direction, reverse),
576 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
577 );
578 let pattern = s.regex.clone();
579 idx.extend_to_end(src);
580
581 let total = idx.record_count();
582 if total == 0 { return false; }
583
584 let cur_record = idx.line_to_record(self.top_line);
585
586 let range: Box<dyn Iterator<Item = usize>> = if forward {
587 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
588 } else {
589 let earlier: Vec<usize> = (0..cur_record).rev().collect();
590 let later: Vec<usize> = (cur_record..total).rev().collect();
591 Box::new(earlier.into_iter().chain(later))
592 };
593
594 for r in range {
595 let bytes = idx.record_bytes_stripped(r, src);
596 let text = String::from_utf8_lossy(&bytes);
597 if pattern.is_match(&text) {
598 let line_range = idx.record_line_range(r);
599 self.top_line = line_range.start;
600 self.top_row = 0;
601 return true;
602 }
603 }
604 false
605 }
606
607 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
608 let display = self.line_display_bytes(src, idx, line_n);
613 let bytes = crate::ansi::strip_sgr(&display);
614 match std::str::from_utf8(&bytes) {
615 Ok(s) => pattern.is_match(s),
616 Err(_) => false,
617 }
618 }
619
620 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
621 let total = idx.line_count();
622 if total == 0 { return false; }
623 let start = self.top_line;
624 for offset in 1..=total {
627 let line_n = if forward {
628 (start + offset) % total
629 } else {
630 (start + total - offset) % total
631 };
632 if self.line_matches(pattern, src, idx, line_n) {
633 self.top_line = line_n;
634 self.top_row = 0;
635 return true;
636 }
637 }
638 false
639 }
640
641 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
642 let total = self.visible_lines.len();
643 if total == 0 { return false; }
644 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
646 for offset in 1..=total {
647 let visible_idx = if forward {
648 (cur + offset) % total
649 } else {
650 (cur + total - offset) % total
651 };
652 let line_n = self.visible_lines[visible_idx];
653 if self.line_matches(pattern, src, idx, line_n) {
654 self.top_line = line_n;
655 self.top_row = 0;
656 return true;
657 }
658 }
659 false
660 }
661
662 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
663 self.filter = filter;
664 self.visible_lines.clear();
665 self.visible_scanned = 0;
666 self.top_line = 0;
668 self.top_row = 0;
669 }
670
671 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
672 self.grep = grep;
673 self.visible_lines.clear();
674 self.visible_scanned = 0;
675 self.top_line = 0;
676 self.top_row = 0;
677 }
678
679 pub fn grep_active(&self) -> bool { self.grep.is_some() }
680
681 pub fn set_dim_mode(&mut self, on: bool) {
682 self.dim_mode = on;
683 self.visible_lines.clear();
687 self.visible_scanned = 0;
688 }
689
690 pub fn filter_active(&self) -> bool { self.filter.is_some() }
691
692 pub fn dim_mode(&self) -> bool { self.dim_mode }
693
694 fn hide_mode(&self) -> bool {
695 (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
696 }
697
698 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
703 if !self.hide_mode() {
704 return;
705 }
706 if idx.records_mode() {
707 self.extend_visible_lines_records(idx, src);
708 } else {
709 self.extend_visible_lines_per_line(idx, src);
710 }
711 }
712
713 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
715 let total = idx.line_count();
716 while self.visible_scanned < total {
717 let line_n = self.visible_scanned;
718 let bytes = idx.line_bytes_stripped(line_n, src);
719 if self.line_passes(&bytes) {
720 self.visible_lines.push(line_n);
721 }
722 self.visible_scanned += 1;
723 }
724 }
725
726 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
733 self.visible_lines.clear();
734 self.visible_scanned = 0; let total_records = idx.record_count();
736 for r in 0..total_records {
737 if self.record_passes(idx, src, r) {
738 for line_n in idx.record_line_range(r) {
739 self.visible_lines.push(line_n);
740 }
741 }
742 }
743 }
744
745 fn line_passes(&self, line: &[u8]) -> bool {
751 let filter_ok = match self.filter.as_ref() {
752 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
753 None => true,
754 };
755 let grep_ok = match self.grep.as_ref() {
756 Some(g) => g.matches(line),
757 None => true,
758 };
759 filter_ok && grep_ok
760 }
761
762 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
770 let bytes = if self.filter.is_some() || self.grep.is_some() {
771 Some(idx.record_bytes_stripped(r, src))
772 } else {
773 None
774 };
775 let filter_ok = match self.filter.as_ref() {
776 Some(f) => matches!(
777 f.evaluate_record(bytes.as_deref().unwrap()),
778 FilterMatch::Matched,
779 ),
780 None => true,
781 };
782 let grep_ok = match self.grep.as_ref() {
783 Some(g) => g.matches(bytes.as_deref().unwrap()),
784 None => true,
785 };
786 filter_ok && grep_ok
787 }
788
789 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
793 if !self.dim_mode {
794 return false;
795 }
796 if idx.records_mode() {
797 let r = idx.line_to_record(line_n);
798 !self.record_passes(idx, src, r)
799 } else {
800 let bytes = idx.line_bytes_stripped(line_n, src);
801 !self.line_passes(&bytes)
802 }
803 }
804
805 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
813 let body_rows = self.body_rows() as usize;
814 if self.hide_mode() && !self.visible_lines.is_empty() {
815 let cur = self
816 .visible_lines
817 .iter()
818 .position(|&l| l >= self.top_line)
819 .unwrap_or(self.visible_lines.len().saturating_sub(1));
820 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
821 return self.visible_lines[last_pos];
822 }
823 let total = idx.line_count();
824 if total == 0 {
825 return self.top_line;
826 }
827 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
828 }
829
830 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
831
832 pub fn follow_mode(&self) -> bool { self.follow_mode }
833
834 pub fn suspend_follow_if(&mut self, flag: bool) {
839 if flag {
840 self.follow_mode = false;
841 }
842 }
843
844 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
845
846 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
847
848 pub fn live_mode(&self) -> bool { self.live_mode }
849
850 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
851
852 pub fn set_prettify_label(&mut self, label: Option<String>) {
855 self.prettify_label = label;
856 }
857
858 pub fn set_format_label(&mut self, label: Option<String>) {
861 self.format_label = label;
862 }
863
864 pub fn invalidate_filter_cache(&mut self) {
869 self.visible_lines.clear();
870 self.visible_scanned = 0;
871 }
872
873 pub fn clamp_top_line(&mut self, line_count: usize) {
876 if line_count == 0 {
877 self.top_line = 0;
878 self.top_row = 0;
879 } else if self.top_line >= line_count {
880 self.top_line = line_count - 1;
881 self.top_row = 0;
882 }
883 }
884
885 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
889 if self.hide_mode() {
890 self.top_line >= self.hide_bottom_top_line(src, idx)
893 } else {
894 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
898 }
899 }
900
901 fn gutter_width(&self, idx: &LineIndex) -> u16 {
903 if !self.show_line_numbers { return 0; }
904 let n = idx.line_count().max(1);
905 let digits = (n as f64).log10().floor() as u16 + 1;
906 digits + 1
907 }
908
909 fn render_opts(&self, gutter: u16) -> RenderOpts {
910 let mut o = self.opts.clone();
911 o.cols = self.cols.saturating_sub(gutter);
912 o.mode = self.ansi_mode;
913 o
914 }
915
916 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
917 if self.hex_mode {
918 return self.frame_hex(src);
919 }
920 let body_rows = self.body_rows() as usize;
921 idx.extend_to_line(self.top_line + body_rows + 1, src);
922
923 let gutter = self.gutter_width(idx);
924 let r_opts = self.render_opts(gutter);
925
926 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
930 reconstruct_render_state(src, idx, self.top_line)
931 } else {
932 crate::render::RenderState::default()
933 };
934 self.render_state = render_state.clone();
936 self.render_state_for = self.top_line;
937
938 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
939 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
940 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
941 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
942 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
943 let hide = self.hide_mode();
945 let total_lines = idx.line_count();
946
947 let header_rows = if !hide && !raw_passthrough {
954 self.header_lines.min(body_rows).min(total_lines)
955 } else {
956 0
957 };
958 if header_rows > 0 {
959 for hl in 0..header_rows {
960 let raw = src.bytes(idx.line_range(hl, src));
961 let display_bytes = if let Some(r) = self.display.as_ref() {
962 match r.render_line(&raw) {
963 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
964 None => raw.clone(),
965 }
966 } else {
967 raw.clone()
968 };
969 let rows = render_line(&display_bytes, &r_opts, None);
970 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
971 let mut v = Vec::with_capacity(self.cols as usize);
972 while v.len() < self.cols as usize { v.push(Cell::Empty); }
973 v
974 });
975 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
976 if gutter > 0 {
977 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
978 for c in label.chars() {
979 full.push(Cell::Char {
980 ch: c,
981 width: 1,
982 style: crate::ansi::Style::default(),
983 hyperlink: None,
984 });
985 }
986 }
987 full.append(&mut content_row);
988 body.push(full);
989 row_styles.push(RowStyle::Normal);
990 highlights.push(Vec::new());
991 raw_rows.push(None);
992 }
993 }
994
995 let mut hide_pos = if hide {
997 self.visible_lines
998 .iter()
999 .position(|&l| l >= self.top_line)
1000 .unwrap_or(self.visible_lines.len())
1001 } else {
1002 0
1003 };
1004 let mut line_n = if hide {
1005 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1006 } else {
1007 self.top_line.max(self.header_lines)
1010 };
1011 let mut skip = if hide || header_rows > 0 { 0 } else { self.top_row };
1012
1013 while body.len() < body_rows {
1014 if line_n >= total_lines {
1015 let mut row = Vec::with_capacity(self.cols as usize);
1016 if gutter > 0 {
1017 for _ in 0..gutter { row.push(Cell::Empty); }
1018 }
1019 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1020 body.push(row);
1021 row_styles.push(RowStyle::Normal);
1022 highlights.push(Vec::new());
1023 raw_rows.push(None);
1024 line_n += 1;
1025 continue;
1026 }
1027 let raw = src.bytes(idx.line_range(line_n, src));
1030 if self.squeeze_blanks && line_is_blank(&raw) {
1035 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1036 let prev = src.bytes(idx.line_range(p, src));
1037 line_is_blank(&prev)
1038 });
1039 if prev_blank {
1040 line_n += 1;
1041 continue;
1042 }
1043 }
1044 let display_bytes = if let Some(r) = self.display.as_ref() {
1045 match r.render_line(&raw) {
1046 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1047 None => raw.clone(),
1048 }
1049 } else {
1050 raw.clone()
1051 };
1052 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1053 Some(&mut render_state)
1054 } else {
1055 None
1056 };
1057 let rows = render_line(&display_bytes, &r_opts, state_arg);
1058 let style = if self.filter.is_some() || self.grep.is_some() {
1059 if self.dim_mode {
1060 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1061 } else {
1062 RowStyle::Normal
1064 }
1065 } else {
1066 RowStyle::Normal
1067 };
1068
1069 let mut first_emitted_for_this_line = true;
1070 for (i, mut content_row) in rows.into_iter().enumerate() {
1071 if i < skip { continue; }
1072 if body.len() >= body_rows { break; }
1073 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1074 if gutter > 0 {
1075 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1076 for c in label.chars() {
1077 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1078 }
1079 }
1080 full.append(&mut content_row);
1081 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1085 find_row_highlights(&full, &s.regex)
1086 } else {
1087 Vec::new()
1088 };
1089 body.push(full);
1090 row_styles.push(style);
1091 highlights.push(row_highlights);
1092 if raw_passthrough {
1093 if first_emitted_for_this_line {
1094 raw_rows.push(Some(raw.to_vec()));
1099 first_emitted_for_this_line = false;
1100 } else {
1101 raw_rows.push(Some(Vec::new()));
1102 }
1103 } else {
1104 raw_rows.push(None);
1105 }
1106 }
1107 skip = 0;
1108 if hide {
1110 hide_pos += 1;
1111 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1112 } else {
1113 line_n += 1;
1114 }
1115 }
1116
1117 self.render_state_for = usize::MAX;
1120
1121 let status = self.format_status(idx, src);
1122 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1123 }
1124
1125 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1126 if let Some(p) = self.prompt.as_ref() {
1127 let ctx = self.build_prompt_context(idx, src);
1128 return p.render(&ctx);
1129 }
1130 let body_rows = self.body_rows() as usize;
1131 let total = idx.line_count();
1132 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1135 let visible_total = self.visible_lines.len();
1136 let cur = self
1138 .visible_lines
1139 .iter()
1140 .position(|&l| l >= self.top_line)
1141 .unwrap_or(visible_total);
1142 let top = cur + 1;
1143 let bottom = (cur + body_rows).min(visible_total.max(1));
1144 let total_str = if src.is_complete() {
1145 format!("{visible_total}/{total}")
1146 } else {
1147 format!("{visible_total}/{total}+")
1148 };
1149 (top, bottom, visible_total, total_str)
1150 } else {
1151 let top = self.top_line + 1;
1152 let bottom = (self.top_line + body_rows).min(total.max(1));
1153 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1154 (top, bottom, total, total_str)
1155 };
1156 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1157 let bottom_line = self.bottom_visible_line(idx);
1161 let (line_prefix, records_block) = if idx.records_mode() {
1162 let line_total = idx.line_count();
1163 let rec_total = idx.record_count();
1164 let rec_block = if line_total == 0 || rec_total == 0 {
1165 format!("R0-0/{}", rec_total)
1166 } else {
1167 let rec_top = idx.line_to_record(self.top_line) + 1;
1168 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1169 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1170 (rec_top, rec_top)
1174 } else {
1175 (rec_top, rec_bottom)
1176 };
1177 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1178 };
1179 ("L", Some(rec_block))
1180 } else {
1181 ("", None)
1182 };
1183 let middle = match records_block {
1184 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1185 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1186 };
1187 let label_with_index = match self.file_index {
1188 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1189 None => self.source_label.clone(),
1190 };
1191 let mut s = format!("{} {}", label_with_index, middle);
1192 if !self.hide_mode() && self.top_row > 0 {
1197 let line_rows = if total > 0 {
1198 let bytes = self.line_display_bytes(src, idx, self.top_line);
1199 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1200 } else { 1 };
1201 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1202 }
1203 if let Some(f) = self.filter.as_ref() {
1204 s.push_str(&format!(" [{}]", f.format_name));
1205 }
1206 if self.grep.is_some() {
1207 s.push_str(" [grep]");
1208 }
1209 if self.filter.is_some() || self.grep.is_some() {
1210 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1211 }
1212 if let Some(sr) = self.search.as_ref() {
1213 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1214 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1215 }
1216 if let Some(label) = self.prettify_label.as_ref() {
1217 s.push_str(&format!(" [pretty:{label}]"));
1218 }
1219 if self.live_mode { s.push_str(" (L)"); }
1220 if self.follow_mode {
1221 if let Some((msg, _)) = self.status_flash.as_ref() {
1222 s.push_str(" ");
1223 s.push_str(msg);
1224 } else if self.is_idle() {
1225 s.push_str(" (F idle)");
1226 } else {
1227 s.push_str(" (F)");
1228 }
1229 }
1230 if let Some(msg) = self.preprocess_failure.as_ref() {
1231 let first_line = msg.lines().next().unwrap_or("");
1232 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1233 }
1234 let tag_suffix = match &self.tag_active {
1235 Some((name, cur, total)) if *total > 1 => {
1236 format!(" [tag: {name} ({cur}/{total})]")
1237 }
1238 _ => String::new(),
1239 };
1240 s.push_str(&tag_suffix);
1241 let used = s.chars().count();
1244 let hint = ":help";
1245 if (self.cols as usize) > used + 1 + hint.chars().count() {
1246 let pad = self.cols as usize - used - hint.chars().count();
1247 s.push_str(&" ".repeat(pad));
1248 s.push_str(hint);
1249 } else {
1250 s.push(' ');
1251 s.push_str(hint);
1252 }
1253 s
1254 }
1255
1256 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1257 use crate::prompt::PromptContext;
1258
1259 let body_rows = self.body_rows() as usize;
1260 let total = idx.line_count();
1261 let top = self.top_line + 1;
1262 let bottom = (self.top_line + body_rows).min(total.max(1));
1263 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1264 let bottom_line = self.bottom_visible_line(idx);
1265
1266 let records_mode = idx.records_mode();
1267 let (rec_top, rec_bottom, rec_total) = if records_mode {
1268 let rt = idx.line_to_record(self.top_line) + 1;
1269 let rb_raw = idx.line_to_record(bottom_line) + 1;
1270 let rb = if rb_raw < rt { rt } else { rb_raw };
1271 (rt, rb, idx.record_count())
1272 } else {
1273 (0, 0, 0)
1274 };
1275
1276 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1277 let line_rows = if total > 0 {
1278 let bytes = self.line_display_bytes(src, idx, self.top_line);
1279 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1280 } else { 1 };
1281 format!("+{}/{}", self.top_row, line_rows)
1282 } else {
1283 String::new()
1284 };
1285
1286 let format_tag = self.format_label.as_ref()
1287 .map(|n| format!(" [{}]", n))
1288 .unwrap_or_default();
1289 let filter_tag = self.filter.as_ref()
1290 .map(|f| format!(" [{}]", f.format_name))
1291 .unwrap_or_default();
1292 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1293 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
1294 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1295 } else {
1296 String::new()
1297 };
1298 let search_tag = self.search.as_ref()
1299 .map(|s| {
1300 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1301 format!(" [{}{}]", p, s.raw)
1302 })
1303 .unwrap_or_default();
1304 let pretty_tag = self.prettify_label.as_ref()
1305 .map(|l| format!(" [pretty:{l}]"))
1306 .unwrap_or_default();
1307 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1308 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1309 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1310 .map(|msg| {
1311 let first_line = msg.lines().next().unwrap_or("");
1312 format!(" [preprocess-failed: {}]", first_line)
1313 })
1314 .unwrap_or_default();
1315
1316 let file_index_tag = match self.file_index {
1317 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1318 None => String::new(),
1319 };
1320
1321 let tag_tag = match &self.tag_active {
1322 Some((name, cur, total)) if *total > 1 => {
1323 format!(" [tag: {name} ({cur}/{total})]")
1324 }
1325 _ => String::new(),
1326 };
1327
1328 PromptContext {
1329 label: self.source_label.clone(),
1330 top,
1331 bottom,
1332 total,
1333 pct: pct.min(100) as u8,
1334 rec_top,
1335 rec_bottom,
1336 rec_total,
1337 records_mode,
1338 wrap_offset,
1339 format_tag,
1340 filter_tag,
1341 grep_tag,
1342 hide_tag,
1343 search_tag,
1344 pretty_tag,
1345 live_tag,
1346 follow_tag,
1347 preprocess_failed_tag,
1348 file_index_tag,
1349 tag_tag,
1350 }
1351 }
1352
1353 fn frame_hex(&self, src: &dyn Source) -> Frame {
1354 use crate::hex::format_hex_row;
1355 use crate::render::{render_line, Cell, RenderOpts};
1356
1357 let body_rows = self.rows.saturating_sub(1) as usize;
1358 let total_bytes = src.len();
1359 let total_hex_rows = total_bytes.div_ceil(16);
1360
1361 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1362 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1363 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1364
1365 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
1366
1367 for row_idx in 0..body_rows {
1368 let hex_row = self.top_line + row_idx;
1369 if hex_row >= total_hex_rows {
1370 body.push(vec![Cell::Empty; self.cols as usize]);
1371 } else {
1372 let offset = hex_row * 16;
1373 let end = (offset + 16).min(total_bytes);
1374 let bytes_cow = src.bytes(offset..end);
1375 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1376 let rows = render_line(text.as_bytes(), &opts, None);
1377 body.push(rows.into_iter().next().unwrap_or_else(|| {
1378 vec![Cell::Empty; self.cols as usize]
1379 }));
1380 }
1381 row_styles.push(RowStyle::Normal);
1382 highlights.push(Vec::new());
1383 }
1384
1385 let status = self.format_status_hex(src);
1386 let raw_rows = vec![None; body.len()];
1387 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1388 }
1389
1390 fn format_status_hex(&self, src: &dyn Source) -> String {
1391 let total_bytes = src.len();
1392 let body_rows = self.rows.saturating_sub(1) as usize;
1393 let top_byte = self.top_line * 16;
1395 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1398 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1399 let label_with_index = match self.file_index {
1400 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1401 None => self.source_label.clone(),
1402 };
1403 let tag_suffix = match &self.tag_active {
1404 Some((name, cur, total)) if *total > 1 => {
1405 format!(" [tag: {name} ({cur}/{total})]")
1406 }
1407 _ => String::new(),
1408 };
1409 format!(
1410 "{} off {}-{}/{} {}% [hex]{}",
1411 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1412 )
1413 }
1414
1415 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1420 if delta == 0 { return; }
1421 if self.hide_mode() {
1422 self.scroll_lines(delta, src, idx);
1423 return;
1424 }
1425 if delta > 0 {
1426 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1427 let total = idx.line_count();
1428 if total == 0 { return; }
1429 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1430 self.top_line = target;
1431 self.top_row = 0;
1432 } else {
1433 let back = (-delta) as usize;
1434 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1439 let extra_back = back.saturating_sub(consumed_for_snap);
1440 self.top_line = self.top_line.saturating_sub(extra_back);
1441 self.top_row = 0;
1442 }
1443 }
1444
1445 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1446 if delta == 0 { return; }
1447 if self.hide_mode() {
1448 self.extend_visible_lines(idx, src);
1452 let total = self.visible_lines.len();
1453 if total == 0 {
1454 self.top_line = 0;
1455 self.top_row = 0;
1456 return;
1457 }
1458 let cur = self
1459 .visible_lines
1460 .iter()
1461 .position(|&l| l >= self.top_line)
1462 .unwrap_or(total);
1463 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1464 self.top_line = self.visible_lines[new];
1465 self.top_row = 0;
1466 return;
1467 }
1468 if delta > 0 {
1469 let mut remaining = delta as usize;
1470 while remaining > 0 {
1471 idx.extend_to_line(self.top_line + 1, src);
1472 let total = idx.line_count();
1473 if total == 0 { break; }
1474 let bytes = self.line_display_bytes(src, idx, self.top_line);
1475 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1476 if self.top_row + 1 < line_rows {
1477 self.top_row += 1;
1478 } else if self.top_line + 1 < total {
1479 self.top_row = 0;
1480 self.top_line += 1;
1481 } else {
1482 break;
1483 }
1484 remaining -= 1;
1485 }
1486 if idx.scanned_through() >= src.len() {
1491 let anchor = self.bottom_anchor(src, idx);
1492 if (self.top_line, self.top_row) > anchor {
1493 self.top_line = anchor.0;
1494 self.top_row = anchor.1;
1495 }
1496 }
1497 } else {
1498 let mut remaining = (-delta) as usize;
1499 while remaining > 0 {
1500 if self.top_row > 0 {
1501 self.top_row -= 1;
1502 } else if self.top_line > 0 {
1503 self.top_line -= 1;
1504 let bytes = self.line_display_bytes(src, idx, self.top_line);
1505 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1506 self.top_row = line_rows.saturating_sub(1);
1507 } else {
1508 break;
1509 }
1510 remaining -= 1;
1511 }
1512 }
1513 }
1514
1515 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1516 let n = self.page_size
1517 .map(|p| p as i64)
1518 .unwrap_or_else(|| self.body_rows() as i64);
1519 self.scroll_lines(n, src, idx);
1520 }
1521
1522 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1523 let n = self.page_size
1524 .map(|p| p as i64)
1525 .unwrap_or_else(|| self.body_rows() as i64);
1526 self.scroll_lines(-n, src, idx);
1527 }
1528
1529 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1530 let n = (self.body_rows() / 2).max(1) as i64;
1531 self.scroll_lines(n, src, idx);
1532 }
1533
1534 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1535 let n = (self.body_rows() / 2).max(1) as i64;
1536 self.scroll_lines(-n, src, idx);
1537 }
1538
1539 pub fn goto_top(&mut self) {
1540 self.top_line = 0;
1541 self.top_row = 0;
1542 }
1543
1544 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1551 let body = self.body_rows() as usize;
1552 let total = idx.line_count();
1553 if total == 0 || body == 0 {
1554 return (0, 0);
1555 }
1556 let r_opts = self.render_opts(self.gutter_width(idx));
1557 let mut remaining = body;
1558 let mut line = total - 1;
1559 loop {
1560 let bytes = self.line_display_bytes(src, idx, line);
1561 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1562 if line_rows >= remaining {
1563 return (line, line_rows - remaining);
1564 }
1565 remaining -= line_rows;
1566 if line == 0 {
1567 return (0, 0);
1568 }
1569 line -= 1;
1570 }
1571 }
1572
1573 fn hide_bottom_top_line(&self, src: &dyn Source, idx: &LineIndex) -> usize {
1579 let body = self.body_rows() as usize;
1580 let vis = &self.visible_lines;
1581 if vis.is_empty() {
1582 return 0;
1583 }
1584 let r_opts = self.render_opts(self.gutter_width(idx));
1585 let mut rows_sum = 0usize;
1586 let mut chosen = *vis.last().unwrap();
1587 for &vl in vis.iter().rev() {
1588 let rows = count_rows(&self.line_display_bytes(src, idx, vl), &r_opts, None).max(1);
1589 if rows_sum > 0 && rows_sum + rows > body {
1590 break;
1591 }
1592 rows_sum += rows;
1593 chosen = vl;
1594 if rows_sum >= body {
1595 break;
1596 }
1597 }
1598 chosen
1599 }
1600
1601 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1602 idx.extend_to_end(src);
1603 if self.hide_mode() {
1604 self.extend_visible_lines(idx, src);
1605 self.top_line = self.hide_bottom_top_line(src, idx);
1606 self.top_row = 0;
1607 } else {
1608 let (line, row) = self.bottom_anchor(src, idx);
1609 self.top_line = line;
1610 self.top_row = row;
1611 }
1612 }
1613
1614 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1616 idx.extend_to_line(n, src);
1617 let target = n.min(idx.line_count().saturating_sub(1));
1618 self.top_line = target;
1619 self.top_row = 0;
1620 }
1621
1622 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1624 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1628 idx.extend_to_end(src);
1629 }
1630 if idx.record_count() == 0 {
1631 return;
1632 }
1633 let target = n.min(idx.record_count().saturating_sub(1));
1634 let line_range = idx.record_line_range(target);
1635 self.top_line = line_range.start;
1636 self.top_row = 0;
1637 }
1638
1639 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1642 let p = p.min(100) as usize;
1643 let target_byte = src.len().saturating_mul(p) / 100;
1644 idx.extend_to_byte_for_query(src, target_byte);
1645 let line_n = idx.line_at_byte(target_byte)
1646 .or_else(|| {
1647 let lc = idx.line_count();
1649 if lc > 0 { Some(lc - 1) } else { None }
1650 })
1651 .unwrap_or(0);
1652 self.top_line = line_n;
1653 self.top_row = 0;
1654 }
1655
1656 pub fn top_line(&self) -> usize {
1658 self.top_line
1659 }
1660
1661 pub fn resize(&mut self, cols: u16, rows: u16) {
1662 self.cols = cols.max(1);
1663 self.rows = rows.max(2);
1664 self.opts.cols = self.cols;
1665 }
1666
1667 pub fn toggle_line_numbers(&mut self) {
1668 self.show_line_numbers = !self.show_line_numbers;
1669 }
1670
1671 pub fn toggle_chop(&mut self) {
1672 self.opts.wrap = !self.opts.wrap;
1673 }
1674
1675 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::*;
1684 use crate::source::MockSource;
1685
1686 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1687 let m = MockSource::new();
1688 m.append(content);
1689 m.finish();
1690 let idx = LineIndex::new();
1691 (m, idx)
1692 }
1693
1694 #[test]
1695 fn frame_renders_body_height_rows() {
1696 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1697 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1699 assert_eq!(frame.body.len(), 4);
1700 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1701 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1702 }
1703
1704 #[test]
1705 fn scroll_down_advances_top_line() {
1706 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1709 let mut v = Viewport::new(10, 5, "test".into());
1710 v.scroll_lines(2, &m, &mut idx);
1711 assert_eq!(v.top_line, 2);
1712 assert_eq!(v.top_row, 0);
1713 }
1714
1715 #[test]
1716 fn scroll_up_clamps_at_zero() {
1717 let (m, mut idx) = setup(b"a\nb\nc\n");
1718 let mut v = Viewport::new(10, 5, "test".into());
1719 v.scroll_lines(-5, &m, &mut idx);
1720 assert_eq!(v.top_line, 0);
1721 assert_eq!(v.top_row, 0);
1722 }
1723
1724 #[test]
1725 fn scroll_down_clamps_at_last_line() {
1726 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1731 let mut v = Viewport::new(10, 5, "test".into());
1732 v.scroll_lines(50, &m, &mut idx);
1733 assert_eq!((v.top_line, v.top_row), (4, 0));
1734 assert!(v.is_at_bottom(&m, &idx));
1735 }
1736
1737 #[test]
1738 fn scroll_logical_lines_skips_wrap_rows() {
1739 let mut content = vec![b'X'; 500];
1741 content.push(b'\n');
1742 content.extend_from_slice(b"second\n");
1743 content.extend_from_slice(b"third\n");
1744 let (m, mut idx) = setup(&content);
1745 let mut v = Viewport::new(10, 8, "f".into());
1746 v.scroll_logical_lines(1, &m, &mut idx);
1747 assert_eq!((v.top_line, v.top_row), (1, 0));
1748 v.scroll_logical_lines(1, &m, &mut idx);
1749 assert_eq!((v.top_line, v.top_row), (2, 0));
1750 }
1751
1752 #[test]
1753 fn scroll_logical_lines_back_snaps_to_line_start() {
1754 let mut content = vec![b'A'; 50];
1759 content.push(b'\n');
1760 content.extend_from_slice(&[b'B'; 50]);
1761 content.push(b'\n');
1762 content.extend_from_slice(&[b'C'; 50]);
1763 content.push(b'\n');
1764 let (m, mut idx) = setup(&content);
1765 let mut v = Viewport::new(10, 8, "f".into());
1766 v.scroll_lines(7, &m, &mut idx);
1767 assert_eq!(v.top_line, 1, "should be on line 1");
1768 assert!(v.top_row > 0, "should be inside line 1's wraps");
1769 v.scroll_logical_lines(-1, &m, &mut idx);
1770 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1771 v.scroll_logical_lines(-1, &m, &mut idx);
1772 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1773 }
1774
1775 #[test]
1776 fn scroll_down_walks_wraps_of_last_line() {
1777 let mut content = b"first\n".to_vec();
1781 content.extend_from_slice(&[b'X'; 60]);
1782 content.push(b'\n');
1783 let (m, mut idx) = setup(&content);
1784 let mut v = Viewport::new(10, 5, "f".into());
1785 v.scroll_lines(1, &m, &mut idx);
1786 assert_eq!((v.top_line, v.top_row), (1, 0));
1787 v.scroll_lines(1, &m, &mut idx);
1788 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1789 v.scroll_lines(1, &m, &mut idx);
1790 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
1791 v.scroll_lines(5, &m, &mut idx);
1793 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
1794 }
1795
1796 #[test]
1797 fn scroll_down_walks_wrap_rows_within_long_line() {
1798 let mut content = vec![b'X'; 30];
1802 content.push(b'\n');
1803 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
1804 let (m, mut idx) = setup(&content);
1805 let mut v = Viewport::new(10, 5, "f".into());
1806 v.scroll_lines(1, &m, &mut idx);
1807 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1808 v.scroll_lines(1, &m, &mut idx);
1809 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1810 v.scroll_lines(1, &m, &mut idx);
1811 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1812 }
1813
1814 #[test]
1815 fn status_line_shows_range_and_pct() {
1816 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1817 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1819 assert!(frame.status.starts_with("f 1-4/10"));
1820 }
1821
1822 #[test]
1823 fn page_down_advances_by_body_rows() {
1824 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1825 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1827 assert_eq!(v.top_line, 4);
1828 }
1829
1830 #[test]
1831 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1832 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1833 let mut v = Viewport::new(10, 5, "f".into());
1834 v.page_down(&m, &mut idx);
1835 v.page_up(&m, &mut idx);
1836 assert_eq!(v.top_line, 0);
1837 assert_eq!(v.top_row, 0);
1838 }
1839
1840 #[test]
1841 fn half_page_down_advances_by_half_body() {
1842 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
1845 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1847 assert_eq!(v.top_line, 3);
1848 }
1849
1850 #[test]
1851 fn goto_top_resets_position() {
1852 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1853 let mut v = Viewport::new(10, 5, "f".into());
1854 v.scroll_lines(2, &m, &mut idx);
1855 v.goto_top();
1856 assert_eq!(v.top_line, 0);
1857 assert_eq!(v.top_row, 0);
1858 }
1859
1860 #[test]
1861 fn goto_bottom_scrolls_to_last_page() {
1862 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1863 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1865 assert_eq!(v.top_line, 6);
1867 }
1868
1869 #[test]
1870 fn goto_line_positions_top_line() {
1871 let m = MockSource::new();
1872 m.append(b"a\nb\nc\nd\ne\n");
1873 let mut idx = LineIndex::new();
1874 idx.extend_to_end(&m);
1875 let mut v = Viewport::new(20, 5, "f".into());
1876 v.goto_line(3, &m, &mut idx);
1877 assert_eq!(v.top_line(), 3);
1878 }
1879
1880 #[test]
1881 fn goto_line_clamps_to_last_line() {
1882 let m = MockSource::new();
1883 m.append(b"a\nb\n");
1884 let mut idx = LineIndex::new();
1885 idx.extend_to_end(&m);
1886 let mut v = Viewport::new(20, 5, "f".into());
1887 v.goto_line(999, &m, &mut idx);
1888 assert_eq!(v.top_line(), 1);
1889 }
1890
1891 #[test]
1892 fn goto_record_positions_at_record_start_line() {
1893 let m = MockSource::new();
1894 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1895 let mut idx = LineIndex::new();
1896 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1897 idx.extend_to_end(&m);
1898 let mut v = Viewport::new(20, 5, "f".into());
1899 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1901 }
1902
1903 #[test]
1904 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1905 let m = MockSource::new();
1906 m.append(b"a\nb\nc\n");
1907 let mut idx = LineIndex::new();
1908 idx.extend_to_end(&m);
1909 let mut v = Viewport::new(20, 5, "f".into());
1910 v.goto_record(2, &m, &mut idx);
1911 assert_eq!(v.top_line(), 2);
1912 }
1913
1914 #[test]
1915 fn goto_percent_50_lands_in_middle() {
1916 let m = MockSource::new();
1917 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1919 idx.extend_to_end(&m);
1920 let mut v = Viewport::new(20, 5, "f".into());
1921 v.goto_percent(50, &m, &mut idx);
1922 assert_eq!(v.top_line(), 2); }
1924
1925 #[test]
1926 fn goto_percent_100_lands_at_last_line() {
1927 let m = MockSource::new();
1928 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1930 idx.extend_to_end(&m);
1931 let mut v = Viewport::new(20, 5, "f".into());
1932 v.goto_percent(100, &m, &mut idx);
1933 assert_eq!(v.top_line(), 2);
1934 }
1935
1936 #[test]
1937 fn goto_percent_0_lands_at_first_line() {
1938 let m = MockSource::new();
1939 m.append(b"a\nb\nc\n");
1940 let mut idx = LineIndex::new();
1941 idx.extend_to_end(&m);
1942 let mut v = Viewport::new(20, 5, "f".into());
1943 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1945 v.goto_percent(0, &m, &mut idx);
1946 assert_eq!(v.top_line(), 0);
1947 }
1948
1949 #[test]
1950 fn resize_updates_dimensions_and_render_opts() {
1951 let (m, mut idx) = setup(b"1\n2\n");
1952 let mut v = Viewport::new(10, 5, "f".into());
1953 v.resize(40, 12);
1954 assert_eq!(v.cols, 40);
1955 assert_eq!(v.rows, 12);
1956 assert_eq!(v.opts.cols, 40);
1957 let _ = v.frame(&m, &mut idx);
1958 }
1959
1960 #[test]
1961 fn toggle_line_numbers_changes_gutter() {
1962 let (m, mut idx) = setup(b"a\nb\nc\n");
1963 let mut v = Viewport::new(10, 5, "f".into());
1964 let frame_off = v.frame(&m, &mut idx);
1965 v.toggle_line_numbers();
1966 let frame_on = v.frame(&m, &mut idx);
1967 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1969 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1970 }
1971
1972 #[test]
1973 fn toggle_chop_changes_wrap_mode() {
1974 let (m, mut idx) = setup(b"abcdefghij\n");
1975 let mut v = Viewport::new(4, 5, "f".into());
1976 v.toggle_chop();
1977 let frame = v.frame(&m, &mut idx);
1978 assert_eq!(frame.body[0][..4],
1981 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1982 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1983 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1984 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1985 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1987 }
1988
1989 #[test]
1992 fn is_at_bottom_initially_only_when_source_fits() {
1993 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1996 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
1997 }
1998
1999 #[test]
2000 fn is_at_bottom_false_when_top_and_more_lines_below() {
2001 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2004 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2005 }
2006
2007 #[test]
2008 fn is_at_bottom_true_after_goto_bottom() {
2009 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2010 let mut v = Viewport::new(10, 5, "f".into());
2011 v.goto_bottom(&m, &mut idx);
2012 assert!(v.is_at_bottom(&m, &idx));
2013 }
2014
2015 #[test]
2016 fn status_shows_follow_suffix_when_follow_mode_on() {
2017 let (m, mut idx) = setup(b"a\nb\n");
2018 let mut v = Viewport::new(20, 5, "f".into());
2019 let frame_off = v.frame(&m, &mut idx);
2020 assert!(!frame_off.status.contains("(F)"));
2021 v.set_follow_mode(true);
2022 let frame_on = v.frame(&m, &mut idx);
2023 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2024 }
2025
2026 #[test]
2027 fn toggle_follow_flips_state() {
2028 let mut v = Viewport::new(10, 5, "f".into());
2029 assert!(!v.follow_mode());
2030 v.toggle_follow();
2031 assert!(v.follow_mode());
2032 v.toggle_follow();
2033 assert!(!v.follow_mode());
2034 }
2035
2036 #[test]
2037 fn idle_indicator_kicks_in_at_threshold() {
2038 let (m, mut idx) = setup(b"a\nb\n");
2039 let mut v = Viewport::new(20, 5, "f".into());
2040 v.set_follow_mode(true);
2041 for _ in 0..19 { v.tick_idle(); }
2043 let f1 = v.frame(&m, &mut idx);
2044 assert!(f1.status.contains("(F)"));
2045 assert!(!f1.status.contains("idle"));
2046 v.tick_idle();
2048 let f2 = v.frame(&m, &mut idx);
2049 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2050 }
2051
2052 #[test]
2053 fn note_growth_resets_idle() {
2054 let (m, mut idx) = setup(b"a\nb\n");
2055 let mut v = Viewport::new(20, 5, "f".into());
2056 v.set_follow_mode(true);
2057 for _ in 0..25 { v.tick_idle(); }
2058 assert!(v.is_idle());
2059 v.note_growth();
2060 assert!(!v.is_idle());
2061 let f = v.frame(&m, &mut idx);
2062 assert!(!f.status.contains("idle"));
2063 }
2064
2065 #[test]
2066 fn qae_off_never_quits_even_at_bottom() {
2067 let (m, mut idx) = setup(b"a\n");
2068 let mut v = Viewport::new(20, 5, "f".into());
2069 v.set_quit_at_eof(QuitAtEof::Off);
2070 v.goto_bottom(&m, &mut idx);
2071 assert!(!v.note_motion_for_eof(true, &m, &idx));
2072 }
2073
2074 #[test]
2075 fn qae_first_quits_immediately_at_bottom() {
2076 let (m, mut idx) = setup(b"a\n");
2077 let mut v = Viewport::new(20, 5, "f".into());
2078 v.set_quit_at_eof(QuitAtEof::First);
2079 v.goto_bottom(&m, &mut idx);
2080 assert!(v.note_motion_for_eof(true, &m, &idx));
2081 }
2082
2083 #[test]
2084 fn qae_first_only_quits_at_eof_not_mid_file() {
2085 let mut content = Vec::new();
2086 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2087 let (m, mut idx) = setup(&content);
2088 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2090 v.set_quit_at_eof(QuitAtEof::First);
2091 assert!(!v.is_at_bottom(&m, &idx));
2093 assert!(!v.note_motion_for_eof(true, &m, &idx));
2094 }
2095
2096 #[test]
2097 fn qae_second_quits_on_second_hit() {
2098 let (m, mut idx) = setup(b"a\n");
2099 let mut v = Viewport::new(20, 5, "f".into());
2100 v.set_quit_at_eof(QuitAtEof::Second);
2101 v.goto_bottom(&m, &mut idx);
2102 assert!(!v.note_motion_for_eof(true, &m, &idx));
2104 assert!(v.note_motion_for_eof(true, &m, &idx));
2106 }
2107
2108 #[test]
2109 fn squeeze_collapses_consecutive_blanks() {
2110 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2112 let mut v = Viewport::new(10, 8, "f".into());
2113 v.set_squeeze_blanks(true);
2114 let f = v.frame(&m, &mut idx);
2115 let stringify = |row: &Vec<Cell>| -> String {
2117 row.iter().filter_map(|c| match c {
2118 Cell::Char { ch, .. } => Some(*ch),
2119 _ => None,
2120 }).collect::<String>().trim().to_string()
2121 };
2122 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2123 assert_eq!(&rows[0], "a");
2125 assert_eq!(&rows[1], "");
2126 assert_eq!(&rows[2], "b");
2127 }
2128
2129 #[test]
2130 fn header_pins_top_rows_when_scrolling() {
2131 let mut content = Vec::new();
2133 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2134 let (m, mut idx) = setup(&content);
2135 let mut v = Viewport::new(20, 6, "f".into());
2136 v.set_header(2, 0);
2137 v.scroll_lines(5, &m, &mut idx);
2141 let f = v.frame(&m, &mut idx);
2142 let chs = |row: &Vec<Cell>| -> String {
2143 row.iter().filter_map(|c| match c {
2144 Cell::Char { ch, .. } => Some(*ch),
2145 _ => None,
2146 }).collect::<String>().trim().to_string()
2147 };
2148 assert_eq!(&chs(&f.body[0]), "line0");
2150 assert_eq!(&chs(&f.body[1]), "line1");
2151 assert_eq!(&chs(&f.body[2]), "line7");
2153 }
2154
2155 #[test]
2156 fn page_size_when_set_overrides_body_rows() {
2157 let mut content = Vec::new();
2158 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2159 let (m, mut idx) = setup(&content);
2160 let mut v = Viewport::new(20, 10, "f".into());
2161 v.set_page_size(Some(3));
2162 let before = v.top_line();
2163 v.page_down(&m, &mut idx);
2164 assert_eq!(v.top_line(), before + 3);
2165 v.page_up(&m, &mut idx);
2166 assert_eq!(v.top_line(), before);
2167 }
2168
2169 #[test]
2170 fn page_size_unset_uses_body_rows() {
2171 let mut content = Vec::new();
2172 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2173 let (m, mut idx) = setup(&content);
2174 let mut v = Viewport::new(20, 10, "f".into());
2175 v.page_down(&m, &mut idx);
2177 assert_eq!(v.top_line(), 9);
2178 }
2179
2180 #[test]
2181 fn header_zero_lines_renders_like_no_header() {
2182 let mut content = Vec::new();
2183 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2184 let (m, mut idx) = setup(&content);
2185 let mut v = Viewport::new(20, 6, "f".into());
2186 v.set_header(0, 0);
2187 let f = v.frame(&m, &mut idx);
2188 let chs = |row: &Vec<Cell>| -> String {
2189 row.iter().filter_map(|c| match c {
2190 Cell::Char { ch, .. } => Some(*ch),
2191 _ => None,
2192 }).collect::<String>().trim().to_string()
2193 };
2194 assert_eq!(&chs(&f.body[0]), "line0");
2195 assert_eq!(&chs(&f.body[1]), "line1");
2196 }
2197
2198 #[test]
2199 fn squeeze_off_preserves_blanks() {
2200 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2201 let mut v = Viewport::new(10, 8, "f".into());
2202 let f = v.frame(&m, &mut idx);
2204 let stringify = |row: &Vec<Cell>| -> String {
2205 row.iter().filter_map(|c| match c {
2206 Cell::Char { ch, .. } => Some(*ch),
2207 _ => None,
2208 }).collect::<String>().trim().to_string()
2209 };
2210 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2211 assert_eq!(&rows[0], "a");
2213 assert_eq!(&rows[1], "");
2214 assert_eq!(&rows[2], "");
2215 assert_eq!(&rows[3], "");
2216 assert_eq!(&rows[4], "b");
2217 }
2218
2219 #[test]
2220 fn qae_second_resets_on_backward_motion() {
2221 let (m, mut idx) = setup(b"a\n");
2222 let mut v = Viewport::new(20, 5, "f".into());
2223 v.set_quit_at_eof(QuitAtEof::Second);
2224 v.goto_bottom(&m, &mut idx);
2225 assert!(!v.note_motion_for_eof(true, &m, &idx));
2226 v.note_motion_for_eof(false, &m, &idx);
2228 assert!(!v.note_motion_for_eof(true, &m, &idx));
2230 assert!(v.note_motion_for_eof(true, &m, &idx));
2232 }
2233
2234 #[test]
2235 fn flash_message_overrides_follow_suffix() {
2236 let (m, mut idx) = setup(b"a\nb\n");
2237 let mut v = Viewport::new(40, 5, "f".into());
2238 v.set_follow_mode(true);
2239 v.flash("(F reopened)", 3);
2240 let f = v.frame(&m, &mut idx);
2241 assert!(f.status.contains("(F reopened)"), "{}", f.status);
2242 assert!(!f.status.contains("(F idle)"));
2243 }
2244
2245 #[test]
2246 fn flash_countdown_clears() {
2247 let mut v = Viewport::new(10, 5, "f".into());
2248 v.flash("hello", 2);
2249 v.tick_flash();
2250 assert!(v.status_flash.is_some());
2251 v.tick_flash();
2252 assert!(v.status_flash.is_none());
2253 }
2254
2255 #[test]
2256 fn suspend_follow_if_off_is_noop() {
2257 let mut v = Viewport::new(10, 5, "f".into());
2258 v.set_follow_mode(true);
2259 v.suspend_follow_if(false);
2260 assert!(v.follow_mode());
2261 }
2262
2263 #[test]
2264 fn suspend_follow_if_on_flips_off() {
2265 let mut v = Viewport::new(10, 5, "f".into());
2266 v.set_follow_mode(true);
2267 v.suspend_follow_if(true);
2268 assert!(!v.follow_mode());
2269 }
2270
2271 #[test]
2272 fn case_mode_sensitive_returns_pattern_unchanged() {
2273 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2274 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2275 }
2276
2277 #[test]
2278 fn case_mode_insensitive_prepends_i_flag() {
2279 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2280 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2281 }
2282
2283 #[test]
2284 fn case_mode_smart_lowercase_is_insensitive() {
2285 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2286 }
2287
2288 #[test]
2289 fn case_mode_smart_with_uppercase_is_sensitive() {
2290 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2291 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2292 }
2293
2294 #[test]
2295 fn set_case_mode_recompiles_active_search() {
2296 let (m, mut idx) = setup(b"hello WORLD\n");
2297 let mut v = Viewport::new(40, 5, "f".into());
2298 v.set_search("world".into(), SearchDirection::Forward).unwrap();
2299 assert!(!v.search_repeat(&m, &mut idx, false));
2301 v.set_case_mode(CaseMode::Insensitive);
2303 assert!(v.search_repeat(&m, &mut idx, false));
2304 }
2305
2306 #[test]
2307 fn status_shows_prettify_label_when_set() {
2308 let (m, mut idx) = setup(b"a\n");
2309 let mut v = Viewport::new(40, 5, "f".into());
2310 let frame_off = v.frame(&m, &mut idx);
2311 assert!(!frame_off.status.contains("[pretty"));
2312 v.set_prettify_label(Some("json".into()));
2313 let frame_on = v.frame(&m, &mut idx);
2314 assert!(frame_on.status.contains("[pretty:json]"),
2315 "expected [pretty:json] in status, got: {}", frame_on.status);
2316 v.set_prettify_label(Some("json:err".into()));
2317 let frame_err = v.frame(&m, &mut idx);
2318 assert!(frame_err.status.contains("[pretty:json:err]"),
2319 "expected [pretty:json:err] in status, got: {}", frame_err.status);
2320 }
2321
2322 #[test]
2323 fn status_shows_l_suffix_when_live_mode_on() {
2324 let (m, mut idx) = setup(b"a\nb\n");
2325 let mut v = Viewport::new(20, 5, "f".into());
2326 let frame_off = v.frame(&m, &mut idx);
2327 assert!(!frame_off.status.contains("(L)"));
2328 v.set_live_mode(true);
2329 let frame_on = v.frame(&m, &mut idx);
2330 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2331 }
2332
2333 #[test]
2334 fn clamp_top_line_pulls_back_when_total_shrinks() {
2335 let mut v = Viewport::new(20, 5, "f".into());
2336 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
2345 let (m, mut idx) = setup(b"only\n");
2347 let _ = v.frame(&m, &mut idx);
2348 }
2349
2350 fn simulate_growth_tick(
2353 v: &mut Viewport,
2354 src: &MockSource,
2355 idx: &mut LineIndex,
2356 ) {
2357 if !v.follow_mode() { return; }
2358 let was_at_bottom = v.is_at_bottom(src, idx);
2359 let lines_before = idx.line_count();
2360 idx.notice_new_bytes(src);
2361 if idx.line_count() != lines_before && was_at_bottom {
2362 v.goto_bottom(src, idx);
2363 }
2364 }
2365
2366 #[test]
2367 fn auto_scroll_engages_when_at_bottom() {
2368 let m = MockSource::new();
2369 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
2371 let mut v = Viewport::new(10, 5, "f".into());
2372 v.set_follow_mode(true);
2373 idx.extend_to_end(&m);
2374 assert!(v.is_at_bottom(&m, &idx));
2375 let top_before = {
2376 let f = v.frame(&m, &mut idx);
2377 f.status.clone() };
2379 let _ = top_before;
2380 m.append(b"5\n6\n7\n8\n");
2382 simulate_growth_tick(&mut v, &m, &mut idx);
2383 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
2385 let frame = v.frame(&m, &mut idx);
2386 let last_row = &frame.body[frame.body.len() - 1];
2389 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2390 }
2391
2392 #[test]
2393 fn auto_scroll_suppressed_when_scrolled_up() {
2394 let m = MockSource::new();
2395 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
2397 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
2399 idx.extend_to_end(&m);
2400 v.goto_bottom(&m, &mut idx);
2401 v.scroll_lines(-2, &m, &mut idx);
2403 assert!(!v.is_at_bottom(&m, &idx));
2404 let frame_before = v.frame(&m, &mut idx);
2405 let top_first_cell_before = frame_before.body[0][0].clone();
2406 m.append(b"9\n10\n");
2408 simulate_growth_tick(&mut v, &m, &mut idx);
2409 let frame_after = v.frame(&m, &mut idx);
2411 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2412 }
2413
2414 #[test]
2417 fn set_search_compiles_regex() {
2418 let mut v = Viewport::new(10, 5, "f".into());
2419 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2420 assert!(v.search_active());
2421 }
2422
2423 #[test]
2424 fn set_search_rejects_bad_regex() {
2425 let mut v = Viewport::new(10, 5, "f".into());
2426 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2427 assert!(!err.is_empty());
2428 assert!(!v.search_active(), "no search should be set on error");
2429 }
2430
2431 #[test]
2432 fn search_step_forward_finds_match_after_top() {
2433 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2434 let mut v = Viewport::new(20, 5, "f".into());
2435 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2436 let found = v.search_repeat(&m, &mut idx, false);
2437 assert!(found);
2438 assert_eq!(v.top_line, 2);
2440 }
2441
2442 #[test]
2443 fn search_step_backward_finds_match_before_top() {
2444 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2445 let mut v = Viewport::new(20, 5, "f".into());
2446 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2448 let found = v.search_repeat(&m, &mut idx, false);
2449 assert!(found);
2450 assert_eq!(v.top_line, 0);
2451 }
2452
2453 #[test]
2454 fn search_wraps_at_end() {
2455 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2456 let mut v = Viewport::new(20, 5, "f".into());
2457 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2459 let found = v.search_repeat(&m, &mut idx, false);
2460 assert!(found, "search should wrap forward past EOF");
2461 assert_eq!(v.top_line, 0);
2462 }
2463
2464 #[test]
2465 fn search_no_match_returns_false_and_does_not_move() {
2466 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2467 let mut v = Viewport::new(20, 5, "f".into());
2468 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2469 let found = v.search_repeat(&m, &mut idx, false);
2470 assert!(!found);
2471 assert_eq!(v.top_line, 0);
2472 }
2473
2474 #[test]
2475 fn frame_records_highlight_ranges_for_matches() {
2476 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2477 let mut v = Viewport::new(20, 5, "f".into());
2478 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2479 let frame = v.frame(&m, &mut idx);
2480 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2482 assert!(frame.highlights[0].is_empty());
2483 assert!(frame.highlights[1].is_empty());
2484 assert_eq!(frame.highlights[2], vec![0..5]);
2485 assert!(frame.highlights[3].is_empty());
2486 }
2487
2488 #[test]
2489 fn frame_highlights_substring_inside_a_row() {
2490 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2491 let mut v = Viewport::new(40, 5, "f".into());
2492 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2493 let frame = v.frame(&m, &mut idx);
2494 assert_eq!(frame.highlights[0], vec![18..22]);
2496 assert!(frame.highlights[1].is_empty());
2497 }
2498
2499 #[test]
2500 fn search_highlight_with_filter_dim_keeps_row_dim() {
2501 let (m, mut idx) = setup(b"alpha\nbeta\n");
2504 let mut v = Viewport::new(20, 5, "f".into());
2505 let fmt = crate::format::LogFormat::compile(
2506 "simple",
2507 r"^(?P<line>.+)$",
2508 )
2509 .unwrap();
2510 let f = crate::filter::CompiledFilter::compile(
2511 &fmt,
2512 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2513 CaseMode::Sensitive,
2514 )
2515 .unwrap();
2516 v.set_filter(Some(f));
2517 v.set_dim_mode(true);
2518 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2519 let frame = v.frame(&m, &mut idx);
2520 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2521 assert_eq!(frame.row_styles[1], RowStyle::Dim);
2522 assert_eq!(frame.highlights[1], vec![0..4]);
2523 }
2524
2525 #[test]
2526 fn grep_only_hides_non_matching_lines() {
2527 use crate::grep::GrepPredicate;
2528 let src = crate::source::MockSource::new();
2529 src.append(b"keep this error\n");
2530 src.append(b"drop this one\n");
2531 src.append(b"another error line\n");
2532 src.finish();
2533 let mut idx = crate::line_index::LineIndex::new();
2534 idx.extend_to_end(&src);
2535
2536 let mut v = Viewport::new(40, 5, "test".into());
2537 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2538 v.extend_visible_lines(&idx, &src);
2539
2540 let frame = v.frame(&src, &mut idx);
2542 let body_text: Vec<String> = frame.body.iter()
2543 .map(|row| row.iter().filter_map(|c| match c {
2544 crate::render::Cell::Char { ch, .. } => Some(*ch),
2545 _ => None,
2546 }).collect())
2547 .collect();
2548 assert!(body_text[0].contains("keep this error"));
2549 assert!(body_text[1].contains("another error line"));
2550 assert!(frame.status.contains("[grep]"));
2551 }
2552
2553 #[test]
2554 fn filter_and_grep_combine_with_and() {
2555 use crate::grep::GrepPredicate;
2556 let fmt = crate::format::LogFormat::compile(
2557 "simple",
2558 r"^(?P<level>\w+) (?P<msg>.+)$",
2559 ).unwrap();
2560 let f = crate::filter::CompiledFilter::compile(
2561 &fmt,
2562 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2563 CaseMode::Sensitive,
2564 ).unwrap();
2565 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2566
2567 let src = crate::source::MockSource::new();
2568 src.append(b"ERROR timeout connecting\n"); src.append(b"ERROR file not found\n"); src.append(b"WARN timeout retrying\n"); src.append(b"INFO all good\n"); src.finish();
2573 let mut idx = crate::line_index::LineIndex::new();
2574 idx.extend_to_end(&src);
2575
2576 let mut v = Viewport::new(80, 5, "test".into());
2577 v.set_filter(Some(f));
2578 v.set_grep(Some(g));
2579 v.extend_visible_lines(&idx, &src);
2580 assert_eq!(v.visible_lines(), &[0usize]);
2581 }
2582
2583 #[test]
2584 fn search_status_shows_pattern() {
2585 let (m, mut idx) = setup(b"x\n");
2586 let mut v = Viewport::new(20, 5, "f".into());
2587 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2588 let frame = v.frame(&m, &mut idx);
2589 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
2590 }
2591
2592 #[test]
2593 fn repeat_search_after_first_match_advances() {
2594 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
2595 let mut v = Viewport::new(40, 5, "f".into());
2596 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2597 assert!(v.search_repeat(&m, &mut idx, false));
2598 assert_eq!(v.top_line, 1, "first foo");
2599 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2600 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
2601 assert_eq!(v.top_line, 3, "should advance to next foo");
2602 }
2603
2604 #[test]
2605 fn auto_scroll_paused_when_follow_off() {
2606 let m = MockSource::new();
2607 m.append(b"1\n2\n3\n4\n");
2608 let mut idx = LineIndex::new();
2609 let mut v = Viewport::new(10, 5, "f".into());
2610 idx.extend_to_end(&m);
2612 let frame_before = v.frame(&m, &mut idx);
2613 let top_first_cell = frame_before.body[0][0].clone();
2614 m.append(b"5\n6\n7\n8\n");
2615 simulate_growth_tick(&mut v, &m, &mut idx);
2616 let frame_after = v.frame(&m, &mut idx);
2617 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
2618 }
2619
2620 #[test]
2623 fn search_jumps_to_next_matching_record() {
2624 let m = MockSource::new();
2625 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
2626 let mut idx = LineIndex::new();
2627 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2628 idx.extend_to_end(&m);
2629 let mut v = Viewport::new(40, 10, "f".into());
2630 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
2631 let hit = v.search_repeat(&m, &mut idx, false);
2632 assert!(hit, "should find 'charlie' in record 2");
2633 assert_eq!(v.top_line(), 3); }
2635
2636 #[test]
2637 fn search_finds_cross_line_match_in_record_with_s_flag() {
2638 let m = MockSource::new();
2639 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
2640 let mut idx = LineIndex::new();
2641 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2642 idx.extend_to_end(&m);
2643 let mut v = Viewport::new(40, 10, "f".into());
2644 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
2645 let hit = v.search_repeat(&m, &mut idx, false);
2646 assert!(hit, "should match across \\n inside record 0 with (?s)");
2647 assert_eq!(v.top_line(), 0);
2648 }
2649
2650 #[test]
2651 fn search_repeat_with_no_match_returns_false() {
2652 let m = MockSource::new();
2653 m.append(b"[1] alpha\n[2] bravo\n");
2654 let mut idx = LineIndex::new();
2655 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2656 idx.extend_to_end(&m);
2657 let mut v = Viewport::new(40, 10, "f".into());
2658 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
2659 let hit = v.search_repeat(&m, &mut idx, false);
2660 assert!(!hit);
2661 }
2662
2663 #[test]
2666 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
2667 let m = MockSource::new();
2670 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
2671 let mut idx = LineIndex::new();
2672 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2673 idx.extend_to_end(&m);
2674 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2675 let mut v = Viewport::new(40, 10, "f".into());
2676 v.set_grep(Some(grep));
2677 v.extend_visible_lines(&idx, &m);
2678 assert_eq!(v.visible_lines(), &[0usize, 1]);
2681 }
2682
2683 #[test]
2684 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2685 let m = MockSource::new();
2691 m.append(
2692 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
2693 );
2694 let mut idx = LineIndex::new();
2695 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2696 idx.extend_to_end(&m);
2697 let fmt = crate::format::LogFormat::compile(
2698 "rec",
2699 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2700 )
2701 .unwrap();
2702 let f = crate::filter::CompiledFilter::compile(
2703 &fmt,
2704 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2705 CaseMode::Sensitive,
2706 )
2707 .unwrap();
2708 let mut v = Viewport::new(40, 10, "f".into());
2709 v.set_filter(Some(f));
2710 v.extend_visible_lines(&idx, &m);
2711 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2713 }
2714
2715 #[test]
2716 fn grep_matches_across_record_newlines_in_records_mode() {
2717 let m = MockSource::new();
2719 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2720 let mut idx = LineIndex::new();
2721 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2722 idx.extend_to_end(&m);
2723 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2724 let mut v = Viewport::new(40, 10, "f".into());
2725 v.set_grep(Some(grep));
2726 v.extend_visible_lines(&idx, &m);
2727 assert_eq!(v.visible_lines(), &[0usize, 1]);
2729 }
2730
2731 #[test]
2732 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2733 let m = MockSource::new();
2736 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2737 let mut idx = LineIndex::new();
2738 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2739 idx.extend_to_end(&m);
2740 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
2741 let mut v = Viewport::new(40, 10, "f".into());
2742 v.set_grep(Some(grep));
2743 v.set_dim_mode(true);
2744 v.extend_visible_lines(&idx, &m);
2745 assert_eq!(v.visible_lines(), &[] as &[usize]);
2747 assert!(!v.should_dim_line(0, &idx, &m));
2749 assert!(!v.should_dim_line(1, &idx, &m));
2750 assert!(v.should_dim_line(2, &idx, &m));
2752 assert!(v.should_dim_line(3, &idx, &m));
2753 }
2754
2755 #[test]
2756 fn status_unchanged_when_records_inactive() {
2757 let (m, mut idx) = setup(b"a\nb\nc\n");
2758 let mut v = Viewport::new(20, 5, "f".into());
2759 let frame = v.frame(&m, &mut idx);
2760 let status = &frame.status;
2761 assert!(status.contains("1-3/3"), "got: {status}");
2763 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2764 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2765 }
2766
2767 #[test]
2768 fn status_r_block_uses_real_lines_in_hide_mode() {
2769 let m = MockSource::new();
2778 let mut buf = Vec::new();
2781 for n in 0..10 {
2782 let kind = if n >= 8 { "B" } else { "A" };
2783 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
2784 }
2785 m.append(&buf);
2786 m.finish();
2787
2788 let mut idx = LineIndex::new();
2789 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2790 idx.extend_to_end(&m);
2791
2792 let fmt = crate::format::LogFormat::compile(
2793 "rec",
2794 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2795 )
2796 .unwrap();
2797 let f = crate::filter::CompiledFilter::compile(
2798 &fmt,
2799 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2800 CaseMode::Sensitive,
2801 )
2802 .unwrap();
2803
2804 let mut v = Viewport::new(80, 5, "f".into());
2807 v.set_filter(Some(f));
2808 v.extend_visible_lines(&idx, &m);
2809
2810 v.goto_record(8, &m, &mut idx);
2812
2813 let frame = v.frame(&m, &mut idx);
2814 assert!(
2816 frame.status.contains("R9-10/10"),
2817 "expected R9-10/10 in status, got: {}",
2818 frame.status,
2819 );
2820 }
2821
2822 #[test]
2823 fn status_dual_readout_when_records_active() {
2824 let m = MockSource::new();
2825 m.append(b"[1] a\n cont\n[2] b\n");
2826 m.finish();
2827 let mut idx = LineIndex::new();
2828 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2829 idx.extend_to_end(&m);
2830 let mut v = Viewport::new(20, 5, "f".into());
2831 let frame = v.frame(&m, &mut idx);
2832 let status = &frame.status;
2833 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2834 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2835 }
2836
2837 #[test]
2838 fn format_status_uses_custom_template_when_set() {
2839 let m = MockSource::new();
2840 m.append(b"a\nb\nc\n");
2841 m.finish();
2842 let mut idx = LineIndex::new();
2843 idx.extend_to_end(&m);
2844 let mut v = Viewport::new(20, 5, "f".into());
2845 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2846 v.set_prompt(Some(prompt));
2847 let frame = v.frame(&m, &mut idx);
2848 assert_eq!(frame.status, "f 100%");
2849 }
2850
2851 #[test]
2852 fn status_shows_preprocess_failed_tag_when_set() {
2853 let m = MockSource::new();
2854 m.append(b"a\n");
2855 let mut idx = LineIndex::new();
2856 idx.extend_to_end(&m);
2857 let mut v = Viewport::new(40, 5, "f".into());
2858 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2859 let frame = v.frame(&m, &mut idx);
2860 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2861 "got: {}", frame.status);
2862 }
2863
2864 #[test]
2865 fn default_status_includes_help_hint() {
2866 let (m, mut idx) = setup(b"a\nb\nc\n");
2867 let mut v = Viewport::new(80, 5, "f".into());
2868 let frame = v.frame(&m, &mut idx);
2869 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2870 }
2871
2872 #[test]
2873 fn custom_prompt_does_not_get_help_hint() {
2874 let (m, mut idx) = setup(b"a\nb\nc\n");
2875 let mut v = Viewport::new(80, 5, "f".into());
2876 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2877 let frame = v.frame(&m, &mut idx);
2878 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2879 }
2880
2881 #[test]
2882 fn status_shows_file_index_when_multifile() {
2883 let m = MockSource::new();
2884 m.append(b"a\n");
2885 let mut idx = LineIndex::new();
2886 idx.extend_to_end(&m);
2887 let mut v = Viewport::new(60, 5, "f.log".into());
2888 v.set_file_index(0, 3);
2889 let frame = v.frame(&m, &mut idx);
2890 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2891 }
2892
2893 #[test]
2894 fn status_omits_file_index_when_single_file() {
2895 let m = MockSource::new();
2896 m.append(b"a\n");
2897 let mut idx = LineIndex::new();
2898 idx.extend_to_end(&m);
2899 let mut v = Viewport::new(60, 5, "f.log".into());
2900 v.set_file_index(0, 1);
2901 let frame = v.frame(&m, &mut idx);
2902 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2903 }
2904
2905 #[test]
2906 fn status_shows_tag_active_when_multimatch() {
2907 let m = MockSource::new();
2908 m.append(b"a\n");
2909 let mut idx = LineIndex::new();
2910 idx.extend_to_end(&m);
2911 let mut v = Viewport::new(80, 5, "f.log".into());
2912 v.set_tag_active(Some(("foo".into(), 2, 3)));
2913 let frame = v.frame(&m, &mut idx);
2914 assert!(
2915 frame.status.contains("[tag: foo (2/3)]"),
2916 "got: {}",
2917 frame.status
2918 );
2919 }
2920
2921 #[test]
2922 fn status_omits_tag_active_when_single_match() {
2923 let m = MockSource::new();
2924 m.append(b"a\n");
2925 let mut idx = LineIndex::new();
2926 idx.extend_to_end(&m);
2927 let mut v = Viewport::new(80, 5, "f.log".into());
2928 v.set_tag_active(Some(("foo".into(), 1, 1)));
2929 let frame = v.frame(&m, &mut idx);
2930 assert!(
2931 !frame.status.contains("[tag:"),
2932 "should not show indicator for single match: {}",
2933 frame.status
2934 );
2935 }
2936
2937 #[test]
2940 fn reconstruct_picks_up_state_from_prior_lines() {
2941 let m = MockSource::new();
2942 m.append(b"\x1b[31mline 1\n");
2943 m.append(b"line 2 (still red, no reset)\n");
2944 m.append(b"line 3\n");
2945 let mut idx = LineIndex::new();
2946 idx.extend_to_end(&m);
2947 let state = reconstruct_render_state(&m, &idx, 2);
2948 assert_eq!(
2949 state.style.fg,
2950 Some(crate::ansi::Color::Ansi(1)),
2951 "red SGR from line 0 should persist to line 2"
2952 );
2953 }
2954
2955 #[test]
2956 fn reconstruct_respects_reset_between_lines() {
2957 let m = MockSource::new();
2958 m.append(b"\x1b[31mline 1\x1b[0m\n");
2959 m.append(b"line 2 (default)\n");
2960 let mut idx = LineIndex::new();
2961 idx.extend_to_end(&m);
2962 let state = reconstruct_render_state(&m, &idx, 1);
2963 assert_eq!(state.style.fg, None);
2964 }
2965
2966 #[test]
2967 fn reconstruct_caps_walkback_at_max_lines() {
2968 let m = MockSource::new();
2969 m.append(b"\x1b[31mvery early\n");
2970 for _ in 0..300 {
2971 m.append(b"line\n");
2972 }
2973 let mut idx = LineIndex::new();
2974 idx.extend_to_end(&m);
2975 let state = reconstruct_render_state(&m, &idx, 290);
2978 assert_eq!(state.style.fg, None);
2979 }
2980}