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