1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::or::OrGroups;
8use crate::line_index::LineIndex;
9use crate::render::{count_rows, render_line, Cell, RenderOpts};
10use crate::source::Source;
11
12const MAX_RECONSTRUCT_LINES: usize = 256;
16
17fn reconstruct_render_state(
24 src: &dyn Source,
25 idx: &crate::line_index::LineIndex,
26 target_line: usize,
27) -> crate::render::RenderState {
28 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
29 let mut state = crate::render::RenderState::default();
30 for line_no in start..target_line {
31 let range = idx.line_range(line_no, src);
32 let raw = src.bytes(range);
33 for &b in raw.as_ref() {
34 let _ = crate::ansi::step(
35 &mut state.parse,
36 &mut state.style,
37 &mut state.hyperlink,
38 b,
39 );
40 }
41 }
42 state
43}
44
45fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
51 let mut text = String::new();
52 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
53 for (col, cell) in row.iter().enumerate() {
54 match cell {
55 Cell::Char { ch, .. } => {
56 starts.push(col);
57 text.push(*ch);
58 }
59 Cell::Empty => {
60 starts.push(col);
61 text.push(' ');
62 }
63 Cell::Continuation => {}
64 }
65 }
66 starts.push(row.len());
67 (text, starts)
68}
69
70fn line_is_blank(bytes: &[u8]) -> bool {
75 bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
76}
77
78fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
82 if row.is_empty() {
83 return Vec::new();
84 }
85 let last_content_col = row
86 .iter()
87 .enumerate()
88 .rev()
89 .find_map(|(c, cell)| match cell {
90 Cell::Char { width, .. } => Some(c + *width as usize),
91 Cell::Continuation => Some(c + 1),
92 Cell::Empty => None,
93 })
94 .unwrap_or(0);
95 if last_content_col == 0 {
96 return Vec::new();
97 }
98 let (text, starts) = row_text_and_starts(row);
99 let mut out = Vec::new();
100 for m in regex.find_iter(&text) {
101 if m.start() == m.end() {
102 continue;
103 }
104 let char_start = text[..m.start()].chars().count();
105 let char_end = text[..m.end()].chars().count();
106 if char_start >= starts.len() - 1 || char_end <= char_start {
107 continue;
108 }
109 let col_start = starts[char_start];
110 let col_end = starts[char_end].min(last_content_col);
111 if col_end > col_start {
112 out.push(col_start..col_end);
113 }
114 }
115 out
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RowStyle {
120 Normal,
121 Dim,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SearchDirection {
128 Forward,
129 Backward,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum CaseMode {
138 #[default]
139 Sensitive,
140 Smart,
141 Insensitive,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum QuitAtEof {
150 #[default]
151 Off,
152 Second,
153 First,
154}
155
156impl CaseMode {
157 pub fn apply_to_pattern(self, pattern: &str) -> String {
160 match self {
161 CaseMode::Sensitive => pattern.to_string(),
162 CaseMode::Insensitive => format!("(?i){pattern}"),
163 CaseMode::Smart => {
164 if pattern.chars().any(|c| c.is_uppercase()) {
165 pattern.to_string()
166 } else {
167 format!("(?i){pattern}")
168 }
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone)]
175pub struct SearchState {
176 pub raw: String,
177 pub regex: Regex,
178 pub direction: SearchDirection,
179}
180
181#[derive(Debug, Clone)]
182pub struct Frame {
183 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
190 pub status: String,
191 pub status_style: crate::ansi::Style,
193 pub raw_rows: Vec<Option<Vec<u8>>>,
201}
202
203pub struct Viewport {
204 top_line: usize,
205 top_row: usize,
206 left_col: usize,
210 cols: u16,
211 rows: u16,
212 pub opts: RenderOpts,
213 pub show_line_numbers: bool,
214 pub source_label: String,
215 follow_mode: bool,
216 live_mode: bool,
217 prettify_label: Option<String>,
218 format_label: Option<String>,
219 filter: Option<CompiledFilter>,
220 grep: Option<GrepPredicate>,
221 or_groups: OrGroups,
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 #[cfg(feature = "image")]
236 image: Option<image::RgbaImage>,
237 image_mode: bool,
238 image_no_color: bool,
239 #[cfg_attr(not(feature = "image"), allow(dead_code))]
240 image_format: String,
241 #[cfg(feature = "image")]
242 image_style: crate::image_render::AsciiStyle,
243 #[cfg_attr(not(feature = "image"), allow(dead_code))]
244 image_width: Option<usize>,
245 hex_group_size: usize,
248 prompt: Option<crate::prompt::ParsedPrompt>,
251 preprocess_failure: Option<String>,
254 file_index: Option<(usize, usize)>,
256 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
260 status_style: crate::ansi::Style,
264 status_flash: Option<(String, u32)>,
269 ticks_since_growth: u32,
274 case_mode: CaseMode,
278 hilite_search: bool,
282 quit_at_eof: QuitAtEof,
284 eof_hits: u8,
287 squeeze_blanks: bool,
291 header_lines: usize,
296 header_cols: usize,
297 page_size: Option<u16>,
301 render_state: crate::render::RenderState,
305 render_state_for: usize,
308 incsearch: bool,
312 status_column: bool,
316 status_marks: std::collections::HashMap<usize, char>,
320}
321
322impl Viewport {
323 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
324 let opts = RenderOpts { cols, ..RenderOpts::default() };
325 Self {
326 top_line: 0,
327 top_row: 0,
328 left_col: 0,
329 cols,
330 rows,
331 opts,
332 show_line_numbers: false,
333 source_label,
334 follow_mode: false,
335 live_mode: false,
336 prettify_label: None,
337 format_label: None,
338 filter: None,
339 grep: None,
340 or_groups: OrGroups::default(),
341 dim_mode: false,
342 visible_lines: Vec::new(),
343 visible_scanned: 0,
344 search: None,
345 display: None,
346 hex_mode: false,
347 #[cfg(feature = "image")]
348 image: None,
349 image_mode: false,
350 image_no_color: false,
351 image_format: String::new(),
352 #[cfg(feature = "image")]
353 image_style: crate::image_render::AsciiStyle::Ramp,
354 image_width: None,
355 hex_group_size: 2,
356 prompt: None,
357 preprocess_failure: None,
358 file_index: None,
359 tag_active: None,
360 ansi_mode: crate::render::AnsiMode::Strict,
361 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
362 status_flash: None,
363 ticks_since_growth: 0,
364 case_mode: CaseMode::default(),
365 hilite_search: true,
366 quit_at_eof: QuitAtEof::default(),
367 eof_hits: 0,
368 squeeze_blanks: false,
369 header_lines: 0,
370 header_cols: 0,
371 page_size: None,
372 render_state: crate::render::RenderState::default(),
373 render_state_for: usize::MAX,
374 incsearch: false,
375 status_column: false,
376 status_marks: std::collections::HashMap::new(),
377 }
378 }
379
380 pub fn status_column(&self) -> bool { self.status_column }
381
382 pub fn set_status_column(&mut self, on: bool) { self.status_column = on; }
383
384 pub fn set_status_marks(&mut self, marks: std::collections::HashMap<usize, char>) {
388 self.status_marks = marks;
389 }
390
391 fn status_col_width(&self) -> u16 {
395 if self.status_column && self.ansi_mode != crate::render::AnsiMode::Raw { 1 } else { 0 }
396 }
397
398 fn status_cell(glyph: char) -> Cell {
401 Cell::Char { ch: glyph, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
402 }
403
404 fn status_glyph(&self, line_n: usize, has_match: bool) -> char {
408 if let Some(&ch) = self.status_marks.get(&line_n) {
409 ch
410 } else if has_match {
411 '*'
412 } else {
413 ' '
414 }
415 }
416
417 pub fn case_mode(&self) -> CaseMode { self.case_mode }
418
419 pub fn hilite_search(&self) -> bool { self.hilite_search }
420
421 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
422
423 pub fn incsearch(&self) -> bool { self.incsearch }
424
425 pub fn set_incsearch(&mut self, on: bool) { self.incsearch = on; }
426
427 pub fn top_row(&self) -> usize { self.top_row }
428
429 pub fn set_top(&mut self, line: usize, row: usize) {
431 self.top_line = line;
432 self.top_row = row;
433 }
434
435 pub fn incsearch_preview(&mut self, src: &dyn Source, idx: &mut LineIndex,
439 pattern: &str, direction: SearchDirection,
440 origin: (usize, usize)) {
441 if pattern.is_empty() { return; }
442 self.set_top(origin.0, origin.1);
443 if self.set_search(pattern.to_string(), direction).is_ok() {
444 self.search_repeat(src, idx, false);
445 }
446 }
447
448 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
449 self.quit_at_eof = mode;
450 self.eof_hits = 0;
451 }
452
453 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
454 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
455
456 pub fn set_header(&mut self, lines: usize, cols: usize) {
457 self.header_lines = lines;
458 self.header_cols = cols;
459 if self.top_line < self.header_lines {
462 self.top_line = self.header_lines;
463 }
464 }
465 pub fn header_lines(&self) -> usize { self.header_lines }
466 pub fn header_cols(&self) -> usize { self.header_cols }
467
468 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
469 pub fn page_size(&self) -> Option<u16> { self.page_size }
470
471 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
476 match self.quit_at_eof {
477 QuitAtEof::Off => false,
478 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
479 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
480 self.eof_hits = self.eof_hits.saturating_add(1);
481 self.eof_hits >= 2
482 }
483 _ => {
484 if !forward { self.eof_hits = 0; }
485 false
486 }
487 }
488 }
489
490 pub fn set_case_mode(&mut self, mode: CaseMode) {
494 self.case_mode = mode;
495 if let Some(s) = self.search.clone() {
496 let _ = self.set_search(s.raw, s.direction);
497 }
498 }
499
500 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
501 self.status_style = style;
502 }
503
504 pub fn status_style(&self) -> crate::ansi::Style {
505 self.status_style
506 }
507
508 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
512 self.status_flash = Some((msg.into(), ticks));
513 }
514
515 pub fn tick_flash(&mut self) {
518 if let Some((_, n)) = &mut self.status_flash {
519 *n = n.saturating_sub(1);
520 if *n == 0 {
521 self.status_flash = None;
522 }
523 }
524 }
525
526 pub fn note_growth(&mut self) {
528 self.ticks_since_growth = 0;
529 }
530
531 pub fn tick_idle(&mut self) {
534 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
535 }
536
537 pub fn is_idle(&self) -> bool {
540 self.ticks_since_growth >= 20
541 }
542
543 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
544 self.display = renderer;
545 }
546
547 pub fn set_hex_mode(&mut self, on: bool) {
548 self.hex_mode = on;
549 }
550
551 pub fn hex_mode(&self) -> bool {
553 self.hex_mode
554 }
555
556 #[cfg(feature = "image")]
557 pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
558 self.image = Some(img);
559 self.image_format = format.to_string();
560 self.image_style = style;
561 self.image_width = width;
562 self.image_mode = true;
563 self.top_line = 0;
564 self.top_row = 0;
565 }
566
567 pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
568
569 pub fn image_mode(&self) -> bool { self.image_mode }
570
571 #[cfg(feature = "image")]
572 fn image_cols(&self) -> u16 {
573 self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
574 }
575
576 #[cfg(feature = "image")]
577 pub fn image_total_rows(&self) -> usize {
578 match &self.image {
579 Some(img) => {
580 let (w, h) = img.dimensions();
581 crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
582 }
583 None => 0,
584 }
585 }
586
587 #[cfg(feature = "image")]
588 pub fn is_at_bottom_image(&self) -> bool {
589 let body = self.body_rows() as usize;
590 self.top_line + body >= self.image_total_rows()
591 }
592
593 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
596 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
597 self.hex_group_size = bytes_per_group;
598 }
599 }
600
601 pub fn hex_group_size(&self) -> usize {
603 self.hex_group_size
604 }
605
606 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
607 self.prompt = prompt;
608 }
609
610 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
611 self.preprocess_failure = msg;
612 }
613
614 pub fn set_file_index(&mut self, current: usize, total: usize) {
615 self.file_index = if total > 1 {
616 Some((current, total))
617 } else {
618 None
619 };
620 }
621
622 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
623 self.tag_active = info;
624 }
625
626 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
627 self.ansi_mode = mode;
628 }
629
630 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
631 self.ansi_mode
632 }
633
634 pub fn set_source_label(&mut self, label: String) {
635 self.source_label = label;
636 }
637
638 pub fn source_label_clone(&self) -> String {
639 self.source_label.clone()
640 }
641
642 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
647 let range = idx.line_range(line_n, src);
648 let raw = src.bytes(range);
649 if let Some(r) = self.display.as_ref() {
650 if let Some(rendered) = r.render_line(&raw) {
651 return std::borrow::Cow::Owned(rendered.into_bytes());
652 }
653 }
654 raw
655 }
656
657 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
661 let compiled = self.case_mode.apply_to_pattern(&raw);
662 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
663 self.search = Some(SearchState { raw, regex, direction });
664 Ok(())
665 }
666
667 pub fn clear_search(&mut self) { self.search = None; }
668
669 pub fn search_active(&self) -> bool { self.search.is_some() }
670
671 pub fn search_direction(&self) -> SearchDirection {
672 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
673 }
674
675 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
679 if idx.records_mode() {
680 self.search_repeat_records(src, idx, reverse)
681 } else {
682 self.search_repeat_lines(src, idx, reverse)
683 }
684 }
685
686 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
688 let Some(s) = self.search.as_ref() else { return false; };
689 let forward = matches!(
690 (s.direction, reverse),
691 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
692 );
693 idx.extend_to_end(src);
694 let pattern = s.regex.clone();
695 if self.hide_mode() {
696 self.extend_visible_lines(idx, src);
697 self.search_step_in_visible(&pattern, src, idx, forward)
698 } else {
699 self.search_step_in_logical(&pattern, src, idx, forward)
700 }
701 }
702
703 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
707 let Some(s) = self.search.as_ref() else { return false; };
708 let forward = matches!(
709 (s.direction, reverse),
710 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
711 );
712 let pattern = s.regex.clone();
713 idx.extend_to_end(src);
714
715 let total = idx.record_count();
716 if total == 0 { return false; }
717
718 let cur_record = idx.line_to_record(self.top_line);
719
720 let range: Box<dyn Iterator<Item = usize>> = if forward {
721 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
722 } else {
723 let earlier: Vec<usize> = (0..cur_record).rev().collect();
724 let later: Vec<usize> = (cur_record..total).rev().collect();
725 Box::new(earlier.into_iter().chain(later))
726 };
727
728 for r in range {
729 let bytes = idx.record_bytes_stripped(r, src);
730 let text = String::from_utf8_lossy(&bytes);
731 if pattern.is_match(&text) {
732 let line_range = idx.record_line_range(r);
733 self.top_line = line_range.start;
734 self.top_row = 0;
735 return true;
736 }
737 }
738 false
739 }
740
741 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
742 let display = self.line_display_bytes(src, idx, line_n);
747 let bytes = crate::ansi::strip_sgr(&display);
748 match std::str::from_utf8(&bytes) {
749 Ok(s) => pattern.is_match(s),
750 Err(_) => false,
751 }
752 }
753
754 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
755 let total = idx.line_count();
756 if total == 0 { return false; }
757 let start = self.top_line;
758 for offset in 1..=total {
761 let line_n = if forward {
762 (start + offset) % total
763 } else {
764 (start + total - offset) % total
765 };
766 if self.line_matches(pattern, src, idx, line_n) {
767 self.top_line = line_n;
768 self.top_row = 0;
769 return true;
770 }
771 }
772 false
773 }
774
775 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
776 let total = self.visible_lines.len();
777 if total == 0 { return false; }
778 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
780 for offset in 1..=total {
781 let visible_idx = if forward {
782 (cur + offset) % total
783 } else {
784 (cur + total - offset) % total
785 };
786 let line_n = self.visible_lines[visible_idx];
787 if self.line_matches(pattern, src, idx, line_n) {
788 self.top_line = line_n;
789 self.top_row = 0;
790 return true;
791 }
792 }
793 false
794 }
795
796 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
797 self.filter = filter;
798 self.visible_lines.clear();
799 self.visible_scanned = 0;
800 self.top_line = 0;
802 self.top_row = 0;
803 self.left_col = 0;
804 }
805
806 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
807 self.grep = grep;
808 self.visible_lines.clear();
809 self.visible_scanned = 0;
810 self.top_line = 0;
811 self.top_row = 0;
812 self.left_col = 0;
813 }
814
815 pub fn set_or_groups(&mut self, or_groups: OrGroups) {
816 self.or_groups = or_groups;
817 self.visible_lines.clear();
818 self.visible_scanned = 0;
819 self.top_line = 0;
820 self.top_row = 0;
821 self.left_col = 0;
822 }
823
824 pub fn or_active(&self) -> bool {
825 self.or_groups.is_active()
826 }
827
828 pub fn grep_active(&self) -> bool { self.grep.is_some() }
829
830 pub fn set_dim_mode(&mut self, on: bool) {
831 self.dim_mode = on;
832 self.visible_lines.clear();
836 self.visible_scanned = 0;
837 }
838
839 pub fn filter_active(&self) -> bool { self.filter.is_some() }
840
841 pub fn dim_mode(&self) -> bool { self.dim_mode }
842
843 fn hide_mode(&self) -> bool {
844 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
845 && !self.dim_mode
846 }
847
848 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
853 if !self.hide_mode() {
854 return;
855 }
856 if idx.records_mode() {
857 self.extend_visible_lines_records(idx, src);
858 } else {
859 self.extend_visible_lines_per_line(idx, src);
860 }
861 }
862
863 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
865 let total = idx.line_count();
866 while self.visible_scanned < total {
867 let line_n = self.visible_scanned;
868 let bytes = idx.line_bytes_stripped(line_n, src);
869 if self.line_passes(&bytes) {
870 self.visible_lines.push(line_n);
871 }
872 self.visible_scanned += 1;
873 }
874 }
875
876 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
883 self.visible_lines.clear();
884 self.visible_scanned = 0; let total_records = idx.record_count();
886 for r in 0..total_records {
887 if self.record_passes(idx, src, r) {
888 for line_n in idx.record_line_range(r) {
889 self.visible_lines.push(line_n);
890 }
891 }
892 }
893 }
894
895 fn line_passes(&self, line: &[u8]) -> bool {
901 let filter_ok = match self.filter.as_ref() {
902 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
903 None => true,
904 };
905 let grep_ok = match self.grep.as_ref() {
906 Some(g) => g.matches(line),
907 None => true,
908 };
909 filter_ok && grep_ok && self.or_groups.matches_line(line)
910 }
911
912 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
920 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
921 let bytes = if need {
922 Some(idx.record_bytes_stripped(r, src))
923 } else {
924 None
925 };
926 let filter_ok = match self.filter.as_ref() {
927 Some(f) => matches!(
928 f.evaluate_record(bytes.as_deref().unwrap()),
929 FilterMatch::Matched,
930 ),
931 None => true,
932 };
933 let grep_ok = match self.grep.as_ref() {
934 Some(g) => g.matches(bytes.as_deref().unwrap()),
935 None => true,
936 };
937 let or_ok = if self.or_groups.is_active() {
938 self.or_groups.matches_record(bytes.as_deref().unwrap())
939 } else {
940 true
941 };
942 filter_ok && grep_ok && or_ok
943 }
944
945 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
949 if !self.dim_mode {
950 return false;
951 }
952 if idx.records_mode() {
953 let r = idx.line_to_record(line_n);
954 !self.record_passes(idx, src, r)
955 } else {
956 let bytes = idx.line_bytes_stripped(line_n, src);
957 !self.line_passes(&bytes)
958 }
959 }
960
961 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
969 let body_rows = self.body_rows() as usize;
970 if self.hide_mode() && !self.visible_lines.is_empty() {
971 let cur = self
972 .visible_lines
973 .iter()
974 .position(|&l| l >= self.top_line)
975 .unwrap_or(self.visible_lines.len().saturating_sub(1));
976 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
977 return self.visible_lines[last_pos];
978 }
979 let total = idx.line_count();
980 if total == 0 {
981 return self.top_line;
982 }
983 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
984 }
985
986 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
987
988 pub fn follow_mode(&self) -> bool { self.follow_mode }
989
990 pub fn suspend_follow_if(&mut self, flag: bool) {
995 if flag {
996 self.follow_mode = false;
997 }
998 }
999
1000 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
1001
1002 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
1003
1004 pub fn live_mode(&self) -> bool { self.live_mode }
1005
1006 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
1007
1008 pub fn set_prettify_label(&mut self, label: Option<String>) {
1011 self.prettify_label = label;
1012 }
1013
1014 pub fn set_format_label(&mut self, label: Option<String>) {
1017 self.format_label = label;
1018 }
1019
1020 pub fn invalidate_filter_cache(&mut self) {
1025 self.visible_lines.clear();
1026 self.visible_scanned = 0;
1027 }
1028
1029 pub fn clamp_top_line(&mut self, line_count: usize) {
1032 if line_count == 0 {
1033 self.top_line = 0;
1034 self.top_row = 0;
1035 } else if self.top_line >= line_count {
1036 self.top_line = line_count - 1;
1037 self.top_row = 0;
1038 }
1039 }
1040
1041 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
1045 #[cfg(feature = "image")]
1046 if self.image_mode {
1047 return self.is_at_bottom_image();
1048 }
1049 if self.hide_mode() {
1050 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
1054 } else {
1055 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
1059 }
1060 }
1061
1062 fn gutter_width(&self, idx: &LineIndex) -> u16 {
1064 if !self.show_line_numbers { return 0; }
1065 let n = idx.line_count().max(1);
1066 let digits = (n as f64).log10().floor() as u16 + 1;
1067 digits + 1
1068 }
1069
1070 fn render_opts(&self, gutter: u16) -> RenderOpts {
1071 let mut o = self.opts.clone();
1072 o.cols = self.cols.saturating_sub(self.status_col_width() + gutter);
1075 o.mode = self.ansi_mode;
1076 o.left_col = self.left_col; o
1078 }
1079
1080 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1081 #[cfg(feature = "image")]
1082 if self.image_mode {
1083 return self.frame_image();
1084 }
1085 if self.hex_mode {
1086 return self.frame_hex(src);
1087 }
1088 let body_rows = self.body_rows() as usize;
1089 idx.extend_to_line(self.top_line + body_rows + 1, src);
1090
1091 if self.left_col > 0 && self.hscroll_active() {
1094 let gutter_for_clamp = self.status_col_width() + self.gutter_width(idx);
1095 let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1096 let mut width_opts = self.opts.clone();
1099 width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1100 width_opts.mode = self.ansi_mode;
1101 width_opts.left_col = 0;
1102 let mut widest = 0usize;
1103 let total_lines_for_clamp = idx.line_count();
1104 if self.hide_mode() {
1105 let hide_pos = self.visible_lines.iter()
1106 .position(|&l| l >= self.top_line)
1107 .unwrap_or(self.visible_lines.len());
1108 let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1109 for vi in hide_pos..end_vi {
1110 let ln = self.visible_lines[vi];
1111 let bytes = self.line_display_bytes(src, idx, ln);
1112 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1113 }
1114 } else {
1115 let start = self.top_line.max(self.header_lines);
1116 let end = (start + body_rows).min(total_lines_for_clamp);
1117 for ln in start..end {
1118 let bytes = self.line_display_bytes(src, idx, ln);
1119 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1120 }
1121 }
1122 self.left_col = self.left_col.min(widest.saturating_sub(avail));
1123 }
1124
1125 let gutter = self.gutter_width(idx);
1126 let scol = self.status_col_width();
1127 let r_opts = self.render_opts(gutter);
1128
1129 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1133 reconstruct_render_state(src, idx, self.top_line)
1134 } else {
1135 crate::render::RenderState::default()
1136 };
1137 self.render_state = render_state.clone();
1139 self.render_state_for = self.top_line;
1140
1141 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1142 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1143 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1144 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1145 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1146 let hide = self.hide_mode();
1148 let total_lines = idx.line_count();
1149
1150 let header_rows = if !hide && !raw_passthrough {
1157 self.header_lines.min(body_rows).min(total_lines)
1158 } else {
1159 0
1160 };
1161 if header_rows > 0 {
1162 for hl in 0..header_rows {
1163 let raw = src.bytes(idx.line_range(hl, src));
1164 let display_bytes = if let Some(r) = self.display.as_ref() {
1165 match r.render_line(&raw) {
1166 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1167 None => raw.clone(),
1168 }
1169 } else {
1170 raw.clone()
1171 };
1172 let rows = render_line(&display_bytes, &r_opts, None);
1173 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1174 let mut v = Vec::with_capacity(self.cols as usize);
1175 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1176 v
1177 });
1178 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1179 if scol > 0 {
1180 let matched = self.search.as_ref()
1181 .is_some_and(|s| !find_row_highlights(&content_row, &s.regex).is_empty());
1182 let glyph = self.status_glyph(hl, matched);
1183 full.push(Self::status_cell(glyph));
1184 }
1185 if gutter > 0 {
1186 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1187 for c in label.chars() {
1188 full.push(Cell::Char {
1189 ch: c,
1190 width: 1,
1191 style: crate::ansi::Style::default(),
1192 hyperlink: None,
1193 });
1194 }
1195 }
1196 full.append(&mut content_row);
1197 body.push(full);
1198 row_styles.push(RowStyle::Normal);
1199 highlights.push(Vec::new());
1200 raw_rows.push(None);
1201 }
1202 }
1203
1204 let mut hide_pos = if hide {
1206 self.visible_lines
1207 .iter()
1208 .position(|&l| l >= self.top_line)
1209 .unwrap_or(self.visible_lines.len())
1210 } else {
1211 0
1212 };
1213 let mut line_n = if hide {
1214 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1215 } else {
1216 self.top_line.max(self.header_lines)
1219 };
1220 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1221
1222 while body.len() < body_rows {
1223 if line_n >= total_lines {
1224 let mut row = Vec::with_capacity(self.cols as usize);
1225 if scol > 0 {
1226 for _ in 0..scol { row.push(Cell::Empty); }
1227 }
1228 if gutter > 0 {
1229 for _ in 0..gutter { row.push(Cell::Empty); }
1230 }
1231 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1232 body.push(row);
1233 row_styles.push(RowStyle::Normal);
1234 highlights.push(Vec::new());
1235 raw_rows.push(None);
1236 line_n += 1;
1237 continue;
1238 }
1239 let raw = src.bytes(idx.line_range(line_n, src));
1242 if self.squeeze_blanks && line_is_blank(&raw) {
1247 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1248 let prev = src.bytes(idx.line_range(p, src));
1249 line_is_blank(&prev)
1250 });
1251 if prev_blank {
1252 line_n += 1;
1253 continue;
1254 }
1255 }
1256 let display_bytes = if let Some(r) = self.display.as_ref() {
1257 match r.render_line(&raw) {
1258 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1259 None => raw.clone(),
1260 }
1261 } else {
1262 raw.clone()
1263 };
1264 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1265 Some(&mut render_state)
1266 } else {
1267 None
1268 };
1269 let rows = render_line(&display_bytes, &r_opts, state_arg);
1270 let style = if self.filter.is_some() || self.grep.is_some() {
1271 if self.dim_mode {
1272 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1273 } else {
1274 RowStyle::Normal
1276 }
1277 } else {
1278 RowStyle::Normal
1279 };
1280
1281 let mut first_emitted_for_this_line = true;
1282 let mut status_first_row_idx: Option<usize> = None;
1287 let mut line_matched = false;
1288 for (i, mut content_row) in rows.into_iter().enumerate() {
1289 if i < skip { continue; }
1290 if body.len() >= body_rows { break; }
1291 if scol > 0 && !line_matched {
1298 if let Some(s) = self.search.as_ref() {
1299 if !find_row_highlights(&content_row, &s.regex).is_empty() {
1300 line_matched = true;
1301 }
1302 }
1303 }
1304 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1305 if scol > 0 {
1306 if status_first_row_idx.is_none() {
1307 status_first_row_idx = Some(body.len());
1308 }
1309 full.push(Self::status_cell(' '));
1312 }
1313 if gutter > 0 {
1314 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1315 for c in label.chars() {
1316 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1317 }
1318 }
1319 full.append(&mut content_row);
1320 if self.left_col > 0 && !self.opts.wrap {
1325 let marker_col = (scol + gutter) as usize;
1326 if let Some(cell) = full.get_mut(marker_col) {
1327 *cell = Cell::Char {
1328 ch: '<',
1329 width: 1,
1330 style: crate::ansi::Style { dim: true, ..Default::default() },
1331 hyperlink: None,
1332 };
1333 }
1334 }
1335 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1339 find_row_highlights(&full, &s.regex)
1340 } else {
1341 Vec::new()
1342 };
1343 body.push(full);
1344 row_styles.push(style);
1345 highlights.push(row_highlights);
1346 if raw_passthrough {
1347 if first_emitted_for_this_line {
1348 raw_rows.push(Some(raw.to_vec()));
1353 first_emitted_for_this_line = false;
1354 } else {
1355 raw_rows.push(Some(Vec::new()));
1356 }
1357 } else {
1358 raw_rows.push(None);
1359 }
1360 }
1361 if let Some(fi) = status_first_row_idx {
1365 let glyph = self.status_glyph(line_n, line_matched);
1366 if glyph != ' ' {
1367 if let Some(cell) = body[fi].first_mut() {
1368 *cell = Self::status_cell(glyph);
1369 }
1370 }
1371 }
1372 skip = 0;
1373 if hide {
1375 hide_pos += 1;
1376 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1377 } else {
1378 line_n += 1;
1379 }
1380 }
1381
1382 self.render_state_for = usize::MAX;
1385
1386 let status = self.format_status(idx, src);
1387 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1388 }
1389
1390 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1391 if let Some(p) = self.prompt.as_ref() {
1392 let ctx = self.build_prompt_context(idx, src);
1393 return p.render(&ctx);
1394 }
1395 let body_rows = self.body_rows() as usize;
1396 let total = idx.line_count();
1397 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1400 let visible_total = self.visible_lines.len();
1401 let cur = self
1403 .visible_lines
1404 .iter()
1405 .position(|&l| l >= self.top_line)
1406 .unwrap_or(visible_total);
1407 let top = cur + 1;
1408 let bottom = (cur + body_rows).min(visible_total.max(1));
1409 let total_str = if src.is_complete() {
1410 format!("{visible_total}/{total}")
1411 } else {
1412 format!("{visible_total}/{total}+")
1413 };
1414 (top, bottom, visible_total, total_str)
1415 } else {
1416 let top = self.top_line + 1;
1417 let bottom = (self.top_line + body_rows).min(total.max(1));
1418 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1419 (top, bottom, total, total_str)
1420 };
1421 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1422 let bottom_line = self.bottom_visible_line(idx);
1426 let (line_prefix, records_block) = if idx.records_mode() {
1427 let line_total = idx.line_count();
1428 let rec_total = idx.record_count();
1429 let rec_block = if line_total == 0 || rec_total == 0 {
1430 format!("R0-0/{}", rec_total)
1431 } else {
1432 let rec_top = idx.line_to_record(self.top_line) + 1;
1433 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1434 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1435 (rec_top, rec_top)
1439 } else {
1440 (rec_top, rec_bottom)
1441 };
1442 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1443 };
1444 ("L", Some(rec_block))
1445 } else {
1446 ("", None)
1447 };
1448 let middle = match records_block {
1449 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1450 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1451 };
1452 let label_with_index = match self.file_index {
1453 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1454 None => self.source_label.clone(),
1455 };
1456 let mut s = format!("{} {}", label_with_index, middle);
1457 if !self.hide_mode() && self.top_row > 0 {
1462 let line_rows = if total > 0 {
1463 let bytes = self.line_display_bytes(src, idx, self.top_line);
1464 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1465 } else { 1 };
1466 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1467 }
1468 if self.left_col > 0 {
1469 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1470 }
1471 if let Some(f) = self.filter.as_ref() {
1472 s.push_str(&format!(" [{}]", f.format_name));
1473 }
1474 if self.grep.is_some() {
1475 s.push_str(" [grep]");
1476 }
1477 if self.or_groups.is_active() {
1478 s.push_str(" [or]");
1479 }
1480 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1481 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1482 }
1483 if let Some(sr) = self.search.as_ref() {
1484 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1485 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1486 }
1487 if let Some(label) = self.prettify_label.as_ref() {
1488 s.push_str(&format!(" [pretty:{label}]"));
1489 }
1490 if self.live_mode { s.push_str(" (L)"); }
1491 if self.follow_mode {
1492 if let Some((msg, _)) = self.status_flash.as_ref() {
1493 s.push_str(" ");
1494 s.push_str(msg);
1495 } else if self.is_idle() {
1496 s.push_str(" (F idle)");
1497 } else {
1498 s.push_str(" (F)");
1499 }
1500 }
1501 if let Some(msg) = self.preprocess_failure.as_ref() {
1502 let first_line = msg.lines().next().unwrap_or("");
1503 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1504 }
1505 let tag_suffix = match &self.tag_active {
1506 Some((name, cur, total)) if *total > 1 => {
1507 format!(" [tag: {name} ({cur}/{total})]")
1508 }
1509 _ => String::new(),
1510 };
1511 s.push_str(&tag_suffix);
1512 let used = s.chars().count();
1515 let hint = ":help";
1516 if (self.cols as usize) > used + 1 + hint.chars().count() {
1517 let pad = self.cols as usize - used - hint.chars().count();
1518 s.push_str(&" ".repeat(pad));
1519 s.push_str(hint);
1520 } else {
1521 s.push(' ');
1522 s.push_str(hint);
1523 }
1524 s
1525 }
1526
1527 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1528 use crate::prompt::PromptContext;
1529
1530 let body_rows = self.body_rows() as usize;
1531 let total = idx.line_count();
1532 let top = self.top_line + 1;
1533 let bottom = (self.top_line + body_rows).min(total.max(1));
1534 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1535 let bottom_line = self.bottom_visible_line(idx);
1536
1537 let records_mode = idx.records_mode();
1538 let (rec_top, rec_bottom, rec_total) = if records_mode {
1539 let rt = idx.line_to_record(self.top_line) + 1;
1540 let rb_raw = idx.line_to_record(bottom_line) + 1;
1541 let rb = if rb_raw < rt { rt } else { rb_raw };
1542 (rt, rb, idx.record_count())
1543 } else {
1544 (0, 0, 0)
1545 };
1546
1547 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1548 let line_rows = if total > 0 {
1549 let bytes = self.line_display_bytes(src, idx, self.top_line);
1550 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1551 } else { 1 };
1552 format!("+{}/{}", self.top_row, line_rows)
1553 } else {
1554 String::new()
1555 };
1556
1557 let col_offset = if self.left_col > 0 { format!(" \u{00bb}{}", self.left_col) } else { String::new() };
1558
1559 let format_tag = self.format_label.as_ref()
1560 .map(|n| format!(" [{}]", n))
1561 .unwrap_or_default();
1562 let filter_tag = self.filter.as_ref()
1563 .map(|f| format!(" [{}]", f.format_name))
1564 .unwrap_or_default();
1565 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1566 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1567 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1568 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1569 } else {
1570 String::new()
1571 };
1572 let search_tag = self.search.as_ref()
1573 .map(|s| {
1574 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1575 format!(" [{}{}]", p, s.raw)
1576 })
1577 .unwrap_or_default();
1578 let pretty_tag = self.prettify_label.as_ref()
1579 .map(|l| format!(" [pretty:{l}]"))
1580 .unwrap_or_default();
1581 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1582 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1583 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1584 .map(|msg| {
1585 let first_line = msg.lines().next().unwrap_or("");
1586 format!(" [preprocess-failed: {}]", first_line)
1587 })
1588 .unwrap_or_default();
1589
1590 let file_index_tag = match self.file_index {
1591 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1592 None => String::new(),
1593 };
1594
1595 let tag_tag = match &self.tag_active {
1596 Some((name, cur, total)) if *total > 1 => {
1597 format!(" [tag: {name} ({cur}/{total})]")
1598 }
1599 _ => String::new(),
1600 };
1601
1602 PromptContext {
1603 label: self.source_label.clone(),
1604 top,
1605 bottom,
1606 total,
1607 pct: pct.min(100) as u8,
1608 rec_top,
1609 rec_bottom,
1610 rec_total,
1611 records_mode,
1612 wrap_offset,
1613 col_offset,
1614 format_tag,
1615 filter_tag,
1616 grep_tag,
1617 or_tag,
1618 hide_tag,
1619 search_tag,
1620 pretty_tag,
1621 live_tag,
1622 follow_tag,
1623 preprocess_failed_tag,
1624 file_index_tag,
1625 tag_tag,
1626 }
1627 }
1628
1629 fn frame_hex(&self, src: &dyn Source) -> Frame {
1630 use crate::hex::format_hex_row;
1631 use crate::render::{render_line, Cell, RenderOpts};
1632
1633 let body_rows = self.rows.saturating_sub(1) as usize;
1634 let total_bytes = src.len();
1635 let total_hex_rows = total_bytes.div_ceil(16);
1636
1637 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1638 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1639 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1640
1641 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false, left_col: 0, tab_stops: None };
1642
1643 for row_idx in 0..body_rows {
1644 let hex_row = self.top_line + row_idx;
1645 if hex_row >= total_hex_rows {
1646 body.push(vec![Cell::Empty; self.cols as usize]);
1647 } else {
1648 let offset = hex_row * 16;
1649 let end = (offset + 16).min(total_bytes);
1650 let bytes_cow = src.bytes(offset..end);
1651 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1652 let rows = render_line(text.as_bytes(), &opts, None);
1653 body.push(rows.into_iter().next().unwrap_or_else(|| {
1654 vec![Cell::Empty; self.cols as usize]
1655 }));
1656 }
1657 row_styles.push(RowStyle::Normal);
1658 highlights.push(Vec::new());
1659 }
1660
1661 let status = self.format_status_hex(src);
1662 let raw_rows = vec![None; body.len()];
1663 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1664 }
1665
1666 fn format_status_hex(&self, src: &dyn Source) -> String {
1667 let total_bytes = src.len();
1668 let body_rows = self.rows.saturating_sub(1) as usize;
1669 let top_byte = self.top_line * 16;
1671 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1674 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1675 let label_with_index = match self.file_index {
1676 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1677 None => self.source_label.clone(),
1678 };
1679 let tag_suffix = match &self.tag_active {
1680 Some((name, cur, total)) if *total > 1 => {
1681 format!(" [tag: {name} ({cur}/{total})]")
1682 }
1683 _ => String::new(),
1684 };
1685 format!(
1686 "{} off {}-{}/{} {}% [hex]{}",
1687 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1688 )
1689 }
1690
1691 #[cfg(feature = "image")]
1692 fn frame_image(&mut self) -> Frame {
1693 use crate::render::Cell;
1694 let body_rows = self.body_rows() as usize;
1695 let cols = self.cols as usize;
1696 let img = match &self.image {
1697 Some(i) => i,
1698 None => {
1699 let body = vec![vec![Cell::Empty; cols]; body_rows];
1700 return Frame {
1701 body,
1702 row_styles: vec![RowStyle::Normal; body_rows],
1703 highlights: vec![Vec::new(); body_rows],
1704 status: self.image_format.clone(),
1705 status_style: self.status_style,
1706 raw_rows: vec![None; body_rows],
1707 };
1708 }
1709 };
1710 let color = !self.image_no_color;
1711 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1712 let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1713 let max_off = grid_w.saturating_sub(cols);
1714 if self.left_col > max_off { self.left_col = max_off; }
1715 let off = self.left_col;
1716 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1717 for r in 0..body_rows {
1718 let gi = self.top_line + r;
1719 if gi < grid.len() {
1720 let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1721 while row.len() < cols { row.push(Cell::Empty); }
1722 body.push(row);
1723 } else {
1724 body.push(vec![Cell::Empty; cols]);
1725 }
1726 }
1727 let status = self.format_status_image(grid.len());
1728 Frame {
1729 body,
1730 row_styles: vec![RowStyle::Normal; body_rows],
1731 highlights: vec![Vec::new(); body_rows],
1732 status,
1733 status_style: self.status_style,
1734 raw_rows: vec![None; body_rows],
1735 }
1736 }
1737
1738 #[cfg(feature = "image")]
1739 fn format_status_image(&self, total_rows: usize) -> String {
1740 let body = self.body_rows() as usize;
1741 let top = self.top_line + 1;
1742 let bottom = (self.top_line + body).min(total_rows.max(1));
1743 let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1744 let mut s = format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1745 if self.left_col > 0 {
1746 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1747 }
1748 s
1749 }
1750
1751 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1756 if delta == 0 { return; }
1757 #[cfg(feature = "image")]
1758 if self.image_mode {
1759 self.scroll_lines(delta, src, idx);
1760 return;
1761 }
1762 if self.hide_mode() {
1763 self.extend_visible_lines(idx, src);
1767 let n = self.visible_lines.len();
1768 if n == 0 {
1769 self.top_line = 0;
1770 self.top_row = 0;
1771 return;
1772 }
1773 let vi = self
1774 .visible_lines
1775 .iter()
1776 .position(|&l| l >= self.top_line)
1777 .unwrap_or(n - 1);
1778 if delta > 0 {
1779 let target = (vi + delta as usize).min(n - 1);
1780 self.top_line = self.visible_lines[target];
1781 self.top_row = 0;
1782 } else {
1783 let back = (-delta) as usize;
1784 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1785 let extra_back = back.saturating_sub(consumed_for_snap);
1786 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1787 self.top_row = 0;
1788 }
1789 return;
1790 }
1791 if delta > 0 {
1792 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1793 let total = idx.line_count();
1794 if total == 0 { return; }
1795 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1796 self.top_line = target;
1797 self.top_row = 0;
1798 } else {
1799 let back = (-delta) as usize;
1800 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1805 let extra_back = back.saturating_sub(consumed_for_snap);
1806 self.top_line = self.top_line.saturating_sub(extra_back);
1807 self.top_row = 0;
1808 }
1809 }
1810
1811 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1812 if delta == 0 { return; }
1813 #[cfg(feature = "image")]
1814 if self.image_mode {
1815 let total = self.image_total_rows();
1816 let body = self.body_rows() as usize;
1817 let max_top = total.saturating_sub(body);
1818 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
1819 self.top_line = next as usize;
1820 self.top_row = 0;
1821 return;
1822 }
1823 if self.hide_mode() {
1824 self.extend_visible_lines(idx, src);
1828 let n = self.visible_lines.len();
1829 if n == 0 {
1830 self.top_line = 0;
1831 self.top_row = 0;
1832 return;
1833 }
1834 let mut vi = self
1835 .visible_lines
1836 .iter()
1837 .position(|&l| l >= self.top_line)
1838 .unwrap_or(n - 1);
1839 if self.visible_lines[vi] != self.top_line {
1842 self.top_row = 0;
1843 }
1844 self.top_line = self.visible_lines[vi];
1845 let r_opts = self.render_opts(self.gutter_width(idx));
1846 if delta > 0 {
1847 let mut remaining = delta as usize;
1848 while remaining > 0 {
1849 let line = self.visible_lines[vi];
1850 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1851 if self.top_row + 1 < rows {
1852 self.top_row += 1;
1853 } else if vi + 1 < n {
1854 self.top_row = 0;
1855 vi += 1;
1856 self.top_line = self.visible_lines[vi];
1857 } else {
1858 break;
1859 }
1860 remaining -= 1;
1861 }
1862 let anchor = self.hide_bottom_anchor(src, idx);
1863 if (self.top_line, self.top_row) > anchor {
1864 self.top_line = anchor.0;
1865 self.top_row = anchor.1;
1866 }
1867 } else {
1868 let mut remaining = (-delta) as usize;
1869 while remaining > 0 {
1870 if self.top_row > 0 {
1871 self.top_row -= 1;
1872 } else if vi > 0 {
1873 vi -= 1;
1874 self.top_line = self.visible_lines[vi];
1875 let line = self.visible_lines[vi];
1876 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1877 self.top_row = rows.saturating_sub(1);
1878 } else {
1879 break;
1880 }
1881 remaining -= 1;
1882 }
1883 }
1884 return;
1885 }
1886 if delta > 0 {
1887 let mut remaining = delta as usize;
1888 while remaining > 0 {
1889 idx.extend_to_line(self.top_line + 1, src);
1890 let total = idx.line_count();
1891 if total == 0 { break; }
1892 let bytes = self.line_display_bytes(src, idx, self.top_line);
1893 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1894 if self.top_row + 1 < line_rows {
1895 self.top_row += 1;
1896 } else if self.top_line + 1 < total {
1897 self.top_row = 0;
1898 self.top_line += 1;
1899 } else {
1900 break;
1901 }
1902 remaining -= 1;
1903 }
1904 if idx.scanned_through() >= src.len() {
1909 let anchor = self.bottom_anchor(src, idx);
1910 if (self.top_line, self.top_row) > anchor {
1911 self.top_line = anchor.0;
1912 self.top_row = anchor.1;
1913 }
1914 }
1915 } else {
1916 let mut remaining = (-delta) as usize;
1917 while remaining > 0 {
1918 if self.top_row > 0 {
1919 self.top_row -= 1;
1920 } else if self.top_line > 0 {
1921 self.top_line -= 1;
1922 let bytes = self.line_display_bytes(src, idx, self.top_line);
1923 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1924 self.top_row = line_rows.saturating_sub(1);
1925 } else {
1926 break;
1927 }
1928 remaining -= 1;
1929 }
1930 }
1931 }
1932
1933 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1934 let n = self.page_size
1935 .map(|p| p as i64)
1936 .unwrap_or_else(|| self.body_rows() as i64);
1937 self.scroll_lines(n, src, idx);
1938 }
1939
1940 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1941 let n = self.page_size
1942 .map(|p| p as i64)
1943 .unwrap_or_else(|| self.body_rows() as i64);
1944 self.scroll_lines(-n, src, idx);
1945 }
1946
1947 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1948 let n = (self.body_rows() / 2).max(1) as i64;
1949 self.scroll_lines(n, src, idx);
1950 }
1951
1952 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1953 let n = (self.body_rows() / 2).max(1) as i64;
1954 self.scroll_lines(-n, src, idx);
1955 }
1956
1957 pub fn goto_top(&mut self) {
1958 self.top_line = 0;
1959 self.top_row = 0;
1960 }
1961
1962 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1969 let body = self.body_rows() as usize;
1970 let total = idx.line_count();
1971 if total == 0 || body == 0 {
1972 return (0, 0);
1973 }
1974 let r_opts = self.render_opts(self.gutter_width(idx));
1975 let mut remaining = body;
1976 let mut line = total - 1;
1977 loop {
1978 let bytes = self.line_display_bytes(src, idx, line);
1979 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1980 if line_rows >= remaining {
1981 return (line, line_rows - remaining);
1982 }
1983 remaining -= line_rows;
1984 if line == 0 {
1985 return (0, 0);
1986 }
1987 line -= 1;
1988 }
1989 }
1990
1991 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1996 let body = self.body_rows() as usize;
1997 let n = self.visible_lines.len();
1998 if n == 0 || body == 0 {
1999 return (0, 0);
2000 }
2001 let r_opts = self.render_opts(self.gutter_width(idx));
2002 let mut remaining = body;
2003 let mut vi = n - 1;
2004 loop {
2005 let line = self.visible_lines[vi];
2006 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2007 if rows >= remaining {
2008 return (line, rows - remaining);
2009 }
2010 remaining -= rows;
2011 if vi == 0 {
2012 return (self.visible_lines[0], 0);
2013 }
2014 vi -= 1;
2015 }
2016 }
2017
2018 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2019 #[cfg(feature = "image")]
2020 if self.image_mode {
2021 let body = self.body_rows() as usize;
2022 self.top_line = self.image_total_rows().saturating_sub(body);
2023 self.top_row = 0;
2024 return;
2025 }
2026 idx.extend_to_end(src);
2027 if self.hide_mode() {
2028 self.extend_visible_lines(idx, src);
2029 let (line, row) = self.hide_bottom_anchor(src, idx);
2030 self.top_line = line;
2031 self.top_row = row;
2032 } else {
2033 let (line, row) = self.bottom_anchor(src, idx);
2034 self.top_line = line;
2035 self.top_row = row;
2036 }
2037 }
2038
2039 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2041 idx.extend_to_line(n, src);
2042 let target = n.min(idx.line_count().saturating_sub(1));
2043 self.top_line = target;
2044 self.top_row = 0;
2045 }
2046
2047 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2049 while idx.record_count() <= n && idx.scanned_through() < src.len() {
2053 idx.extend_to_end(src);
2054 }
2055 if idx.record_count() == 0 {
2056 return;
2057 }
2058 let target = n.min(idx.record_count().saturating_sub(1));
2059 let line_range = idx.record_line_range(target);
2060 self.top_line = line_range.start;
2061 self.top_row = 0;
2062 }
2063
2064 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
2067 let p = p.min(100) as usize;
2068 let target_byte = src.len().saturating_mul(p) / 100;
2069 idx.extend_to_byte_for_query(src, target_byte);
2070 let line_n = idx.line_at_byte(target_byte)
2071 .or_else(|| {
2072 let lc = idx.line_count();
2074 if lc > 0 { Some(lc - 1) } else { None }
2075 })
2076 .unwrap_or(0);
2077 self.top_line = line_n;
2078 self.top_row = 0;
2079 }
2080
2081 pub fn top_line(&self) -> usize {
2083 self.top_line
2084 }
2085
2086 pub fn resize(&mut self, cols: u16, rows: u16) {
2087 self.cols = cols.max(1);
2088 self.rows = rows.max(2);
2089 self.opts.cols = self.cols;
2090 }
2091
2092 pub fn toggle_line_numbers(&mut self) {
2093 self.show_line_numbers = !self.show_line_numbers;
2094 }
2095
2096 pub fn toggle_chop(&mut self) {
2097 self.opts.wrap = !self.opts.wrap;
2098 if self.opts.wrap {
2099 self.left_col = 0;
2100 }
2101 }
2102
2103 const HSCROLL_STEP: usize = 8;
2104
2105 pub fn hscroll_active(&self) -> bool {
2109 #[cfg(feature = "image")]
2110 if self.image.is_some() {
2111 return true;
2112 }
2113 !self.opts.wrap
2114 && !self.hex_mode
2115 && self.ansi_mode != crate::render::AnsiMode::Raw
2116 }
2117
2118 fn hscroll_by(&mut self, delta: isize) {
2119 if !self.hscroll_active() {
2120 return;
2121 }
2122 self.left_col = (self.left_col as isize + delta).max(0) as usize;
2123 }
2125
2126 pub fn hscroll_left_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2127 pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2128 pub fn hscroll_left_step(&mut self) { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2129 pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2130
2131 pub fn hscroll_left_cols(&mut self, n: u16) { self.hscroll_by(-(n as isize)); }
2133 pub fn hscroll_right_cols(&mut self, n: u16) { self.hscroll_by(n as isize); }
2135
2136 pub fn left_col(&self) -> usize { self.left_col }
2137
2138 pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2141
2142 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2146}
2147
2148#[cfg(test)]
2149mod tests {
2150 use super::*;
2151 use crate::source::MockSource;
2152
2153 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2154 let m = MockSource::new();
2155 m.append(content);
2156 m.finish();
2157 let idx = LineIndex::new();
2158 (m, idx)
2159 }
2160
2161 fn first_cell_char(row: &[Cell]) -> char {
2164 match row.first() {
2165 Some(Cell::Char { ch, .. }) => *ch,
2166 other => panic!("expected Char in first cell, got {:?}", other),
2167 }
2168 }
2169
2170 #[test]
2171 fn status_column_shows_mark_then_search_glyphs() {
2172 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2175 let mut v = Viewport::new(20, 5, "f".into()); v.opts.wrap = false;
2177 v.set_status_column(true);
2178 let mut marks = std::collections::HashMap::new();
2179 marks.insert(1usize, 'a');
2180 v.set_status_marks(marks);
2181 v.set_search("cc".into(), SearchDirection::Forward).unwrap();
2182
2183 let frame = v.frame(&m, &mut idx);
2184 assert_eq!(first_cell_char(&frame.body[0]), ' ', "line 0: no mark, no match");
2185 assert_eq!(first_cell_char(&frame.body[1]), 'a', "line 1: mark letter");
2186 assert_eq!(first_cell_char(&frame.body[2]), '*', "line 2: search match");
2187 }
2188
2189 #[test]
2190 fn status_column_mark_beats_search_match() {
2191 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2194 let mut v = Viewport::new(20, 5, "f".into());
2195 v.opts.wrap = false;
2196 v.set_status_column(true);
2197 let mut marks = std::collections::HashMap::new();
2198 marks.insert(1usize, 'z');
2199 v.set_status_marks(marks);
2200 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2201
2202 let frame = v.frame(&m, &mut idx);
2203 assert_eq!(first_cell_char(&frame.body[1]), 'z', "mark beats search-match");
2204 }
2205
2206 #[test]
2207 fn status_column_matches_content_not_gutter_digits() {
2208 let (m, mut idx) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2215 let mut v = Viewport::new(40, 14, "f".into()); v.opts.wrap = false;
2217 v.show_line_numbers = true;
2218 v.set_status_column(true);
2219 v.set_search("5".into(), SearchDirection::Forward).unwrap();
2220
2221 let frame = v.frame(&m, &mut idx);
2222 for i in 0..12 {
2226 assert_eq!(
2227 first_cell_char(&frame.body[i]), ' ',
2228 "body row {i}: no content match for '5' but status column flagged it"
2229 );
2230 }
2231
2232 let (m2, mut idx2) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2234 let mut v2 = Viewport::new(40, 14, "f".into());
2235 v2.opts.wrap = false;
2236 v2.show_line_numbers = true;
2237 v2.set_status_column(true);
2238 v2.set_search("ee".into(), SearchDirection::Forward).unwrap();
2239 let frame2 = v2.frame(&m2, &mut idx2);
2240 assert_eq!(first_cell_char(&frame2.body[4]), '*', "line 5 content 'ee' matches search");
2241 }
2242
2243 #[test]
2244 fn status_column_off_leaves_first_cell_as_content() {
2245 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2248 let mut v = Viewport::new(20, 5, "f".into());
2249 v.opts.wrap = false;
2250 let mut marks = std::collections::HashMap::new();
2252 marks.insert(1usize, 'a');
2253 v.set_status_marks(marks);
2254 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2255
2256 let frame = v.frame(&m, &mut idx);
2257 assert_eq!(first_cell_char(&frame.body[0]), 'a', "line 0 content unchanged");
2258 assert_eq!(first_cell_char(&frame.body[1]), 'b', "line 1 content unchanged");
2259 assert_eq!(first_cell_char(&frame.body[2]), 'c', "line 2 content unchanged");
2260 }
2261
2262 #[test]
2263 fn frame_renders_body_height_rows() {
2264 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2265 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
2267 assert_eq!(frame.body.len(), 4);
2268 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2269 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2270 }
2271
2272 #[test]
2273 fn scroll_down_advances_top_line() {
2274 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2277 let mut v = Viewport::new(10, 5, "test".into());
2278 v.scroll_lines(2, &m, &mut idx);
2279 assert_eq!(v.top_line, 2);
2280 assert_eq!(v.top_row, 0);
2281 }
2282
2283 #[test]
2284 fn scroll_up_clamps_at_zero() {
2285 let (m, mut idx) = setup(b"a\nb\nc\n");
2286 let mut v = Viewport::new(10, 5, "test".into());
2287 v.scroll_lines(-5, &m, &mut idx);
2288 assert_eq!(v.top_line, 0);
2289 assert_eq!(v.top_row, 0);
2290 }
2291
2292 #[test]
2293 fn scroll_down_clamps_at_last_line() {
2294 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2299 let mut v = Viewport::new(10, 5, "test".into());
2300 v.scroll_lines(50, &m, &mut idx);
2301 assert_eq!((v.top_line, v.top_row), (4, 0));
2302 assert!(v.is_at_bottom(&m, &idx));
2303 }
2304
2305 #[test]
2306 fn scroll_logical_lines_skips_wrap_rows() {
2307 let mut content = vec![b'X'; 500];
2309 content.push(b'\n');
2310 content.extend_from_slice(b"second\n");
2311 content.extend_from_slice(b"third\n");
2312 let (m, mut idx) = setup(&content);
2313 let mut v = Viewport::new(10, 8, "f".into());
2314 v.scroll_logical_lines(1, &m, &mut idx);
2315 assert_eq!((v.top_line, v.top_row), (1, 0));
2316 v.scroll_logical_lines(1, &m, &mut idx);
2317 assert_eq!((v.top_line, v.top_row), (2, 0));
2318 }
2319
2320 #[test]
2321 fn scroll_logical_lines_back_snaps_to_line_start() {
2322 let mut content = vec![b'A'; 50];
2327 content.push(b'\n');
2328 content.extend_from_slice(&[b'B'; 50]);
2329 content.push(b'\n');
2330 content.extend_from_slice(&[b'C'; 50]);
2331 content.push(b'\n');
2332 let (m, mut idx) = setup(&content);
2333 let mut v = Viewport::new(10, 8, "f".into());
2334 v.scroll_lines(7, &m, &mut idx);
2335 assert_eq!(v.top_line, 1, "should be on line 1");
2336 assert!(v.top_row > 0, "should be inside line 1's wraps");
2337 v.scroll_logical_lines(-1, &m, &mut idx);
2338 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2339 v.scroll_logical_lines(-1, &m, &mut idx);
2340 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2341 }
2342
2343 #[test]
2344 fn scroll_down_walks_wraps_of_last_line() {
2345 let mut content = b"first\n".to_vec();
2349 content.extend_from_slice(&[b'X'; 60]);
2350 content.push(b'\n');
2351 let (m, mut idx) = setup(&content);
2352 let mut v = Viewport::new(10, 5, "f".into());
2353 v.scroll_lines(1, &m, &mut idx);
2354 assert_eq!((v.top_line, v.top_row), (1, 0));
2355 v.scroll_lines(1, &m, &mut idx);
2356 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2357 v.scroll_lines(1, &m, &mut idx);
2358 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2359 v.scroll_lines(5, &m, &mut idx);
2361 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2362 }
2363
2364 #[test]
2365 fn scroll_down_walks_wrap_rows_within_long_line() {
2366 let mut content = vec![b'X'; 30];
2370 content.push(b'\n');
2371 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2372 let (m, mut idx) = setup(&content);
2373 let mut v = Viewport::new(10, 5, "f".into());
2374 v.scroll_lines(1, &m, &mut idx);
2375 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2376 v.scroll_lines(1, &m, &mut idx);
2377 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2378 v.scroll_lines(1, &m, &mut idx);
2379 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2380 }
2381
2382 #[test]
2383 fn status_line_shows_range_and_pct() {
2384 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2385 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2387 assert!(frame.status.starts_with("f 1-4/10"));
2388 }
2389
2390 #[test]
2391 fn page_down_advances_by_body_rows() {
2392 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2393 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2395 assert_eq!(v.top_line, 4);
2396 }
2397
2398 #[test]
2399 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2400 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2401 let mut v = Viewport::new(10, 5, "f".into());
2402 v.page_down(&m, &mut idx);
2403 v.page_up(&m, &mut idx);
2404 assert_eq!(v.top_line, 0);
2405 assert_eq!(v.top_row, 0);
2406 }
2407
2408 #[test]
2409 fn half_page_down_advances_by_half_body() {
2410 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2413 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2415 assert_eq!(v.top_line, 3);
2416 }
2417
2418 #[test]
2419 fn goto_top_resets_position() {
2420 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2421 let mut v = Viewport::new(10, 5, "f".into());
2422 v.scroll_lines(2, &m, &mut idx);
2423 v.goto_top();
2424 assert_eq!(v.top_line, 0);
2425 assert_eq!(v.top_row, 0);
2426 }
2427
2428 #[test]
2429 fn goto_bottom_scrolls_to_last_page() {
2430 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2431 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2433 assert_eq!(v.top_line, 6);
2435 }
2436
2437 #[cfg(feature = "image")]
2438 #[test]
2439 fn image_mode_frame_renders_and_scrolls() {
2440 use image::{Rgba, RgbaImage};
2441 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2442 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2444 assert!(v.image_mode());
2445 let total = v.image_total_rows();
2446 assert!(total > 5, "tall image should exceed the body");
2447 assert!(!v.is_at_bottom_image(), "starts at top");
2448 let mut idx = LineIndex::new();
2449 let m = MockSource::new();
2450 let frame = v.frame(&m, &mut idx);
2451 assert_eq!(frame.body.len(), 5);
2452 v.goto_bottom(&m, &mut idx);
2453 assert!(v.is_at_bottom_image());
2454 }
2455
2456 #[cfg(feature = "image")]
2457 #[test]
2458 fn frame_image_slices_at_left_col() {
2459 use crate::render::Cell;
2460 use image::{Rgba, RgbaImage};
2461
2462 let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2468 let mut v = Viewport::new(10, 4, "wide.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2470 assert!(v.hscroll_active(), "image mode should make hscroll active");
2471
2472 let mut idx = LineIndex::new();
2473 let m = MockSource::new();
2474
2475 assert_eq!(v.left_col(), 0);
2477 let frame0 = v.frame(&m, &mut idx);
2478 assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2479 assert_eq!(frame0.body[0].len(), 10);
2481 assert!(
2483 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2484 "no scroll marker expected on image frame at left_col=0"
2485 );
2486 let cell_at_col0 = frame0.body[0][0].clone();
2488 let cell_at_col8 = frame0.body[0][8].clone();
2489
2490 v.hscroll_right_step();
2492 assert_eq!(v.left_col(), 8);
2493 let frame1 = v.frame(&m, &mut idx);
2494 assert_eq!(frame1.body[0].len(), 10);
2495 assert!(
2497 !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2498 "no scroll marker expected on image frame after hscroll_right_step"
2499 );
2500 assert_eq!(
2502 frame1.body[0][0], cell_at_col8,
2503 "after hscroll_right_step the first visible cell should be grid col 8"
2504 );
2505 assert_ne!(
2509 frame1.body[0][0], cell_at_col0,
2510 "the scrolled first cell must differ from the unscrolled one"
2511 );
2512 }
2513
2514 #[test]
2515 fn goto_line_positions_top_line() {
2516 let m = MockSource::new();
2517 m.append(b"a\nb\nc\nd\ne\n");
2518 let mut idx = LineIndex::new();
2519 idx.extend_to_end(&m);
2520 let mut v = Viewport::new(20, 5, "f".into());
2521 v.goto_line(3, &m, &mut idx);
2522 assert_eq!(v.top_line(), 3);
2523 }
2524
2525 #[test]
2526 fn goto_line_clamps_to_last_line() {
2527 let m = MockSource::new();
2528 m.append(b"a\nb\n");
2529 let mut idx = LineIndex::new();
2530 idx.extend_to_end(&m);
2531 let mut v = Viewport::new(20, 5, "f".into());
2532 v.goto_line(999, &m, &mut idx);
2533 assert_eq!(v.top_line(), 1);
2534 }
2535
2536 #[test]
2537 fn goto_record_positions_at_record_start_line() {
2538 let m = MockSource::new();
2539 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
2540 let mut idx = LineIndex::new();
2541 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2542 idx.extend_to_end(&m);
2543 let mut v = Viewport::new(20, 5, "f".into());
2544 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2546 }
2547
2548 #[test]
2549 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2550 let m = MockSource::new();
2551 m.append(b"a\nb\nc\n");
2552 let mut idx = LineIndex::new();
2553 idx.extend_to_end(&m);
2554 let mut v = Viewport::new(20, 5, "f".into());
2555 v.goto_record(2, &m, &mut idx);
2556 assert_eq!(v.top_line(), 2);
2557 }
2558
2559 #[test]
2560 fn goto_percent_50_lands_in_middle() {
2561 let m = MockSource::new();
2562 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2564 idx.extend_to_end(&m);
2565 let mut v = Viewport::new(20, 5, "f".into());
2566 v.goto_percent(50, &m, &mut idx);
2567 assert_eq!(v.top_line(), 2); }
2569
2570 #[test]
2571 fn goto_percent_100_lands_at_last_line() {
2572 let m = MockSource::new();
2573 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2575 idx.extend_to_end(&m);
2576 let mut v = Viewport::new(20, 5, "f".into());
2577 v.goto_percent(100, &m, &mut idx);
2578 assert_eq!(v.top_line(), 2);
2579 }
2580
2581 #[test]
2582 fn goto_percent_0_lands_at_first_line() {
2583 let m = MockSource::new();
2584 m.append(b"a\nb\nc\n");
2585 let mut idx = LineIndex::new();
2586 idx.extend_to_end(&m);
2587 let mut v = Viewport::new(20, 5, "f".into());
2588 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2590 v.goto_percent(0, &m, &mut idx);
2591 assert_eq!(v.top_line(), 0);
2592 }
2593
2594 #[test]
2595 fn resize_updates_dimensions_and_render_opts() {
2596 let (m, mut idx) = setup(b"1\n2\n");
2597 let mut v = Viewport::new(10, 5, "f".into());
2598 v.resize(40, 12);
2599 assert_eq!(v.cols, 40);
2600 assert_eq!(v.rows, 12);
2601 assert_eq!(v.opts.cols, 40);
2602 let _ = v.frame(&m, &mut idx);
2603 }
2604
2605 #[test]
2606 fn toggle_line_numbers_changes_gutter() {
2607 let (m, mut idx) = setup(b"a\nb\nc\n");
2608 let mut v = Viewport::new(10, 5, "f".into());
2609 let frame_off = v.frame(&m, &mut idx);
2610 v.toggle_line_numbers();
2611 let frame_on = v.frame(&m, &mut idx);
2612 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2614 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2615 }
2616
2617 #[test]
2618 fn toggle_chop_changes_wrap_mode() {
2619 let (m, mut idx) = setup(b"abcdefghij\n");
2620 let mut v = Viewport::new(4, 5, "f".into());
2621 v.toggle_chop();
2622 let frame = v.frame(&m, &mut idx);
2623 assert_eq!(frame.body[0][..4],
2626 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2627 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2628 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2629 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2630 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2632 }
2633
2634 #[test]
2637 fn is_at_bottom_initially_only_when_source_fits() {
2638 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2641 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2642 }
2643
2644 #[test]
2645 fn is_at_bottom_false_when_top_and_more_lines_below() {
2646 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);
2649 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2650 }
2651
2652 #[test]
2653 fn is_at_bottom_true_after_goto_bottom() {
2654 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2655 let mut v = Viewport::new(10, 5, "f".into());
2656 v.goto_bottom(&m, &mut idx);
2657 assert!(v.is_at_bottom(&m, &idx));
2658 }
2659
2660 #[test]
2661 fn status_shows_follow_suffix_when_follow_mode_on() {
2662 let (m, mut idx) = setup(b"a\nb\n");
2663 let mut v = Viewport::new(20, 5, "f".into());
2664 let frame_off = v.frame(&m, &mut idx);
2665 assert!(!frame_off.status.contains("(F)"));
2666 v.set_follow_mode(true);
2667 let frame_on = v.frame(&m, &mut idx);
2668 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2669 }
2670
2671 #[test]
2672 fn toggle_follow_flips_state() {
2673 let mut v = Viewport::new(10, 5, "f".into());
2674 assert!(!v.follow_mode());
2675 v.toggle_follow();
2676 assert!(v.follow_mode());
2677 v.toggle_follow();
2678 assert!(!v.follow_mode());
2679 }
2680
2681 #[test]
2682 fn idle_indicator_kicks_in_at_threshold() {
2683 let (m, mut idx) = setup(b"a\nb\n");
2684 let mut v = Viewport::new(20, 5, "f".into());
2685 v.set_follow_mode(true);
2686 for _ in 0..19 { v.tick_idle(); }
2688 let f1 = v.frame(&m, &mut idx);
2689 assert!(f1.status.contains("(F)"));
2690 assert!(!f1.status.contains("idle"));
2691 v.tick_idle();
2693 let f2 = v.frame(&m, &mut idx);
2694 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2695 }
2696
2697 #[test]
2698 fn note_growth_resets_idle() {
2699 let (m, mut idx) = setup(b"a\nb\n");
2700 let mut v = Viewport::new(20, 5, "f".into());
2701 v.set_follow_mode(true);
2702 for _ in 0..25 { v.tick_idle(); }
2703 assert!(v.is_idle());
2704 v.note_growth();
2705 assert!(!v.is_idle());
2706 let f = v.frame(&m, &mut idx);
2707 assert!(!f.status.contains("idle"));
2708 }
2709
2710 #[test]
2711 fn qae_off_never_quits_even_at_bottom() {
2712 let (m, mut idx) = setup(b"a\n");
2713 let mut v = Viewport::new(20, 5, "f".into());
2714 v.set_quit_at_eof(QuitAtEof::Off);
2715 v.goto_bottom(&m, &mut idx);
2716 assert!(!v.note_motion_for_eof(true, &m, &idx));
2717 }
2718
2719 #[test]
2720 fn qae_first_quits_immediately_at_bottom() {
2721 let (m, mut idx) = setup(b"a\n");
2722 let mut v = Viewport::new(20, 5, "f".into());
2723 v.set_quit_at_eof(QuitAtEof::First);
2724 v.goto_bottom(&m, &mut idx);
2725 assert!(v.note_motion_for_eof(true, &m, &idx));
2726 }
2727
2728 #[test]
2729 fn qae_first_only_quits_at_eof_not_mid_file() {
2730 let mut content = Vec::new();
2731 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2732 let (m, mut idx) = setup(&content);
2733 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2735 v.set_quit_at_eof(QuitAtEof::First);
2736 assert!(!v.is_at_bottom(&m, &idx));
2738 assert!(!v.note_motion_for_eof(true, &m, &idx));
2739 }
2740
2741 #[test]
2742 fn qae_second_quits_on_second_hit() {
2743 let (m, mut idx) = setup(b"a\n");
2744 let mut v = Viewport::new(20, 5, "f".into());
2745 v.set_quit_at_eof(QuitAtEof::Second);
2746 v.goto_bottom(&m, &mut idx);
2747 assert!(!v.note_motion_for_eof(true, &m, &idx));
2749 assert!(v.note_motion_for_eof(true, &m, &idx));
2751 }
2752
2753 #[test]
2754 fn squeeze_collapses_consecutive_blanks() {
2755 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2757 let mut v = Viewport::new(10, 8, "f".into());
2758 v.set_squeeze_blanks(true);
2759 let f = v.frame(&m, &mut idx);
2760 let stringify = |row: &Vec<Cell>| -> String {
2762 row.iter().filter_map(|c| match c {
2763 Cell::Char { ch, .. } => Some(*ch),
2764 _ => None,
2765 }).collect::<String>().trim().to_string()
2766 };
2767 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2768 assert_eq!(&rows[0], "a");
2770 assert_eq!(&rows[1], "");
2771 assert_eq!(&rows[2], "b");
2772 }
2773
2774 #[test]
2775 fn header_pins_top_rows_when_scrolling() {
2776 let mut content = Vec::new();
2778 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2779 let (m, mut idx) = setup(&content);
2780 let mut v = Viewport::new(20, 6, "f".into());
2781 v.set_header(2, 0);
2782 v.scroll_lines(5, &m, &mut idx);
2786 let f = v.frame(&m, &mut idx);
2787 let chs = |row: &Vec<Cell>| -> String {
2788 row.iter().filter_map(|c| match c {
2789 Cell::Char { ch, .. } => Some(*ch),
2790 _ => None,
2791 }).collect::<String>().trim().to_string()
2792 };
2793 assert_eq!(&chs(&f.body[0]), "line0");
2795 assert_eq!(&chs(&f.body[1]), "line1");
2796 assert_eq!(&chs(&f.body[2]), "line7");
2798 }
2799
2800 #[test]
2801 fn page_size_when_set_overrides_body_rows() {
2802 let mut content = Vec::new();
2803 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2804 let (m, mut idx) = setup(&content);
2805 let mut v = Viewport::new(20, 10, "f".into());
2806 v.set_page_size(Some(3));
2807 let before = v.top_line();
2808 v.page_down(&m, &mut idx);
2809 assert_eq!(v.top_line(), before + 3);
2810 v.page_up(&m, &mut idx);
2811 assert_eq!(v.top_line(), before);
2812 }
2813
2814 #[test]
2815 fn page_size_unset_uses_body_rows() {
2816 let mut content = Vec::new();
2817 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2818 let (m, mut idx) = setup(&content);
2819 let mut v = Viewport::new(20, 10, "f".into());
2820 v.page_down(&m, &mut idx);
2822 assert_eq!(v.top_line(), 9);
2823 }
2824
2825 #[test]
2826 fn header_zero_lines_renders_like_no_header() {
2827 let mut content = Vec::new();
2828 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2829 let (m, mut idx) = setup(&content);
2830 let mut v = Viewport::new(20, 6, "f".into());
2831 v.set_header(0, 0);
2832 let f = v.frame(&m, &mut idx);
2833 let chs = |row: &Vec<Cell>| -> String {
2834 row.iter().filter_map(|c| match c {
2835 Cell::Char { ch, .. } => Some(*ch),
2836 _ => None,
2837 }).collect::<String>().trim().to_string()
2838 };
2839 assert_eq!(&chs(&f.body[0]), "line0");
2840 assert_eq!(&chs(&f.body[1]), "line1");
2841 }
2842
2843 #[test]
2844 fn squeeze_off_preserves_blanks() {
2845 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2846 let mut v = Viewport::new(10, 8, "f".into());
2847 let f = v.frame(&m, &mut idx);
2849 let stringify = |row: &Vec<Cell>| -> String {
2850 row.iter().filter_map(|c| match c {
2851 Cell::Char { ch, .. } => Some(*ch),
2852 _ => None,
2853 }).collect::<String>().trim().to_string()
2854 };
2855 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2856 assert_eq!(&rows[0], "a");
2858 assert_eq!(&rows[1], "");
2859 assert_eq!(&rows[2], "");
2860 assert_eq!(&rows[3], "");
2861 assert_eq!(&rows[4], "b");
2862 }
2863
2864 #[test]
2865 fn qae_second_resets_on_backward_motion() {
2866 let (m, mut idx) = setup(b"a\n");
2867 let mut v = Viewport::new(20, 5, "f".into());
2868 v.set_quit_at_eof(QuitAtEof::Second);
2869 v.goto_bottom(&m, &mut idx);
2870 assert!(!v.note_motion_for_eof(true, &m, &idx));
2871 v.note_motion_for_eof(false, &m, &idx);
2873 assert!(!v.note_motion_for_eof(true, &m, &idx));
2875 assert!(v.note_motion_for_eof(true, &m, &idx));
2877 }
2878
2879 #[test]
2880 fn flash_message_overrides_follow_suffix() {
2881 let (m, mut idx) = setup(b"a\nb\n");
2882 let mut v = Viewport::new(40, 5, "f".into());
2883 v.set_follow_mode(true);
2884 v.flash("(F reopened)", 3);
2885 let f = v.frame(&m, &mut idx);
2886 assert!(f.status.contains("(F reopened)"), "{}", f.status);
2887 assert!(!f.status.contains("(F idle)"));
2888 }
2889
2890 #[test]
2891 fn flash_countdown_clears() {
2892 let mut v = Viewport::new(10, 5, "f".into());
2893 v.flash("hello", 2);
2894 v.tick_flash();
2895 assert!(v.status_flash.is_some());
2896 v.tick_flash();
2897 assert!(v.status_flash.is_none());
2898 }
2899
2900 #[test]
2901 fn suspend_follow_if_off_is_noop() {
2902 let mut v = Viewport::new(10, 5, "f".into());
2903 v.set_follow_mode(true);
2904 v.suspend_follow_if(false);
2905 assert!(v.follow_mode());
2906 }
2907
2908 #[test]
2909 fn suspend_follow_if_on_flips_off() {
2910 let mut v = Viewport::new(10, 5, "f".into());
2911 v.set_follow_mode(true);
2912 v.suspend_follow_if(true);
2913 assert!(!v.follow_mode());
2914 }
2915
2916 #[test]
2917 fn case_mode_sensitive_returns_pattern_unchanged() {
2918 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2919 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2920 }
2921
2922 #[test]
2923 fn case_mode_insensitive_prepends_i_flag() {
2924 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2925 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2926 }
2927
2928 #[test]
2929 fn case_mode_smart_lowercase_is_insensitive() {
2930 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2931 }
2932
2933 #[test]
2934 fn case_mode_smart_with_uppercase_is_sensitive() {
2935 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2936 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2937 }
2938
2939 #[test]
2940 fn set_case_mode_recompiles_active_search() {
2941 let (m, mut idx) = setup(b"hello WORLD\n");
2942 let mut v = Viewport::new(40, 5, "f".into());
2943 v.set_search("world".into(), SearchDirection::Forward).unwrap();
2944 assert!(!v.search_repeat(&m, &mut idx, false));
2946 v.set_case_mode(CaseMode::Insensitive);
2948 assert!(v.search_repeat(&m, &mut idx, false));
2949 }
2950
2951 #[test]
2952 fn status_shows_prettify_label_when_set() {
2953 let (m, mut idx) = setup(b"a\n");
2954 let mut v = Viewport::new(40, 5, "f".into());
2955 let frame_off = v.frame(&m, &mut idx);
2956 assert!(!frame_off.status.contains("[pretty"));
2957 v.set_prettify_label(Some("json".into()));
2958 let frame_on = v.frame(&m, &mut idx);
2959 assert!(frame_on.status.contains("[pretty:json]"),
2960 "expected [pretty:json] in status, got: {}", frame_on.status);
2961 v.set_prettify_label(Some("json:err".into()));
2962 let frame_err = v.frame(&m, &mut idx);
2963 assert!(frame_err.status.contains("[pretty:json:err]"),
2964 "expected [pretty:json:err] in status, got: {}", frame_err.status);
2965 }
2966
2967 #[test]
2968 fn status_shows_l_suffix_when_live_mode_on() {
2969 let (m, mut idx) = setup(b"a\nb\n");
2970 let mut v = Viewport::new(20, 5, "f".into());
2971 let frame_off = v.frame(&m, &mut idx);
2972 assert!(!frame_off.status.contains("(L)"));
2973 v.set_live_mode(true);
2974 let frame_on = v.frame(&m, &mut idx);
2975 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2976 }
2977
2978 #[test]
2979 fn clamp_top_line_pulls_back_when_total_shrinks() {
2980 let mut v = Viewport::new(20, 5, "f".into());
2981 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
2990 let (m, mut idx) = setup(b"only\n");
2992 let _ = v.frame(&m, &mut idx);
2993 }
2994
2995 fn simulate_growth_tick(
2998 v: &mut Viewport,
2999 src: &MockSource,
3000 idx: &mut LineIndex,
3001 ) {
3002 if !v.follow_mode() { return; }
3003 let was_at_bottom = v.is_at_bottom(src, idx);
3004 let lines_before = idx.line_count();
3005 idx.notice_new_bytes(src);
3006 if idx.line_count() != lines_before && was_at_bottom {
3007 v.goto_bottom(src, idx);
3008 }
3009 }
3010
3011 #[test]
3012 fn auto_scroll_engages_when_at_bottom() {
3013 let m = MockSource::new();
3014 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
3016 let mut v = Viewport::new(10, 5, "f".into());
3017 v.set_follow_mode(true);
3018 idx.extend_to_end(&m);
3019 assert!(v.is_at_bottom(&m, &idx));
3020 let top_before = {
3021 let f = v.frame(&m, &mut idx);
3022 f.status.clone() };
3024 let _ = top_before;
3025 m.append(b"5\n6\n7\n8\n");
3027 simulate_growth_tick(&mut v, &m, &mut idx);
3028 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
3030 let frame = v.frame(&m, &mut idx);
3031 let last_row = &frame.body[frame.body.len() - 1];
3034 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
3035 }
3036
3037 #[test]
3038 fn auto_scroll_suppressed_when_scrolled_up() {
3039 let m = MockSource::new();
3040 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
3042 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
3044 idx.extend_to_end(&m);
3045 v.goto_bottom(&m, &mut idx);
3046 v.scroll_lines(-2, &m, &mut idx);
3048 assert!(!v.is_at_bottom(&m, &idx));
3049 let frame_before = v.frame(&m, &mut idx);
3050 let top_first_cell_before = frame_before.body[0][0].clone();
3051 m.append(b"9\n10\n");
3053 simulate_growth_tick(&mut v, &m, &mut idx);
3054 let frame_after = v.frame(&m, &mut idx);
3056 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
3057 }
3058
3059 #[test]
3062 fn set_search_compiles_regex() {
3063 let mut v = Viewport::new(10, 5, "f".into());
3064 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
3065 assert!(v.search_active());
3066 }
3067
3068 #[test]
3069 fn set_search_rejects_bad_regex() {
3070 let mut v = Viewport::new(10, 5, "f".into());
3071 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
3072 assert!(!err.is_empty());
3073 assert!(!v.search_active(), "no search should be set on error");
3074 }
3075
3076 #[test]
3077 fn search_step_forward_finds_match_after_top() {
3078 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3079 let mut v = Viewport::new(20, 5, "f".into());
3080 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3081 let found = v.search_repeat(&m, &mut idx, false);
3082 assert!(found);
3083 assert_eq!(v.top_line, 2);
3085 }
3086
3087 #[test]
3088 fn search_step_backward_finds_match_before_top() {
3089 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3090 let mut v = Viewport::new(20, 5, "f".into());
3091 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
3093 let found = v.search_repeat(&m, &mut idx, false);
3094 assert!(found);
3095 assert_eq!(v.top_line, 0);
3096 }
3097
3098 #[test]
3099 fn search_wraps_at_end() {
3100 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3101 let mut v = Viewport::new(20, 5, "f".into());
3102 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
3104 let found = v.search_repeat(&m, &mut idx, false);
3105 assert!(found, "search should wrap forward past EOF");
3106 assert_eq!(v.top_line, 0);
3107 }
3108
3109 #[test]
3110 fn search_no_match_returns_false_and_does_not_move() {
3111 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3112 let mut v = Viewport::new(20, 5, "f".into());
3113 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
3114 let found = v.search_repeat(&m, &mut idx, false);
3115 assert!(!found);
3116 assert_eq!(v.top_line, 0);
3117 }
3118
3119 #[test]
3120 fn frame_records_highlight_ranges_for_matches() {
3121 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
3122 let mut v = Viewport::new(20, 5, "f".into());
3123 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3124 let frame = v.frame(&m, &mut idx);
3125 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3127 assert!(frame.highlights[0].is_empty());
3128 assert!(frame.highlights[1].is_empty());
3129 assert_eq!(frame.highlights[2], vec![0..5]);
3130 assert!(frame.highlights[3].is_empty());
3131 }
3132
3133 #[test]
3134 fn frame_highlights_substring_inside_a_row() {
3135 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
3136 let mut v = Viewport::new(40, 5, "f".into());
3137 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3138 let frame = v.frame(&m, &mut idx);
3139 assert_eq!(frame.highlights[0], vec![18..22]);
3141 assert!(frame.highlights[1].is_empty());
3142 }
3143
3144 #[test]
3145 fn search_highlight_with_filter_dim_keeps_row_dim() {
3146 let (m, mut idx) = setup(b"alpha\nbeta\n");
3149 let mut v = Viewport::new(20, 5, "f".into());
3150 let fmt = crate::format::LogFormat::compile(
3151 "simple",
3152 r"^(?P<line>.+)$",
3153 )
3154 .unwrap();
3155 let f = crate::filter::CompiledFilter::compile(
3156 &fmt,
3157 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
3158 CaseMode::Sensitive,
3159 )
3160 .unwrap();
3161 v.set_filter(Some(f));
3162 v.set_dim_mode(true);
3163 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3164 let frame = v.frame(&m, &mut idx);
3165 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3166 assert_eq!(frame.row_styles[1], RowStyle::Dim);
3167 assert_eq!(frame.highlights[1], vec![0..4]);
3168 }
3169
3170 #[test]
3171 fn grep_only_hides_non_matching_lines() {
3172 use crate::grep::GrepPredicate;
3173 let src = crate::source::MockSource::new();
3174 src.append(b"keep this error\n");
3175 src.append(b"drop this one\n");
3176 src.append(b"another error line\n");
3177 src.finish();
3178 let mut idx = crate::line_index::LineIndex::new();
3179 idx.extend_to_end(&src);
3180
3181 let mut v = Viewport::new(40, 5, "test".into());
3182 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
3183 v.extend_visible_lines(&idx, &src);
3184
3185 let frame = v.frame(&src, &mut idx);
3187 let body_text: Vec<String> = frame.body.iter()
3188 .map(|row| row.iter().filter_map(|c| match c {
3189 crate::render::Cell::Char { ch, .. } => Some(*ch),
3190 _ => None,
3191 }).collect())
3192 .collect();
3193 assert!(body_text[0].contains("keep this error"));
3194 assert!(body_text[1].contains("another error line"));
3195 assert!(frame.status.contains("[grep]"));
3196 }
3197
3198 #[test]
3199 fn incsearch_preview_anchors_from_origin_not_previous_match() {
3200 let src = crate::source::MockSource::new();
3208 src.append(b"zero\n"); src.append(b"one\n"); src.append(b"origin\n"); src.append(b"three\n"); src.append(b"mark\n"); src.append(b"five\n"); src.append(b"six\n"); src.append(b"seven\n"); src.append(b"target\n"); src.append(b"mark\n"); src.finish();
3219 let mut idx = crate::line_index::LineIndex::new();
3220
3221 let origin = (2usize, 0usize);
3222 let mut vp = Viewport::new(20, 4, "test".into()); vp.set_top(origin.0, origin.1);
3224 assert_eq!(vp.top_line(), 2);
3225
3226 vp.incsearch_preview(&src, &mut idx, "target", SearchDirection::Forward, origin);
3228 assert_eq!(vp.top_line(), 8, "should land on the far-below match");
3229 assert_eq!(vp.top_row(), 0);
3230
3231 vp.incsearch_preview(&src, &mut idx, "mark", SearchDirection::Forward, origin);
3237 assert_eq!(
3238 vp.top_line(), 4,
3239 "preview must reset to origin before scanning, landing on the match \
3240 after origin rather than continuing forward from the previous match"
3241 );
3242 assert_eq!(vp.top_row(), 0);
3243 }
3244
3245 #[test]
3246 fn incsearch_preview_empty_or_invalid_is_noop() {
3247 let (src, mut idx) = setup(b"alpha\nbeta\n[unbalanced\n");
3248 let mut vp = Viewport::new(20, 4, "test".into());
3249 vp.set_top(1, 0);
3250 vp.incsearch_preview(&src, &mut idx, "", SearchDirection::Forward, (0, 0));
3252 assert_eq!(vp.top_line(), 1);
3253 vp.incsearch_preview(&src, &mut idx, "(", SearchDirection::Forward, (0, 0));
3255 assert_eq!(vp.top_line(), 0);
3256 }
3257
3258 #[test]
3259 fn filter_and_grep_combine_with_and() {
3260 use crate::grep::GrepPredicate;
3261 let fmt = crate::format::LogFormat::compile(
3262 "simple",
3263 r"^(?P<level>\w+) (?P<msg>.+)$",
3264 ).unwrap();
3265 let f = crate::filter::CompiledFilter::compile(
3266 &fmt,
3267 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
3268 CaseMode::Sensitive,
3269 ).unwrap();
3270 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3271
3272 let src = crate::source::MockSource::new();
3273 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();
3278 let mut idx = crate::line_index::LineIndex::new();
3279 idx.extend_to_end(&src);
3280
3281 let mut v = Viewport::new(80, 5, "test".into());
3282 v.set_filter(Some(f));
3283 v.set_grep(Some(g));
3284 v.extend_visible_lines(&idx, &src);
3285 assert_eq!(v.visible_lines(), &[0usize]);
3286 }
3287
3288 #[test]
3289 fn search_status_shows_pattern() {
3290 let (m, mut idx) = setup(b"x\n");
3291 let mut v = Viewport::new(20, 5, "f".into());
3292 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3293 let frame = v.frame(&m, &mut idx);
3294 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3295 }
3296
3297 #[test]
3298 fn repeat_search_after_first_match_advances() {
3299 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3300 let mut v = Viewport::new(40, 5, "f".into());
3301 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3302 assert!(v.search_repeat(&m, &mut idx, false));
3303 assert_eq!(v.top_line, 1, "first foo");
3304 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3305 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3306 assert_eq!(v.top_line, 3, "should advance to next foo");
3307 }
3308
3309 #[test]
3310 fn auto_scroll_paused_when_follow_off() {
3311 let m = MockSource::new();
3312 m.append(b"1\n2\n3\n4\n");
3313 let mut idx = LineIndex::new();
3314 let mut v = Viewport::new(10, 5, "f".into());
3315 idx.extend_to_end(&m);
3317 let frame_before = v.frame(&m, &mut idx);
3318 let top_first_cell = frame_before.body[0][0].clone();
3319 m.append(b"5\n6\n7\n8\n");
3320 simulate_growth_tick(&mut v, &m, &mut idx);
3321 let frame_after = v.frame(&m, &mut idx);
3322 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3323 }
3324
3325 #[test]
3328 fn search_jumps_to_next_matching_record() {
3329 let m = MockSource::new();
3330 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
3331 let mut idx = LineIndex::new();
3332 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3333 idx.extend_to_end(&m);
3334 let mut v = Viewport::new(40, 10, "f".into());
3335 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3336 let hit = v.search_repeat(&m, &mut idx, false);
3337 assert!(hit, "should find 'charlie' in record 2");
3338 assert_eq!(v.top_line(), 3); }
3340
3341 #[test]
3342 fn search_finds_cross_line_match_in_record_with_s_flag() {
3343 let m = MockSource::new();
3344 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
3345 let mut idx = LineIndex::new();
3346 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3347 idx.extend_to_end(&m);
3348 let mut v = Viewport::new(40, 10, "f".into());
3349 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3350 let hit = v.search_repeat(&m, &mut idx, false);
3351 assert!(hit, "should match across \\n inside record 0 with (?s)");
3352 assert_eq!(v.top_line(), 0);
3353 }
3354
3355 #[test]
3356 fn search_repeat_with_no_match_returns_false() {
3357 let m = MockSource::new();
3358 m.append(b"[1] alpha\n[2] bravo\n");
3359 let mut idx = LineIndex::new();
3360 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3361 idx.extend_to_end(&m);
3362 let mut v = Viewport::new(40, 10, "f".into());
3363 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3364 let hit = v.search_repeat(&m, &mut idx, false);
3365 assert!(!hit);
3366 }
3367
3368 #[test]
3371 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3372 let m = MockSource::new();
3375 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
3376 let mut idx = LineIndex::new();
3377 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3378 idx.extend_to_end(&m);
3379 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3380 let mut v = Viewport::new(40, 10, "f".into());
3381 v.set_grep(Some(grep));
3382 v.extend_visible_lines(&idx, &m);
3383 assert_eq!(v.visible_lines(), &[0usize, 1]);
3386 }
3387
3388 #[test]
3389 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3390 let m = MockSource::new();
3396 m.append(
3397 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
3398 );
3399 let mut idx = LineIndex::new();
3400 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3401 idx.extend_to_end(&m);
3402 let fmt = crate::format::LogFormat::compile(
3403 "rec",
3404 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3405 )
3406 .unwrap();
3407 let f = crate::filter::CompiledFilter::compile(
3408 &fmt,
3409 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3410 CaseMode::Sensitive,
3411 )
3412 .unwrap();
3413 let mut v = Viewport::new(40, 10, "f".into());
3414 v.set_filter(Some(f));
3415 v.extend_visible_lines(&idx, &m);
3416 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3418 }
3419
3420 #[test]
3421 fn grep_matches_across_record_newlines_in_records_mode() {
3422 let m = MockSource::new();
3424 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
3425 let mut idx = LineIndex::new();
3426 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3427 idx.extend_to_end(&m);
3428 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3429 let mut v = Viewport::new(40, 10, "f".into());
3430 v.set_grep(Some(grep));
3431 v.extend_visible_lines(&idx, &m);
3432 assert_eq!(v.visible_lines(), &[0usize, 1]);
3434 }
3435
3436 #[test]
3437 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3438 let m = MockSource::new();
3441 m.append(b"[1] head\n cont\n[2] other\n cont\n");
3442 let mut idx = LineIndex::new();
3443 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3444 idx.extend_to_end(&m);
3445 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3446 let mut v = Viewport::new(40, 10, "f".into());
3447 v.set_grep(Some(grep));
3448 v.set_dim_mode(true);
3449 v.extend_visible_lines(&idx, &m);
3450 assert_eq!(v.visible_lines(), &[] as &[usize]);
3452 assert!(!v.should_dim_line(0, &idx, &m));
3454 assert!(!v.should_dim_line(1, &idx, &m));
3455 assert!(v.should_dim_line(2, &idx, &m));
3457 assert!(v.should_dim_line(3, &idx, &m));
3458 }
3459
3460 #[test]
3461 fn status_unchanged_when_records_inactive() {
3462 let (m, mut idx) = setup(b"a\nb\nc\n");
3463 let mut v = Viewport::new(20, 5, "f".into());
3464 let frame = v.frame(&m, &mut idx);
3465 let status = &frame.status;
3466 assert!(status.contains("1-3/3"), "got: {status}");
3468 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3469 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3470 }
3471
3472 #[test]
3473 fn status_r_block_uses_real_lines_in_hide_mode() {
3474 let m = MockSource::new();
3483 let mut buf = Vec::new();
3486 for n in 0..10 {
3487 let kind = if n >= 8 { "B" } else { "A" };
3488 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3489 }
3490 m.append(&buf);
3491 m.finish();
3492
3493 let mut idx = LineIndex::new();
3494 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3495 idx.extend_to_end(&m);
3496
3497 let fmt = crate::format::LogFormat::compile(
3498 "rec",
3499 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3500 )
3501 .unwrap();
3502 let f = crate::filter::CompiledFilter::compile(
3503 &fmt,
3504 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3505 CaseMode::Sensitive,
3506 )
3507 .unwrap();
3508
3509 let mut v = Viewport::new(80, 5, "f".into());
3512 v.set_filter(Some(f));
3513 v.extend_visible_lines(&idx, &m);
3514
3515 v.goto_record(8, &m, &mut idx);
3517
3518 let frame = v.frame(&m, &mut idx);
3519 assert!(
3521 frame.status.contains("R9-10/10"),
3522 "expected R9-10/10 in status, got: {}",
3523 frame.status,
3524 );
3525 }
3526
3527 #[test]
3528 fn status_dual_readout_when_records_active() {
3529 let m = MockSource::new();
3530 m.append(b"[1] a\n cont\n[2] b\n");
3531 m.finish();
3532 let mut idx = LineIndex::new();
3533 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3534 idx.extend_to_end(&m);
3535 let mut v = Viewport::new(20, 5, "f".into());
3536 let frame = v.frame(&m, &mut idx);
3537 let status = &frame.status;
3538 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3539 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3540 }
3541
3542 #[test]
3543 fn format_status_uses_custom_template_when_set() {
3544 let m = MockSource::new();
3545 m.append(b"a\nb\nc\n");
3546 m.finish();
3547 let mut idx = LineIndex::new();
3548 idx.extend_to_end(&m);
3549 let mut v = Viewport::new(20, 5, "f".into());
3550 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3551 v.set_prompt(Some(prompt));
3552 let frame = v.frame(&m, &mut idx);
3553 assert_eq!(frame.status, "f 100%");
3554 }
3555
3556 #[test]
3557 fn status_shows_preprocess_failed_tag_when_set() {
3558 let m = MockSource::new();
3559 m.append(b"a\n");
3560 let mut idx = LineIndex::new();
3561 idx.extend_to_end(&m);
3562 let mut v = Viewport::new(40, 5, "f".into());
3563 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3564 let frame = v.frame(&m, &mut idx);
3565 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3566 "got: {}", frame.status);
3567 }
3568
3569 #[test]
3570 fn default_status_includes_help_hint() {
3571 let (m, mut idx) = setup(b"a\nb\nc\n");
3572 let mut v = Viewport::new(80, 5, "f".into());
3573 let frame = v.frame(&m, &mut idx);
3574 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3575 }
3576
3577 #[test]
3578 fn custom_prompt_does_not_get_help_hint() {
3579 let (m, mut idx) = setup(b"a\nb\nc\n");
3580 let mut v = Viewport::new(80, 5, "f".into());
3581 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3582 let frame = v.frame(&m, &mut idx);
3583 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3584 }
3585
3586 #[test]
3587 fn status_shows_file_index_when_multifile() {
3588 let m = MockSource::new();
3589 m.append(b"a\n");
3590 let mut idx = LineIndex::new();
3591 idx.extend_to_end(&m);
3592 let mut v = Viewport::new(60, 5, "f.log".into());
3593 v.set_file_index(0, 3);
3594 let frame = v.frame(&m, &mut idx);
3595 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3596 }
3597
3598 #[test]
3599 fn status_omits_file_index_when_single_file() {
3600 let m = MockSource::new();
3601 m.append(b"a\n");
3602 let mut idx = LineIndex::new();
3603 idx.extend_to_end(&m);
3604 let mut v = Viewport::new(60, 5, "f.log".into());
3605 v.set_file_index(0, 1);
3606 let frame = v.frame(&m, &mut idx);
3607 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3608 }
3609
3610 #[test]
3611 fn status_shows_tag_active_when_multimatch() {
3612 let m = MockSource::new();
3613 m.append(b"a\n");
3614 let mut idx = LineIndex::new();
3615 idx.extend_to_end(&m);
3616 let mut v = Viewport::new(80, 5, "f.log".into());
3617 v.set_tag_active(Some(("foo".into(), 2, 3)));
3618 let frame = v.frame(&m, &mut idx);
3619 assert!(
3620 frame.status.contains("[tag: foo (2/3)]"),
3621 "got: {}",
3622 frame.status
3623 );
3624 }
3625
3626 #[test]
3627 fn status_omits_tag_active_when_single_match() {
3628 let m = MockSource::new();
3629 m.append(b"a\n");
3630 let mut idx = LineIndex::new();
3631 idx.extend_to_end(&m);
3632 let mut v = Viewport::new(80, 5, "f.log".into());
3633 v.set_tag_active(Some(("foo".into(), 1, 1)));
3634 let frame = v.frame(&m, &mut idx);
3635 assert!(
3636 !frame.status.contains("[tag:"),
3637 "should not show indicator for single match: {}",
3638 frame.status
3639 );
3640 }
3641
3642 #[test]
3643 fn hscroll_noop_when_wrapping() {
3644 let mut v = Viewport::new(80, 24, "t".into());
3645 v.hscroll_right_step();
3647 assert_eq!(v.left_col(), 0);
3648 }
3649
3650 #[test]
3651 fn hscroll_active_in_chop_and_clamps_at_zero() {
3652 let mut v = Viewport::new(80, 24, "t".into());
3653 v.toggle_chop(); assert!(v.hscroll_active());
3655 v.hscroll_right_step();
3656 assert_eq!(v.left_col(), 8);
3657 v.hscroll_right_half();
3658 assert_eq!(v.left_col(), 8 + 40); v.hscroll_left_half();
3660 assert_eq!(v.left_col(), 8);
3661 v.hscroll_left_half();
3662 assert_eq!(v.left_col(), 0); }
3664
3665 #[test]
3666 fn hscroll_by_explicit_cols_moves_left_col() {
3667 let mut v = Viewport::new(80, 24, "t".into());
3669 v.toggle_chop(); v.hscroll_right_cols(12);
3671 assert_eq!(v.left_col(), 12);
3672 v.hscroll_right_cols(12);
3673 assert_eq!(v.left_col(), 24);
3674 v.hscroll_left_cols(12);
3675 assert_eq!(v.left_col(), 12);
3676 v.hscroll_left_cols(99);
3677 assert_eq!(v.left_col(), 0); }
3679
3680 #[test]
3681 fn hscroll_resets_to_zero_when_wrap_turned_on() {
3682 let mut v = Viewport::new(80, 24, "t".into());
3683 v.toggle_chop(); v.hscroll_right_step();
3685 assert_eq!(v.left_col(), 8);
3686 v.toggle_chop(); assert_eq!(v.left_col(), 0);
3688 }
3689
3690 #[test]
3691 fn reset_hscroll_zeroes_left_col() {
3692 let mut v = Viewport::new(80, 24, "t".into());
3694 v.toggle_chop();
3695 v.hscroll_right_step();
3696 assert_eq!(v.left_col(), 8);
3697 v.reset_hscroll();
3698 assert_eq!(v.left_col(), 0);
3699 }
3700
3701 #[test]
3704 fn reconstruct_picks_up_state_from_prior_lines() {
3705 let m = MockSource::new();
3706 m.append(b"\x1b[31mline 1\n");
3707 m.append(b"line 2 (still red, no reset)\n");
3708 m.append(b"line 3\n");
3709 let mut idx = LineIndex::new();
3710 idx.extend_to_end(&m);
3711 let state = reconstruct_render_state(&m, &idx, 2);
3712 assert_eq!(
3713 state.style.fg,
3714 Some(crate::ansi::Color::Ansi(1)),
3715 "red SGR from line 0 should persist to line 2"
3716 );
3717 }
3718
3719 #[test]
3720 fn reconstruct_respects_reset_between_lines() {
3721 let m = MockSource::new();
3722 m.append(b"\x1b[31mline 1\x1b[0m\n");
3723 m.append(b"line 2 (default)\n");
3724 let mut idx = LineIndex::new();
3725 idx.extend_to_end(&m);
3726 let state = reconstruct_render_state(&m, &idx, 1);
3727 assert_eq!(state.style.fg, None);
3728 }
3729
3730 #[test]
3731 fn reconstruct_caps_walkback_at_max_lines() {
3732 let m = MockSource::new();
3733 m.append(b"\x1b[31mvery early\n");
3734 for _ in 0..300 {
3735 m.append(b"line\n");
3736 }
3737 let mut idx = LineIndex::new();
3738 idx.extend_to_end(&m);
3739 let state = reconstruct_render_state(&m, &idx, 290);
3742 assert_eq!(state.style.fg, None);
3743 }
3744
3745 #[test]
3746 fn or_groups_narrow_within_required_line_mode() {
3747 let mut raw = crate::or::OrSpecRaw::new();
3748 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
3749 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
3750 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3751 let mut v = Viewport::new(80, 24, "t".into());
3752 v.set_or_groups(og);
3753 assert!(v.or_active());
3754 assert!(v.line_passes(b"login failed"));
3755 assert!(v.line_passes(b"access denied"));
3756 assert!(!v.line_passes(b"login ok"));
3757 }
3758
3759 #[test]
3760 fn status_shows_or_indicator_when_active() {
3761 let mut raw = crate::or::OrSpecRaw::new();
3762 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
3763 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3764 let (m, mut idx) = setup(b"x\ny\nx\n");
3765 idx.extend_to_end(&m);
3766 let mut v = Viewport::new(80, 5, "f".into());
3767 v.set_or_groups(og);
3768 v.extend_visible_lines(&idx, &m);
3769 let status = v.format_status(&idx, &m);
3770 assert!(status.contains("[or]"), "expected [or] in status: {status}");
3771 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
3772 }
3773
3774 #[test]
3775 fn status_shows_col_offset_when_scrolled() {
3776 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
3778 let (m, mut idx) = setup(content);
3779 let mut v = Viewport::new(10, 3, "t".into());
3780 v.toggle_chop(); v.hscroll_right_step(); let f = v.frame(&m, &mut idx);
3783 assert!(
3784 f.status.contains('\u{00bb}'),
3785 "expected » in status after hscroll_right_step, got: {}",
3786 f.status
3787 );
3788 }
3789
3790 #[test]
3791 fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
3792 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
3801 let (m, mut idx) = setup(content);
3802
3803 let mut v = Viewport::new(10, 3, "t".into());
3805 v.toggle_chop(); let frame0 = v.frame(&m, &mut idx);
3809 assert_eq!(
3810 frame0.body[0][0],
3811 Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
3812 "at left_col=0 first cell should be 'A'"
3813 );
3814 assert!(
3816 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
3817 "no left marker expected at left_col=0"
3818 );
3819
3820 v.hscroll_right_step();
3822 assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
3823
3824 let frame1 = v.frame(&m, &mut idx);
3825 assert_eq!(
3827 frame1.body[0][0],
3828 Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
3829 "after scrolling right, first cell should be the '<' left marker"
3830 );
3831 assert_eq!(
3835 frame1.body[0][1],
3836 Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
3837 "second cell should be 'J' (display column left_col+1 = 9)"
3838 );
3839 }
3840}