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