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 let body = self.body_rows() as usize;
890 if self.hide_mode() {
891 let pos = self
893 .visible_lines
894 .iter()
895 .position(|&l| l >= self.top_line)
896 .unwrap_or(self.visible_lines.len());
897 pos + body >= self.visible_lines.len()
898 } else {
899 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
903 }
904 }
905
906 fn gutter_width(&self, idx: &LineIndex) -> u16 {
908 if !self.show_line_numbers { return 0; }
909 let n = idx.line_count().max(1);
910 let digits = (n as f64).log10().floor() as u16 + 1;
911 digits + 1
912 }
913
914 fn render_opts(&self, gutter: u16) -> RenderOpts {
915 let mut o = self.opts.clone();
916 o.cols = self.cols.saturating_sub(gutter);
917 o.mode = self.ansi_mode;
918 o
919 }
920
921 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
922 if self.hex_mode {
923 return self.frame_hex(src);
924 }
925 let body_rows = self.body_rows() as usize;
926 idx.extend_to_line(self.top_line + body_rows + 1, src);
927
928 let gutter = self.gutter_width(idx);
929 let r_opts = self.render_opts(gutter);
930
931 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
935 reconstruct_render_state(src, idx, self.top_line)
936 } else {
937 crate::render::RenderState::default()
938 };
939 self.render_state = render_state.clone();
941 self.render_state_for = self.top_line;
942
943 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
944 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
945 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
946 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
947 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
948 let hide = self.hide_mode();
950 let total_lines = idx.line_count();
951
952 let header_rows = if !hide && !raw_passthrough {
959 self.header_lines.min(body_rows).min(total_lines)
960 } else {
961 0
962 };
963 if header_rows > 0 {
964 for hl in 0..header_rows {
965 let raw = src.bytes(idx.line_range(hl, src));
966 let display_bytes = if let Some(r) = self.display.as_ref() {
967 match r.render_line(&raw) {
968 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
969 None => raw.clone(),
970 }
971 } else {
972 raw.clone()
973 };
974 let rows = render_line(&display_bytes, &r_opts, None);
975 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
976 let mut v = Vec::with_capacity(self.cols as usize);
977 while v.len() < self.cols as usize { v.push(Cell::Empty); }
978 v
979 });
980 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
981 if gutter > 0 {
982 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
983 for c in label.chars() {
984 full.push(Cell::Char {
985 ch: c,
986 width: 1,
987 style: crate::ansi::Style::default(),
988 hyperlink: None,
989 });
990 }
991 }
992 full.append(&mut content_row);
993 body.push(full);
994 row_styles.push(RowStyle::Normal);
995 highlights.push(Vec::new());
996 raw_rows.push(None);
997 }
998 }
999
1000 let mut hide_pos = if hide {
1002 self.visible_lines
1003 .iter()
1004 .position(|&l| l >= self.top_line)
1005 .unwrap_or(self.visible_lines.len())
1006 } else {
1007 0
1008 };
1009 let mut line_n = if hide {
1010 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1011 } else {
1012 self.top_line.max(self.header_lines)
1015 };
1016 let mut skip = if hide || header_rows > 0 { 0 } else { self.top_row };
1017
1018 while body.len() < body_rows {
1019 if line_n >= total_lines {
1020 let mut row = Vec::with_capacity(self.cols as usize);
1021 if gutter > 0 {
1022 for _ in 0..gutter { row.push(Cell::Empty); }
1023 }
1024 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1025 body.push(row);
1026 row_styles.push(RowStyle::Normal);
1027 highlights.push(Vec::new());
1028 raw_rows.push(None);
1029 line_n += 1;
1030 continue;
1031 }
1032 let raw = src.bytes(idx.line_range(line_n, src));
1035 if self.squeeze_blanks && line_is_blank(&raw) {
1040 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1041 let prev = src.bytes(idx.line_range(p, src));
1042 line_is_blank(&prev)
1043 });
1044 if prev_blank {
1045 line_n += 1;
1046 continue;
1047 }
1048 }
1049 let display_bytes = if let Some(r) = self.display.as_ref() {
1050 match r.render_line(&raw) {
1051 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1052 None => raw.clone(),
1053 }
1054 } else {
1055 raw.clone()
1056 };
1057 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1058 Some(&mut render_state)
1059 } else {
1060 None
1061 };
1062 let rows = render_line(&display_bytes, &r_opts, state_arg);
1063 let style = if self.filter.is_some() || self.grep.is_some() {
1064 if self.dim_mode {
1065 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1066 } else {
1067 RowStyle::Normal
1069 }
1070 } else {
1071 RowStyle::Normal
1072 };
1073
1074 let mut first_emitted_for_this_line = true;
1075 for (i, mut content_row) in rows.into_iter().enumerate() {
1076 if i < skip { continue; }
1077 if body.len() >= body_rows { break; }
1078 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1079 if gutter > 0 {
1080 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1081 for c in label.chars() {
1082 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1083 }
1084 }
1085 full.append(&mut content_row);
1086 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1090 find_row_highlights(&full, &s.regex)
1091 } else {
1092 Vec::new()
1093 };
1094 body.push(full);
1095 row_styles.push(style);
1096 highlights.push(row_highlights);
1097 if raw_passthrough {
1098 if first_emitted_for_this_line {
1099 raw_rows.push(Some(raw.to_vec()));
1104 first_emitted_for_this_line = false;
1105 } else {
1106 raw_rows.push(Some(Vec::new()));
1107 }
1108 } else {
1109 raw_rows.push(None);
1110 }
1111 }
1112 skip = 0;
1113 if hide {
1115 hide_pos += 1;
1116 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1117 } else {
1118 line_n += 1;
1119 }
1120 }
1121
1122 self.render_state_for = usize::MAX;
1125
1126 let status = self.format_status(idx, src);
1127 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1128 }
1129
1130 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1131 if let Some(p) = self.prompt.as_ref() {
1132 let ctx = self.build_prompt_context(idx, src);
1133 return p.render(&ctx);
1134 }
1135 let body_rows = self.body_rows() as usize;
1136 let total = idx.line_count();
1137 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1140 let visible_total = self.visible_lines.len();
1141 let cur = self
1143 .visible_lines
1144 .iter()
1145 .position(|&l| l >= self.top_line)
1146 .unwrap_or(visible_total);
1147 let top = cur + 1;
1148 let bottom = (cur + body_rows).min(visible_total.max(1));
1149 let total_str = if src.is_complete() {
1150 format!("{visible_total}/{total}")
1151 } else {
1152 format!("{visible_total}/{total}+")
1153 };
1154 (top, bottom, visible_total, total_str)
1155 } else {
1156 let top = self.top_line + 1;
1157 let bottom = (self.top_line + body_rows).min(total.max(1));
1158 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1159 (top, bottom, total, total_str)
1160 };
1161 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1162 let bottom_line = self.bottom_visible_line(idx);
1166 let (line_prefix, records_block) = if idx.records_mode() {
1167 let line_total = idx.line_count();
1168 let rec_total = idx.record_count();
1169 let rec_block = if line_total == 0 || rec_total == 0 {
1170 format!("R0-0/{}", rec_total)
1171 } else {
1172 let rec_top = idx.line_to_record(self.top_line) + 1;
1173 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1174 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1175 (rec_top, rec_top)
1179 } else {
1180 (rec_top, rec_bottom)
1181 };
1182 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1183 };
1184 ("L", Some(rec_block))
1185 } else {
1186 ("", None)
1187 };
1188 let middle = match records_block {
1189 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1190 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1191 };
1192 let label_with_index = match self.file_index {
1193 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1194 None => self.source_label.clone(),
1195 };
1196 let mut s = format!("{} {}", label_with_index, middle);
1197 if !self.hide_mode() && self.top_row > 0 {
1202 let line_rows = if total > 0 {
1203 let bytes = self.line_display_bytes(src, idx, self.top_line);
1204 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1205 } else { 1 };
1206 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1207 }
1208 if let Some(f) = self.filter.as_ref() {
1209 s.push_str(&format!(" [{}]", f.format_name));
1210 }
1211 if self.grep.is_some() {
1212 s.push_str(" [grep]");
1213 }
1214 if self.filter.is_some() || self.grep.is_some() {
1215 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1216 }
1217 if let Some(sr) = self.search.as_ref() {
1218 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1219 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1220 }
1221 if let Some(label) = self.prettify_label.as_ref() {
1222 s.push_str(&format!(" [pretty:{label}]"));
1223 }
1224 if self.live_mode { s.push_str(" (L)"); }
1225 if self.follow_mode {
1226 if let Some((msg, _)) = self.status_flash.as_ref() {
1227 s.push_str(" ");
1228 s.push_str(msg);
1229 } else if self.is_idle() {
1230 s.push_str(" (F idle)");
1231 } else {
1232 s.push_str(" (F)");
1233 }
1234 }
1235 if let Some(msg) = self.preprocess_failure.as_ref() {
1236 let first_line = msg.lines().next().unwrap_or("");
1237 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1238 }
1239 let tag_suffix = match &self.tag_active {
1240 Some((name, cur, total)) if *total > 1 => {
1241 format!(" [tag: {name} ({cur}/{total})]")
1242 }
1243 _ => String::new(),
1244 };
1245 s.push_str(&tag_suffix);
1246 let used = s.chars().count();
1249 let hint = ":help";
1250 if (self.cols as usize) > used + 1 + hint.chars().count() {
1251 let pad = self.cols as usize - used - hint.chars().count();
1252 s.push_str(&" ".repeat(pad));
1253 s.push_str(hint);
1254 } else {
1255 s.push(' ');
1256 s.push_str(hint);
1257 }
1258 s
1259 }
1260
1261 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1262 use crate::prompt::PromptContext;
1263
1264 let body_rows = self.body_rows() as usize;
1265 let total = idx.line_count();
1266 let top = self.top_line + 1;
1267 let bottom = (self.top_line + body_rows).min(total.max(1));
1268 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1269 let bottom_line = self.bottom_visible_line(idx);
1270
1271 let records_mode = idx.records_mode();
1272 let (rec_top, rec_bottom, rec_total) = if records_mode {
1273 let rt = idx.line_to_record(self.top_line) + 1;
1274 let rb_raw = idx.line_to_record(bottom_line) + 1;
1275 let rb = if rb_raw < rt { rt } else { rb_raw };
1276 (rt, rb, idx.record_count())
1277 } else {
1278 (0, 0, 0)
1279 };
1280
1281 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1282 let line_rows = if total > 0 {
1283 let bytes = self.line_display_bytes(src, idx, self.top_line);
1284 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1285 } else { 1 };
1286 format!("+{}/{}", self.top_row, line_rows)
1287 } else {
1288 String::new()
1289 };
1290
1291 let format_tag = self.format_label.as_ref()
1292 .map(|n| format!(" [{}]", n))
1293 .unwrap_or_default();
1294 let filter_tag = self.filter.as_ref()
1295 .map(|f| format!(" [{}]", f.format_name))
1296 .unwrap_or_default();
1297 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1298 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
1299 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1300 } else {
1301 String::new()
1302 };
1303 let search_tag = self.search.as_ref()
1304 .map(|s| {
1305 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1306 format!(" [{}{}]", p, s.raw)
1307 })
1308 .unwrap_or_default();
1309 let pretty_tag = self.prettify_label.as_ref()
1310 .map(|l| format!(" [pretty:{l}]"))
1311 .unwrap_or_default();
1312 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1313 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1314 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1315 .map(|msg| {
1316 let first_line = msg.lines().next().unwrap_or("");
1317 format!(" [preprocess-failed: {}]", first_line)
1318 })
1319 .unwrap_or_default();
1320
1321 let file_index_tag = match self.file_index {
1322 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1323 None => String::new(),
1324 };
1325
1326 let tag_tag = match &self.tag_active {
1327 Some((name, cur, total)) if *total > 1 => {
1328 format!(" [tag: {name} ({cur}/{total})]")
1329 }
1330 _ => String::new(),
1331 };
1332
1333 PromptContext {
1334 label: self.source_label.clone(),
1335 top,
1336 bottom,
1337 total,
1338 pct: pct.min(100) as u8,
1339 rec_top,
1340 rec_bottom,
1341 rec_total,
1342 records_mode,
1343 wrap_offset,
1344 format_tag,
1345 filter_tag,
1346 grep_tag,
1347 hide_tag,
1348 search_tag,
1349 pretty_tag,
1350 live_tag,
1351 follow_tag,
1352 preprocess_failed_tag,
1353 file_index_tag,
1354 tag_tag,
1355 }
1356 }
1357
1358 fn frame_hex(&self, src: &dyn Source) -> Frame {
1359 use crate::hex::format_hex_row;
1360 use crate::render::{render_line, Cell, RenderOpts};
1361
1362 let body_rows = self.rows.saturating_sub(1) as usize;
1363 let total_bytes = src.len();
1364 let total_hex_rows = total_bytes.div_ceil(16);
1365
1366 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1367 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1368 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1369
1370 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
1371
1372 for row_idx in 0..body_rows {
1373 let hex_row = self.top_line + row_idx;
1374 if hex_row >= total_hex_rows {
1375 body.push(vec![Cell::Empty; self.cols as usize]);
1376 } else {
1377 let offset = hex_row * 16;
1378 let end = (offset + 16).min(total_bytes);
1379 let bytes_cow = src.bytes(offset..end);
1380 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1381 let rows = render_line(text.as_bytes(), &opts, None);
1382 body.push(rows.into_iter().next().unwrap_or_else(|| {
1383 vec![Cell::Empty; self.cols as usize]
1384 }));
1385 }
1386 row_styles.push(RowStyle::Normal);
1387 highlights.push(Vec::new());
1388 }
1389
1390 let status = self.format_status_hex(src);
1391 let raw_rows = vec![None; body.len()];
1392 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1393 }
1394
1395 fn format_status_hex(&self, src: &dyn Source) -> String {
1396 let total_bytes = src.len();
1397 let body_rows = self.rows.saturating_sub(1) as usize;
1398 let top_byte = self.top_line * 16;
1400 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1403 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1404 let label_with_index = match self.file_index {
1405 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1406 None => self.source_label.clone(),
1407 };
1408 let tag_suffix = match &self.tag_active {
1409 Some((name, cur, total)) if *total > 1 => {
1410 format!(" [tag: {name} ({cur}/{total})]")
1411 }
1412 _ => String::new(),
1413 };
1414 format!(
1415 "{} off {}-{}/{} {}% [hex]{}",
1416 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1417 )
1418 }
1419
1420 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1425 if delta == 0 { return; }
1426 if self.hide_mode() {
1427 self.scroll_lines(delta, src, idx);
1428 return;
1429 }
1430 if delta > 0 {
1431 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1432 let total = idx.line_count();
1433 if total == 0 { return; }
1434 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1435 self.top_line = target;
1436 self.top_row = 0;
1437 } else {
1438 let back = (-delta) as usize;
1439 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1444 let extra_back = back.saturating_sub(consumed_for_snap);
1445 self.top_line = self.top_line.saturating_sub(extra_back);
1446 self.top_row = 0;
1447 }
1448 }
1449
1450 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1451 if delta == 0 { return; }
1452 if self.hide_mode() {
1453 self.extend_visible_lines(idx, src);
1457 let total = self.visible_lines.len();
1458 if total == 0 {
1459 self.top_line = 0;
1460 self.top_row = 0;
1461 return;
1462 }
1463 let cur = self
1464 .visible_lines
1465 .iter()
1466 .position(|&l| l >= self.top_line)
1467 .unwrap_or(total);
1468 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1469 self.top_line = self.visible_lines[new];
1470 self.top_row = 0;
1471 return;
1472 }
1473 if delta > 0 {
1474 let mut remaining = delta as usize;
1475 while remaining > 0 {
1476 idx.extend_to_line(self.top_line + 1, src);
1477 let total = idx.line_count();
1478 if total == 0 { break; }
1479 let bytes = self.line_display_bytes(src, idx, self.top_line);
1480 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1481 if self.top_row + 1 < line_rows {
1482 self.top_row += 1;
1483 } else if self.top_line + 1 < total {
1484 self.top_row = 0;
1485 self.top_line += 1;
1486 } else {
1487 break;
1488 }
1489 remaining -= 1;
1490 }
1491 if idx.scanned_through() >= src.len() {
1496 let anchor = self.bottom_anchor(src, idx);
1497 if (self.top_line, self.top_row) > anchor {
1498 self.top_line = anchor.0;
1499 self.top_row = anchor.1;
1500 }
1501 }
1502 } else {
1503 let mut remaining = (-delta) as usize;
1504 while remaining > 0 {
1505 if self.top_row > 0 {
1506 self.top_row -= 1;
1507 } else if self.top_line > 0 {
1508 self.top_line -= 1;
1509 let bytes = self.line_display_bytes(src, idx, self.top_line);
1510 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1511 self.top_row = line_rows.saturating_sub(1);
1512 } else {
1513 break;
1514 }
1515 remaining -= 1;
1516 }
1517 }
1518 }
1519
1520 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1521 let n = self.page_size
1522 .map(|p| p as i64)
1523 .unwrap_or_else(|| self.body_rows() as i64);
1524 self.scroll_lines(n, src, idx);
1525 }
1526
1527 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1528 let n = self.page_size
1529 .map(|p| p as i64)
1530 .unwrap_or_else(|| self.body_rows() as i64);
1531 self.scroll_lines(-n, src, idx);
1532 }
1533
1534 pub fn half_page_down(&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 half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1540 let n = (self.body_rows() / 2).max(1) as i64;
1541 self.scroll_lines(-n, src, idx);
1542 }
1543
1544 pub fn goto_top(&mut self) {
1545 self.top_line = 0;
1546 self.top_row = 0;
1547 }
1548
1549 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1556 let body = self.body_rows() as usize;
1557 let total = idx.line_count();
1558 if total == 0 || body == 0 {
1559 return (0, 0);
1560 }
1561 let r_opts = self.render_opts(self.gutter_width(idx));
1562 let mut remaining = body;
1563 let mut line = total - 1;
1564 loop {
1565 let bytes = self.line_display_bytes(src, idx, line);
1566 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1567 if line_rows >= remaining {
1568 return (line, line_rows - remaining);
1569 }
1570 remaining -= line_rows;
1571 if line == 0 {
1572 return (0, 0);
1573 }
1574 line -= 1;
1575 }
1576 }
1577
1578 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1579 idx.extend_to_end(src);
1580 let body = self.body_rows() as usize;
1581 if self.hide_mode() {
1582 self.extend_visible_lines(idx, src);
1583 let total = self.visible_lines.len();
1584 let target_visible = total.saturating_sub(body);
1585 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1586 self.top_row = 0;
1587 } else {
1588 let (line, row) = self.bottom_anchor(src, idx);
1589 self.top_line = line;
1590 self.top_row = row;
1591 }
1592 }
1593
1594 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1596 idx.extend_to_line(n, src);
1597 let target = n.min(idx.line_count().saturating_sub(1));
1598 self.top_line = target;
1599 self.top_row = 0;
1600 }
1601
1602 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1604 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1608 idx.extend_to_end(src);
1609 }
1610 if idx.record_count() == 0 {
1611 return;
1612 }
1613 let target = n.min(idx.record_count().saturating_sub(1));
1614 let line_range = idx.record_line_range(target);
1615 self.top_line = line_range.start;
1616 self.top_row = 0;
1617 }
1618
1619 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1622 let p = p.min(100) as usize;
1623 let target_byte = src.len().saturating_mul(p) / 100;
1624 idx.extend_to_byte_for_query(src, target_byte);
1625 let line_n = idx.line_at_byte(target_byte)
1626 .or_else(|| {
1627 let lc = idx.line_count();
1629 if lc > 0 { Some(lc - 1) } else { None }
1630 })
1631 .unwrap_or(0);
1632 self.top_line = line_n;
1633 self.top_row = 0;
1634 }
1635
1636 pub fn top_line(&self) -> usize {
1638 self.top_line
1639 }
1640
1641 pub fn resize(&mut self, cols: u16, rows: u16) {
1642 self.cols = cols.max(1);
1643 self.rows = rows.max(2);
1644 self.opts.cols = self.cols;
1645 }
1646
1647 pub fn toggle_line_numbers(&mut self) {
1648 self.show_line_numbers = !self.show_line_numbers;
1649 }
1650
1651 pub fn toggle_chop(&mut self) {
1652 self.opts.wrap = !self.opts.wrap;
1653 }
1654
1655 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use crate::source::MockSource;
1665
1666 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1667 let m = MockSource::new();
1668 m.append(content);
1669 m.finish();
1670 let idx = LineIndex::new();
1671 (m, idx)
1672 }
1673
1674 #[test]
1675 fn frame_renders_body_height_rows() {
1676 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1677 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1679 assert_eq!(frame.body.len(), 4);
1680 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1681 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1682 }
1683
1684 #[test]
1685 fn scroll_down_advances_top_line() {
1686 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1689 let mut v = Viewport::new(10, 5, "test".into());
1690 v.scroll_lines(2, &m, &mut idx);
1691 assert_eq!(v.top_line, 2);
1692 assert_eq!(v.top_row, 0);
1693 }
1694
1695 #[test]
1696 fn scroll_up_clamps_at_zero() {
1697 let (m, mut idx) = setup(b"a\nb\nc\n");
1698 let mut v = Viewport::new(10, 5, "test".into());
1699 v.scroll_lines(-5, &m, &mut idx);
1700 assert_eq!(v.top_line, 0);
1701 assert_eq!(v.top_row, 0);
1702 }
1703
1704 #[test]
1705 fn scroll_down_clamps_at_last_line() {
1706 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1711 let mut v = Viewport::new(10, 5, "test".into());
1712 v.scroll_lines(50, &m, &mut idx);
1713 assert_eq!((v.top_line, v.top_row), (4, 0));
1714 assert!(v.is_at_bottom(&m, &idx));
1715 }
1716
1717 #[test]
1718 fn scroll_logical_lines_skips_wrap_rows() {
1719 let mut content = vec![b'X'; 500];
1721 content.push(b'\n');
1722 content.extend_from_slice(b"second\n");
1723 content.extend_from_slice(b"third\n");
1724 let (m, mut idx) = setup(&content);
1725 let mut v = Viewport::new(10, 8, "f".into());
1726 v.scroll_logical_lines(1, &m, &mut idx);
1727 assert_eq!((v.top_line, v.top_row), (1, 0));
1728 v.scroll_logical_lines(1, &m, &mut idx);
1729 assert_eq!((v.top_line, v.top_row), (2, 0));
1730 }
1731
1732 #[test]
1733 fn scroll_logical_lines_back_snaps_to_line_start() {
1734 let mut content = vec![b'A'; 50];
1739 content.push(b'\n');
1740 content.extend_from_slice(&[b'B'; 50]);
1741 content.push(b'\n');
1742 content.extend_from_slice(&[b'C'; 50]);
1743 content.push(b'\n');
1744 let (m, mut idx) = setup(&content);
1745 let mut v = Viewport::new(10, 8, "f".into());
1746 v.scroll_lines(7, &m, &mut idx);
1747 assert_eq!(v.top_line, 1, "should be on line 1");
1748 assert!(v.top_row > 0, "should be inside line 1's wraps");
1749 v.scroll_logical_lines(-1, &m, &mut idx);
1750 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1751 v.scroll_logical_lines(-1, &m, &mut idx);
1752 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1753 }
1754
1755 #[test]
1756 fn scroll_down_walks_wraps_of_last_line() {
1757 let mut content = b"first\n".to_vec();
1761 content.extend_from_slice(&[b'X'; 60]);
1762 content.push(b'\n');
1763 let (m, mut idx) = setup(&content);
1764 let mut v = Viewport::new(10, 5, "f".into());
1765 v.scroll_lines(1, &m, &mut idx);
1766 assert_eq!((v.top_line, v.top_row), (1, 0));
1767 v.scroll_lines(1, &m, &mut idx);
1768 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1769 v.scroll_lines(1, &m, &mut idx);
1770 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
1771 v.scroll_lines(5, &m, &mut idx);
1773 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
1774 }
1775
1776 #[test]
1777 fn scroll_down_walks_wrap_rows_within_long_line() {
1778 let mut content = vec![b'X'; 30];
1782 content.push(b'\n');
1783 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
1784 let (m, mut idx) = setup(&content);
1785 let mut v = Viewport::new(10, 5, "f".into());
1786 v.scroll_lines(1, &m, &mut idx);
1787 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1788 v.scroll_lines(1, &m, &mut idx);
1789 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1790 v.scroll_lines(1, &m, &mut idx);
1791 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1792 }
1793
1794 #[test]
1795 fn status_line_shows_range_and_pct() {
1796 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1797 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1799 assert!(frame.status.starts_with("f 1-4/10"));
1800 }
1801
1802 #[test]
1803 fn page_down_advances_by_body_rows() {
1804 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1805 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1807 assert_eq!(v.top_line, 4);
1808 }
1809
1810 #[test]
1811 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1812 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1813 let mut v = Viewport::new(10, 5, "f".into());
1814 v.page_down(&m, &mut idx);
1815 v.page_up(&m, &mut idx);
1816 assert_eq!(v.top_line, 0);
1817 assert_eq!(v.top_row, 0);
1818 }
1819
1820 #[test]
1821 fn half_page_down_advances_by_half_body() {
1822 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
1825 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1827 assert_eq!(v.top_line, 3);
1828 }
1829
1830 #[test]
1831 fn goto_top_resets_position() {
1832 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1833 let mut v = Viewport::new(10, 5, "f".into());
1834 v.scroll_lines(2, &m, &mut idx);
1835 v.goto_top();
1836 assert_eq!(v.top_line, 0);
1837 assert_eq!(v.top_row, 0);
1838 }
1839
1840 #[test]
1841 fn goto_bottom_scrolls_to_last_page() {
1842 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1843 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1845 assert_eq!(v.top_line, 6);
1847 }
1848
1849 #[test]
1850 fn goto_line_positions_top_line() {
1851 let m = MockSource::new();
1852 m.append(b"a\nb\nc\nd\ne\n");
1853 let mut idx = LineIndex::new();
1854 idx.extend_to_end(&m);
1855 let mut v = Viewport::new(20, 5, "f".into());
1856 v.goto_line(3, &m, &mut idx);
1857 assert_eq!(v.top_line(), 3);
1858 }
1859
1860 #[test]
1861 fn goto_line_clamps_to_last_line() {
1862 let m = MockSource::new();
1863 m.append(b"a\nb\n");
1864 let mut idx = LineIndex::new();
1865 idx.extend_to_end(&m);
1866 let mut v = Viewport::new(20, 5, "f".into());
1867 v.goto_line(999, &m, &mut idx);
1868 assert_eq!(v.top_line(), 1);
1869 }
1870
1871 #[test]
1872 fn goto_record_positions_at_record_start_line() {
1873 let m = MockSource::new();
1874 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1875 let mut idx = LineIndex::new();
1876 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1877 idx.extend_to_end(&m);
1878 let mut v = Viewport::new(20, 5, "f".into());
1879 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1881 }
1882
1883 #[test]
1884 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1885 let m = MockSource::new();
1886 m.append(b"a\nb\nc\n");
1887 let mut idx = LineIndex::new();
1888 idx.extend_to_end(&m);
1889 let mut v = Viewport::new(20, 5, "f".into());
1890 v.goto_record(2, &m, &mut idx);
1891 assert_eq!(v.top_line(), 2);
1892 }
1893
1894 #[test]
1895 fn goto_percent_50_lands_in_middle() {
1896 let m = MockSource::new();
1897 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1899 idx.extend_to_end(&m);
1900 let mut v = Viewport::new(20, 5, "f".into());
1901 v.goto_percent(50, &m, &mut idx);
1902 assert_eq!(v.top_line(), 2); }
1904
1905 #[test]
1906 fn goto_percent_100_lands_at_last_line() {
1907 let m = MockSource::new();
1908 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1910 idx.extend_to_end(&m);
1911 let mut v = Viewport::new(20, 5, "f".into());
1912 v.goto_percent(100, &m, &mut idx);
1913 assert_eq!(v.top_line(), 2);
1914 }
1915
1916 #[test]
1917 fn goto_percent_0_lands_at_first_line() {
1918 let m = MockSource::new();
1919 m.append(b"a\nb\nc\n");
1920 let mut idx = LineIndex::new();
1921 idx.extend_to_end(&m);
1922 let mut v = Viewport::new(20, 5, "f".into());
1923 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1925 v.goto_percent(0, &m, &mut idx);
1926 assert_eq!(v.top_line(), 0);
1927 }
1928
1929 #[test]
1930 fn resize_updates_dimensions_and_render_opts() {
1931 let (m, mut idx) = setup(b"1\n2\n");
1932 let mut v = Viewport::new(10, 5, "f".into());
1933 v.resize(40, 12);
1934 assert_eq!(v.cols, 40);
1935 assert_eq!(v.rows, 12);
1936 assert_eq!(v.opts.cols, 40);
1937 let _ = v.frame(&m, &mut idx);
1938 }
1939
1940 #[test]
1941 fn toggle_line_numbers_changes_gutter() {
1942 let (m, mut idx) = setup(b"a\nb\nc\n");
1943 let mut v = Viewport::new(10, 5, "f".into());
1944 let frame_off = v.frame(&m, &mut idx);
1945 v.toggle_line_numbers();
1946 let frame_on = v.frame(&m, &mut idx);
1947 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1949 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1950 }
1951
1952 #[test]
1953 fn toggle_chop_changes_wrap_mode() {
1954 let (m, mut idx) = setup(b"abcdefghij\n");
1955 let mut v = Viewport::new(4, 5, "f".into());
1956 v.toggle_chop();
1957 let frame = v.frame(&m, &mut idx);
1958 assert_eq!(frame.body[0][..4],
1961 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1962 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1963 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1964 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1965 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1967 }
1968
1969 #[test]
1972 fn is_at_bottom_initially_only_when_source_fits() {
1973 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1976 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
1977 }
1978
1979 #[test]
1980 fn is_at_bottom_false_when_top_and_more_lines_below() {
1981 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);
1984 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
1985 }
1986
1987 #[test]
1988 fn is_at_bottom_true_after_goto_bottom() {
1989 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1990 let mut v = Viewport::new(10, 5, "f".into());
1991 v.goto_bottom(&m, &mut idx);
1992 assert!(v.is_at_bottom(&m, &idx));
1993 }
1994
1995 #[test]
1996 fn status_shows_follow_suffix_when_follow_mode_on() {
1997 let (m, mut idx) = setup(b"a\nb\n");
1998 let mut v = Viewport::new(20, 5, "f".into());
1999 let frame_off = v.frame(&m, &mut idx);
2000 assert!(!frame_off.status.contains("(F)"));
2001 v.set_follow_mode(true);
2002 let frame_on = v.frame(&m, &mut idx);
2003 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2004 }
2005
2006 #[test]
2007 fn toggle_follow_flips_state() {
2008 let mut v = Viewport::new(10, 5, "f".into());
2009 assert!(!v.follow_mode());
2010 v.toggle_follow();
2011 assert!(v.follow_mode());
2012 v.toggle_follow();
2013 assert!(!v.follow_mode());
2014 }
2015
2016 #[test]
2017 fn idle_indicator_kicks_in_at_threshold() {
2018 let (m, mut idx) = setup(b"a\nb\n");
2019 let mut v = Viewport::new(20, 5, "f".into());
2020 v.set_follow_mode(true);
2021 for _ in 0..19 { v.tick_idle(); }
2023 let f1 = v.frame(&m, &mut idx);
2024 assert!(f1.status.contains("(F)"));
2025 assert!(!f1.status.contains("idle"));
2026 v.tick_idle();
2028 let f2 = v.frame(&m, &mut idx);
2029 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2030 }
2031
2032 #[test]
2033 fn note_growth_resets_idle() {
2034 let (m, mut idx) = setup(b"a\nb\n");
2035 let mut v = Viewport::new(20, 5, "f".into());
2036 v.set_follow_mode(true);
2037 for _ in 0..25 { v.tick_idle(); }
2038 assert!(v.is_idle());
2039 v.note_growth();
2040 assert!(!v.is_idle());
2041 let f = v.frame(&m, &mut idx);
2042 assert!(!f.status.contains("idle"));
2043 }
2044
2045 #[test]
2046 fn qae_off_never_quits_even_at_bottom() {
2047 let (m, mut idx) = setup(b"a\n");
2048 let mut v = Viewport::new(20, 5, "f".into());
2049 v.set_quit_at_eof(QuitAtEof::Off);
2050 v.goto_bottom(&m, &mut idx);
2051 assert!(!v.note_motion_for_eof(true, &m, &idx));
2052 }
2053
2054 #[test]
2055 fn qae_first_quits_immediately_at_bottom() {
2056 let (m, mut idx) = setup(b"a\n");
2057 let mut v = Viewport::new(20, 5, "f".into());
2058 v.set_quit_at_eof(QuitAtEof::First);
2059 v.goto_bottom(&m, &mut idx);
2060 assert!(v.note_motion_for_eof(true, &m, &idx));
2061 }
2062
2063 #[test]
2064 fn qae_first_only_quits_at_eof_not_mid_file() {
2065 let mut content = Vec::new();
2066 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2067 let (m, mut idx) = setup(&content);
2068 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2070 v.set_quit_at_eof(QuitAtEof::First);
2071 assert!(!v.is_at_bottom(&m, &idx));
2073 assert!(!v.note_motion_for_eof(true, &m, &idx));
2074 }
2075
2076 #[test]
2077 fn qae_second_quits_on_second_hit() {
2078 let (m, mut idx) = setup(b"a\n");
2079 let mut v = Viewport::new(20, 5, "f".into());
2080 v.set_quit_at_eof(QuitAtEof::Second);
2081 v.goto_bottom(&m, &mut idx);
2082 assert!(!v.note_motion_for_eof(true, &m, &idx));
2084 assert!(v.note_motion_for_eof(true, &m, &idx));
2086 }
2087
2088 #[test]
2089 fn squeeze_collapses_consecutive_blanks() {
2090 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2092 let mut v = Viewport::new(10, 8, "f".into());
2093 v.set_squeeze_blanks(true);
2094 let f = v.frame(&m, &mut idx);
2095 let stringify = |row: &Vec<Cell>| -> String {
2097 row.iter().filter_map(|c| match c {
2098 Cell::Char { ch, .. } => Some(*ch),
2099 _ => None,
2100 }).collect::<String>().trim().to_string()
2101 };
2102 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2103 assert_eq!(&rows[0], "a");
2105 assert_eq!(&rows[1], "");
2106 assert_eq!(&rows[2], "b");
2107 }
2108
2109 #[test]
2110 fn header_pins_top_rows_when_scrolling() {
2111 let mut content = Vec::new();
2113 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2114 let (m, mut idx) = setup(&content);
2115 let mut v = Viewport::new(20, 6, "f".into());
2116 v.set_header(2, 0);
2117 v.scroll_lines(5, &m, &mut idx);
2121 let f = v.frame(&m, &mut idx);
2122 let chs = |row: &Vec<Cell>| -> String {
2123 row.iter().filter_map(|c| match c {
2124 Cell::Char { ch, .. } => Some(*ch),
2125 _ => None,
2126 }).collect::<String>().trim().to_string()
2127 };
2128 assert_eq!(&chs(&f.body[0]), "line0");
2130 assert_eq!(&chs(&f.body[1]), "line1");
2131 assert_eq!(&chs(&f.body[2]), "line7");
2133 }
2134
2135 #[test]
2136 fn page_size_when_set_overrides_body_rows() {
2137 let mut content = Vec::new();
2138 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2139 let (m, mut idx) = setup(&content);
2140 let mut v = Viewport::new(20, 10, "f".into());
2141 v.set_page_size(Some(3));
2142 let before = v.top_line();
2143 v.page_down(&m, &mut idx);
2144 assert_eq!(v.top_line(), before + 3);
2145 v.page_up(&m, &mut idx);
2146 assert_eq!(v.top_line(), before);
2147 }
2148
2149 #[test]
2150 fn page_size_unset_uses_body_rows() {
2151 let mut content = Vec::new();
2152 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2153 let (m, mut idx) = setup(&content);
2154 let mut v = Viewport::new(20, 10, "f".into());
2155 v.page_down(&m, &mut idx);
2157 assert_eq!(v.top_line(), 9);
2158 }
2159
2160 #[test]
2161 fn header_zero_lines_renders_like_no_header() {
2162 let mut content = Vec::new();
2163 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2164 let (m, mut idx) = setup(&content);
2165 let mut v = Viewport::new(20, 6, "f".into());
2166 v.set_header(0, 0);
2167 let f = v.frame(&m, &mut idx);
2168 let chs = |row: &Vec<Cell>| -> String {
2169 row.iter().filter_map(|c| match c {
2170 Cell::Char { ch, .. } => Some(*ch),
2171 _ => None,
2172 }).collect::<String>().trim().to_string()
2173 };
2174 assert_eq!(&chs(&f.body[0]), "line0");
2175 assert_eq!(&chs(&f.body[1]), "line1");
2176 }
2177
2178 #[test]
2179 fn squeeze_off_preserves_blanks() {
2180 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2181 let mut v = Viewport::new(10, 8, "f".into());
2182 let f = v.frame(&m, &mut idx);
2184 let stringify = |row: &Vec<Cell>| -> String {
2185 row.iter().filter_map(|c| match c {
2186 Cell::Char { ch, .. } => Some(*ch),
2187 _ => None,
2188 }).collect::<String>().trim().to_string()
2189 };
2190 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2191 assert_eq!(&rows[0], "a");
2193 assert_eq!(&rows[1], "");
2194 assert_eq!(&rows[2], "");
2195 assert_eq!(&rows[3], "");
2196 assert_eq!(&rows[4], "b");
2197 }
2198
2199 #[test]
2200 fn qae_second_resets_on_backward_motion() {
2201 let (m, mut idx) = setup(b"a\n");
2202 let mut v = Viewport::new(20, 5, "f".into());
2203 v.set_quit_at_eof(QuitAtEof::Second);
2204 v.goto_bottom(&m, &mut idx);
2205 assert!(!v.note_motion_for_eof(true, &m, &idx));
2206 v.note_motion_for_eof(false, &m, &idx);
2208 assert!(!v.note_motion_for_eof(true, &m, &idx));
2210 assert!(v.note_motion_for_eof(true, &m, &idx));
2212 }
2213
2214 #[test]
2215 fn flash_message_overrides_follow_suffix() {
2216 let (m, mut idx) = setup(b"a\nb\n");
2217 let mut v = Viewport::new(40, 5, "f".into());
2218 v.set_follow_mode(true);
2219 v.flash("(F reopened)", 3);
2220 let f = v.frame(&m, &mut idx);
2221 assert!(f.status.contains("(F reopened)"), "{}", f.status);
2222 assert!(!f.status.contains("(F idle)"));
2223 }
2224
2225 #[test]
2226 fn flash_countdown_clears() {
2227 let mut v = Viewport::new(10, 5, "f".into());
2228 v.flash("hello", 2);
2229 v.tick_flash();
2230 assert!(v.status_flash.is_some());
2231 v.tick_flash();
2232 assert!(v.status_flash.is_none());
2233 }
2234
2235 #[test]
2236 fn suspend_follow_if_off_is_noop() {
2237 let mut v = Viewport::new(10, 5, "f".into());
2238 v.set_follow_mode(true);
2239 v.suspend_follow_if(false);
2240 assert!(v.follow_mode());
2241 }
2242
2243 #[test]
2244 fn suspend_follow_if_on_flips_off() {
2245 let mut v = Viewport::new(10, 5, "f".into());
2246 v.set_follow_mode(true);
2247 v.suspend_follow_if(true);
2248 assert!(!v.follow_mode());
2249 }
2250
2251 #[test]
2252 fn case_mode_sensitive_returns_pattern_unchanged() {
2253 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2254 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2255 }
2256
2257 #[test]
2258 fn case_mode_insensitive_prepends_i_flag() {
2259 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2260 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2261 }
2262
2263 #[test]
2264 fn case_mode_smart_lowercase_is_insensitive() {
2265 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2266 }
2267
2268 #[test]
2269 fn case_mode_smart_with_uppercase_is_sensitive() {
2270 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2271 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2272 }
2273
2274 #[test]
2275 fn set_case_mode_recompiles_active_search() {
2276 let (m, mut idx) = setup(b"hello WORLD\n");
2277 let mut v = Viewport::new(40, 5, "f".into());
2278 v.set_search("world".into(), SearchDirection::Forward).unwrap();
2279 assert!(!v.search_repeat(&m, &mut idx, false));
2281 v.set_case_mode(CaseMode::Insensitive);
2283 assert!(v.search_repeat(&m, &mut idx, false));
2284 }
2285
2286 #[test]
2287 fn status_shows_prettify_label_when_set() {
2288 let (m, mut idx) = setup(b"a\n");
2289 let mut v = Viewport::new(40, 5, "f".into());
2290 let frame_off = v.frame(&m, &mut idx);
2291 assert!(!frame_off.status.contains("[pretty"));
2292 v.set_prettify_label(Some("json".into()));
2293 let frame_on = v.frame(&m, &mut idx);
2294 assert!(frame_on.status.contains("[pretty:json]"),
2295 "expected [pretty:json] in status, got: {}", frame_on.status);
2296 v.set_prettify_label(Some("json:err".into()));
2297 let frame_err = v.frame(&m, &mut idx);
2298 assert!(frame_err.status.contains("[pretty:json:err]"),
2299 "expected [pretty:json:err] in status, got: {}", frame_err.status);
2300 }
2301
2302 #[test]
2303 fn status_shows_l_suffix_when_live_mode_on() {
2304 let (m, mut idx) = setup(b"a\nb\n");
2305 let mut v = Viewport::new(20, 5, "f".into());
2306 let frame_off = v.frame(&m, &mut idx);
2307 assert!(!frame_off.status.contains("(L)"));
2308 v.set_live_mode(true);
2309 let frame_on = v.frame(&m, &mut idx);
2310 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2311 }
2312
2313 #[test]
2314 fn clamp_top_line_pulls_back_when_total_shrinks() {
2315 let mut v = Viewport::new(20, 5, "f".into());
2316 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
2325 let (m, mut idx) = setup(b"only\n");
2327 let _ = v.frame(&m, &mut idx);
2328 }
2329
2330 fn simulate_growth_tick(
2333 v: &mut Viewport,
2334 src: &MockSource,
2335 idx: &mut LineIndex,
2336 ) {
2337 if !v.follow_mode() { return; }
2338 let was_at_bottom = v.is_at_bottom(src, idx);
2339 let lines_before = idx.line_count();
2340 idx.notice_new_bytes(src);
2341 if idx.line_count() != lines_before && was_at_bottom {
2342 v.goto_bottom(src, idx);
2343 }
2344 }
2345
2346 #[test]
2347 fn auto_scroll_engages_when_at_bottom() {
2348 let m = MockSource::new();
2349 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
2351 let mut v = Viewport::new(10, 5, "f".into());
2352 v.set_follow_mode(true);
2353 idx.extend_to_end(&m);
2354 assert!(v.is_at_bottom(&m, &idx));
2355 let top_before = {
2356 let f = v.frame(&m, &mut idx);
2357 f.status.clone() };
2359 let _ = top_before;
2360 m.append(b"5\n6\n7\n8\n");
2362 simulate_growth_tick(&mut v, &m, &mut idx);
2363 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
2365 let frame = v.frame(&m, &mut idx);
2366 let last_row = &frame.body[frame.body.len() - 1];
2369 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2370 }
2371
2372 #[test]
2373 fn auto_scroll_suppressed_when_scrolled_up() {
2374 let m = MockSource::new();
2375 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
2377 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
2379 idx.extend_to_end(&m);
2380 v.goto_bottom(&m, &mut idx);
2381 v.scroll_lines(-2, &m, &mut idx);
2383 assert!(!v.is_at_bottom(&m, &idx));
2384 let frame_before = v.frame(&m, &mut idx);
2385 let top_first_cell_before = frame_before.body[0][0].clone();
2386 m.append(b"9\n10\n");
2388 simulate_growth_tick(&mut v, &m, &mut idx);
2389 let frame_after = v.frame(&m, &mut idx);
2391 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2392 }
2393
2394 #[test]
2397 fn set_search_compiles_regex() {
2398 let mut v = Viewport::new(10, 5, "f".into());
2399 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2400 assert!(v.search_active());
2401 }
2402
2403 #[test]
2404 fn set_search_rejects_bad_regex() {
2405 let mut v = Viewport::new(10, 5, "f".into());
2406 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2407 assert!(!err.is_empty());
2408 assert!(!v.search_active(), "no search should be set on error");
2409 }
2410
2411 #[test]
2412 fn search_step_forward_finds_match_after_top() {
2413 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2414 let mut v = Viewport::new(20, 5, "f".into());
2415 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2416 let found = v.search_repeat(&m, &mut idx, false);
2417 assert!(found);
2418 assert_eq!(v.top_line, 2);
2420 }
2421
2422 #[test]
2423 fn search_step_backward_finds_match_before_top() {
2424 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2425 let mut v = Viewport::new(20, 5, "f".into());
2426 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2428 let found = v.search_repeat(&m, &mut idx, false);
2429 assert!(found);
2430 assert_eq!(v.top_line, 0);
2431 }
2432
2433 #[test]
2434 fn search_wraps_at_end() {
2435 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2436 let mut v = Viewport::new(20, 5, "f".into());
2437 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2439 let found = v.search_repeat(&m, &mut idx, false);
2440 assert!(found, "search should wrap forward past EOF");
2441 assert_eq!(v.top_line, 0);
2442 }
2443
2444 #[test]
2445 fn search_no_match_returns_false_and_does_not_move() {
2446 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2447 let mut v = Viewport::new(20, 5, "f".into());
2448 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2449 let found = v.search_repeat(&m, &mut idx, false);
2450 assert!(!found);
2451 assert_eq!(v.top_line, 0);
2452 }
2453
2454 #[test]
2455 fn frame_records_highlight_ranges_for_matches() {
2456 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2457 let mut v = Viewport::new(20, 5, "f".into());
2458 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2459 let frame = v.frame(&m, &mut idx);
2460 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2462 assert!(frame.highlights[0].is_empty());
2463 assert!(frame.highlights[1].is_empty());
2464 assert_eq!(frame.highlights[2], vec![0..5]);
2465 assert!(frame.highlights[3].is_empty());
2466 }
2467
2468 #[test]
2469 fn frame_highlights_substring_inside_a_row() {
2470 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2471 let mut v = Viewport::new(40, 5, "f".into());
2472 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2473 let frame = v.frame(&m, &mut idx);
2474 assert_eq!(frame.highlights[0], vec![18..22]);
2476 assert!(frame.highlights[1].is_empty());
2477 }
2478
2479 #[test]
2480 fn search_highlight_with_filter_dim_keeps_row_dim() {
2481 let (m, mut idx) = setup(b"alpha\nbeta\n");
2484 let mut v = Viewport::new(20, 5, "f".into());
2485 let fmt = crate::format::LogFormat::compile(
2486 "simple",
2487 r"^(?P<line>.+)$",
2488 )
2489 .unwrap();
2490 let f = crate::filter::CompiledFilter::compile(
2491 &fmt,
2492 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2493 CaseMode::Sensitive,
2494 )
2495 .unwrap();
2496 v.set_filter(Some(f));
2497 v.set_dim_mode(true);
2498 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2499 let frame = v.frame(&m, &mut idx);
2500 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2501 assert_eq!(frame.row_styles[1], RowStyle::Dim);
2502 assert_eq!(frame.highlights[1], vec![0..4]);
2503 }
2504
2505 #[test]
2506 fn grep_only_hides_non_matching_lines() {
2507 use crate::grep::GrepPredicate;
2508 let src = crate::source::MockSource::new();
2509 src.append(b"keep this error\n");
2510 src.append(b"drop this one\n");
2511 src.append(b"another error line\n");
2512 src.finish();
2513 let mut idx = crate::line_index::LineIndex::new();
2514 idx.extend_to_end(&src);
2515
2516 let mut v = Viewport::new(40, 5, "test".into());
2517 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2518 v.extend_visible_lines(&idx, &src);
2519
2520 let frame = v.frame(&src, &mut idx);
2522 let body_text: Vec<String> = frame.body.iter()
2523 .map(|row| row.iter().filter_map(|c| match c {
2524 crate::render::Cell::Char { ch, .. } => Some(*ch),
2525 _ => None,
2526 }).collect())
2527 .collect();
2528 assert!(body_text[0].contains("keep this error"));
2529 assert!(body_text[1].contains("another error line"));
2530 assert!(frame.status.contains("[grep]"));
2531 }
2532
2533 #[test]
2534 fn filter_and_grep_combine_with_and() {
2535 use crate::grep::GrepPredicate;
2536 let fmt = crate::format::LogFormat::compile(
2537 "simple",
2538 r"^(?P<level>\w+) (?P<msg>.+)$",
2539 ).unwrap();
2540 let f = crate::filter::CompiledFilter::compile(
2541 &fmt,
2542 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2543 CaseMode::Sensitive,
2544 ).unwrap();
2545 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2546
2547 let src = crate::source::MockSource::new();
2548 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();
2553 let mut idx = crate::line_index::LineIndex::new();
2554 idx.extend_to_end(&src);
2555
2556 let mut v = Viewport::new(80, 5, "test".into());
2557 v.set_filter(Some(f));
2558 v.set_grep(Some(g));
2559 v.extend_visible_lines(&idx, &src);
2560 assert_eq!(v.visible_lines(), &[0usize]);
2561 }
2562
2563 #[test]
2564 fn search_status_shows_pattern() {
2565 let (m, mut idx) = setup(b"x\n");
2566 let mut v = Viewport::new(20, 5, "f".into());
2567 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2568 let frame = v.frame(&m, &mut idx);
2569 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
2570 }
2571
2572 #[test]
2573 fn repeat_search_after_first_match_advances() {
2574 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
2575 let mut v = Viewport::new(40, 5, "f".into());
2576 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2577 assert!(v.search_repeat(&m, &mut idx, false));
2578 assert_eq!(v.top_line, 1, "first foo");
2579 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2580 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
2581 assert_eq!(v.top_line, 3, "should advance to next foo");
2582 }
2583
2584 #[test]
2585 fn auto_scroll_paused_when_follow_off() {
2586 let m = MockSource::new();
2587 m.append(b"1\n2\n3\n4\n");
2588 let mut idx = LineIndex::new();
2589 let mut v = Viewport::new(10, 5, "f".into());
2590 idx.extend_to_end(&m);
2592 let frame_before = v.frame(&m, &mut idx);
2593 let top_first_cell = frame_before.body[0][0].clone();
2594 m.append(b"5\n6\n7\n8\n");
2595 simulate_growth_tick(&mut v, &m, &mut idx);
2596 let frame_after = v.frame(&m, &mut idx);
2597 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
2598 }
2599
2600 #[test]
2603 fn search_jumps_to_next_matching_record() {
2604 let m = MockSource::new();
2605 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
2606 let mut idx = LineIndex::new();
2607 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2608 idx.extend_to_end(&m);
2609 let mut v = Viewport::new(40, 10, "f".into());
2610 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
2611 let hit = v.search_repeat(&m, &mut idx, false);
2612 assert!(hit, "should find 'charlie' in record 2");
2613 assert_eq!(v.top_line(), 3); }
2615
2616 #[test]
2617 fn search_finds_cross_line_match_in_record_with_s_flag() {
2618 let m = MockSource::new();
2619 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
2620 let mut idx = LineIndex::new();
2621 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2622 idx.extend_to_end(&m);
2623 let mut v = Viewport::new(40, 10, "f".into());
2624 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
2625 let hit = v.search_repeat(&m, &mut idx, false);
2626 assert!(hit, "should match across \\n inside record 0 with (?s)");
2627 assert_eq!(v.top_line(), 0);
2628 }
2629
2630 #[test]
2631 fn search_repeat_with_no_match_returns_false() {
2632 let m = MockSource::new();
2633 m.append(b"[1] alpha\n[2] bravo\n");
2634 let mut idx = LineIndex::new();
2635 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2636 idx.extend_to_end(&m);
2637 let mut v = Viewport::new(40, 10, "f".into());
2638 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
2639 let hit = v.search_repeat(&m, &mut idx, false);
2640 assert!(!hit);
2641 }
2642
2643 #[test]
2646 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
2647 let m = MockSource::new();
2650 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
2651 let mut idx = LineIndex::new();
2652 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2653 idx.extend_to_end(&m);
2654 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2655 let mut v = Viewport::new(40, 10, "f".into());
2656 v.set_grep(Some(grep));
2657 v.extend_visible_lines(&idx, &m);
2658 assert_eq!(v.visible_lines(), &[0usize, 1]);
2661 }
2662
2663 #[test]
2664 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2665 let m = MockSource::new();
2671 m.append(
2672 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
2673 );
2674 let mut idx = LineIndex::new();
2675 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2676 idx.extend_to_end(&m);
2677 let fmt = crate::format::LogFormat::compile(
2678 "rec",
2679 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2680 )
2681 .unwrap();
2682 let f = crate::filter::CompiledFilter::compile(
2683 &fmt,
2684 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2685 CaseMode::Sensitive,
2686 )
2687 .unwrap();
2688 let mut v = Viewport::new(40, 10, "f".into());
2689 v.set_filter(Some(f));
2690 v.extend_visible_lines(&idx, &m);
2691 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2693 }
2694
2695 #[test]
2696 fn grep_matches_across_record_newlines_in_records_mode() {
2697 let m = MockSource::new();
2699 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2700 let mut idx = LineIndex::new();
2701 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2702 idx.extend_to_end(&m);
2703 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2704 let mut v = Viewport::new(40, 10, "f".into());
2705 v.set_grep(Some(grep));
2706 v.extend_visible_lines(&idx, &m);
2707 assert_eq!(v.visible_lines(), &[0usize, 1]);
2709 }
2710
2711 #[test]
2712 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2713 let m = MockSource::new();
2716 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2717 let mut idx = LineIndex::new();
2718 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2719 idx.extend_to_end(&m);
2720 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
2721 let mut v = Viewport::new(40, 10, "f".into());
2722 v.set_grep(Some(grep));
2723 v.set_dim_mode(true);
2724 v.extend_visible_lines(&idx, &m);
2725 assert_eq!(v.visible_lines(), &[] as &[usize]);
2727 assert!(!v.should_dim_line(0, &idx, &m));
2729 assert!(!v.should_dim_line(1, &idx, &m));
2730 assert!(v.should_dim_line(2, &idx, &m));
2732 assert!(v.should_dim_line(3, &idx, &m));
2733 }
2734
2735 #[test]
2736 fn status_unchanged_when_records_inactive() {
2737 let (m, mut idx) = setup(b"a\nb\nc\n");
2738 let mut v = Viewport::new(20, 5, "f".into());
2739 let frame = v.frame(&m, &mut idx);
2740 let status = &frame.status;
2741 assert!(status.contains("1-3/3"), "got: {status}");
2743 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2744 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2745 }
2746
2747 #[test]
2748 fn status_r_block_uses_real_lines_in_hide_mode() {
2749 let m = MockSource::new();
2758 let mut buf = Vec::new();
2761 for n in 0..10 {
2762 let kind = if n >= 8 { "B" } else { "A" };
2763 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
2764 }
2765 m.append(&buf);
2766 m.finish();
2767
2768 let mut idx = LineIndex::new();
2769 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2770 idx.extend_to_end(&m);
2771
2772 let fmt = crate::format::LogFormat::compile(
2773 "rec",
2774 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2775 )
2776 .unwrap();
2777 let f = crate::filter::CompiledFilter::compile(
2778 &fmt,
2779 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2780 CaseMode::Sensitive,
2781 )
2782 .unwrap();
2783
2784 let mut v = Viewport::new(80, 5, "f".into());
2787 v.set_filter(Some(f));
2788 v.extend_visible_lines(&idx, &m);
2789
2790 v.goto_record(8, &m, &mut idx);
2792
2793 let frame = v.frame(&m, &mut idx);
2794 assert!(
2796 frame.status.contains("R9-10/10"),
2797 "expected R9-10/10 in status, got: {}",
2798 frame.status,
2799 );
2800 }
2801
2802 #[test]
2803 fn status_dual_readout_when_records_active() {
2804 let m = MockSource::new();
2805 m.append(b"[1] a\n cont\n[2] b\n");
2806 m.finish();
2807 let mut idx = LineIndex::new();
2808 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2809 idx.extend_to_end(&m);
2810 let mut v = Viewport::new(20, 5, "f".into());
2811 let frame = v.frame(&m, &mut idx);
2812 let status = &frame.status;
2813 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2814 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2815 }
2816
2817 #[test]
2818 fn format_status_uses_custom_template_when_set() {
2819 let m = MockSource::new();
2820 m.append(b"a\nb\nc\n");
2821 m.finish();
2822 let mut idx = LineIndex::new();
2823 idx.extend_to_end(&m);
2824 let mut v = Viewport::new(20, 5, "f".into());
2825 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2826 v.set_prompt(Some(prompt));
2827 let frame = v.frame(&m, &mut idx);
2828 assert_eq!(frame.status, "f 100%");
2829 }
2830
2831 #[test]
2832 fn status_shows_preprocess_failed_tag_when_set() {
2833 let m = MockSource::new();
2834 m.append(b"a\n");
2835 let mut idx = LineIndex::new();
2836 idx.extend_to_end(&m);
2837 let mut v = Viewport::new(40, 5, "f".into());
2838 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2839 let frame = v.frame(&m, &mut idx);
2840 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2841 "got: {}", frame.status);
2842 }
2843
2844 #[test]
2845 fn default_status_includes_help_hint() {
2846 let (m, mut idx) = setup(b"a\nb\nc\n");
2847 let mut v = Viewport::new(80, 5, "f".into());
2848 let frame = v.frame(&m, &mut idx);
2849 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2850 }
2851
2852 #[test]
2853 fn custom_prompt_does_not_get_help_hint() {
2854 let (m, mut idx) = setup(b"a\nb\nc\n");
2855 let mut v = Viewport::new(80, 5, "f".into());
2856 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2857 let frame = v.frame(&m, &mut idx);
2858 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2859 }
2860
2861 #[test]
2862 fn status_shows_file_index_when_multifile() {
2863 let m = MockSource::new();
2864 m.append(b"a\n");
2865 let mut idx = LineIndex::new();
2866 idx.extend_to_end(&m);
2867 let mut v = Viewport::new(60, 5, "f.log".into());
2868 v.set_file_index(0, 3);
2869 let frame = v.frame(&m, &mut idx);
2870 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2871 }
2872
2873 #[test]
2874 fn status_omits_file_index_when_single_file() {
2875 let m = MockSource::new();
2876 m.append(b"a\n");
2877 let mut idx = LineIndex::new();
2878 idx.extend_to_end(&m);
2879 let mut v = Viewport::new(60, 5, "f.log".into());
2880 v.set_file_index(0, 1);
2881 let frame = v.frame(&m, &mut idx);
2882 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2883 }
2884
2885 #[test]
2886 fn status_shows_tag_active_when_multimatch() {
2887 let m = MockSource::new();
2888 m.append(b"a\n");
2889 let mut idx = LineIndex::new();
2890 idx.extend_to_end(&m);
2891 let mut v = Viewport::new(80, 5, "f.log".into());
2892 v.set_tag_active(Some(("foo".into(), 2, 3)));
2893 let frame = v.frame(&m, &mut idx);
2894 assert!(
2895 frame.status.contains("[tag: foo (2/3)]"),
2896 "got: {}",
2897 frame.status
2898 );
2899 }
2900
2901 #[test]
2902 fn status_omits_tag_active_when_single_match() {
2903 let m = MockSource::new();
2904 m.append(b"a\n");
2905 let mut idx = LineIndex::new();
2906 idx.extend_to_end(&m);
2907 let mut v = Viewport::new(80, 5, "f.log".into());
2908 v.set_tag_active(Some(("foo".into(), 1, 1)));
2909 let frame = v.frame(&m, &mut idx);
2910 assert!(
2911 !frame.status.contains("[tag:"),
2912 "should not show indicator for single match: {}",
2913 frame.status
2914 );
2915 }
2916
2917 #[test]
2920 fn reconstruct_picks_up_state_from_prior_lines() {
2921 let m = MockSource::new();
2922 m.append(b"\x1b[31mline 1\n");
2923 m.append(b"line 2 (still red, no reset)\n");
2924 m.append(b"line 3\n");
2925 let mut idx = LineIndex::new();
2926 idx.extend_to_end(&m);
2927 let state = reconstruct_render_state(&m, &idx, 2);
2928 assert_eq!(
2929 state.style.fg,
2930 Some(crate::ansi::Color::Ansi(1)),
2931 "red SGR from line 0 should persist to line 2"
2932 );
2933 }
2934
2935 #[test]
2936 fn reconstruct_respects_reset_between_lines() {
2937 let m = MockSource::new();
2938 m.append(b"\x1b[31mline 1\x1b[0m\n");
2939 m.append(b"line 2 (default)\n");
2940 let mut idx = LineIndex::new();
2941 idx.extend_to_end(&m);
2942 let state = reconstruct_render_state(&m, &idx, 1);
2943 assert_eq!(state.style.fg, None);
2944 }
2945
2946 #[test]
2947 fn reconstruct_caps_walkback_at_max_lines() {
2948 let m = MockSource::new();
2949 m.append(b"\x1b[31mvery early\n");
2950 for _ in 0..300 {
2951 m.append(b"line\n");
2952 }
2953 let mut idx = LineIndex::new();
2954 idx.extend_to_end(&m);
2955 let state = reconstruct_render_state(&m, &idx, 290);
2958 assert_eq!(state.style.fg, None);
2959 }
2960}