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 pub image_blob: Option<Vec<u8>>,
206}
207
208#[cfg(feature = "image")]
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub enum ImageProtocol {
214 #[default]
215 Ascii,
216 Kitty,
217 Sixel,
218}
219
220pub struct Viewport {
221 top_line: usize,
222 top_row: usize,
223 left_col: usize,
227 cols: u16,
228 rows: u16,
229 pub opts: RenderOpts,
230 pub show_line_numbers: bool,
231 pub source_label: String,
232 follow_mode: bool,
233 live_mode: bool,
234 prettify_label: Option<String>,
235 format_label: Option<String>,
236 filter: Option<CompiledFilter>,
237 grep: Option<GrepPredicate>,
238 or_groups: OrGroups,
239 dim_mode: bool,
240 visible_lines: Vec<usize>,
243 visible_scanned: usize,
246 search: Option<SearchState>,
247 display: Option<crate::format::DisplayRenderer>,
251 hex_mode: bool,
252 #[cfg(feature = "image")]
253 image: Option<image::RgbaImage>,
254 #[cfg(feature = "image")]
255 image_protocol: ImageProtocol,
256 #[cfg(feature = "image")]
259 cell_px: (u16, u16),
260 #[cfg(feature = "image")]
263 image_scaled: Option<(u16, image::RgbaImage)>,
264 image_mode: bool,
265 image_no_color: bool,
266 #[cfg_attr(not(feature = "image"), allow(dead_code))]
267 image_format: String,
268 #[cfg(feature = "image")]
269 image_style: crate::image_render::AsciiStyle,
270 #[cfg_attr(not(feature = "image"), allow(dead_code))]
271 image_width: Option<usize>,
272 hex_group_size: usize,
275 prompt: Option<crate::prompt::ParsedPrompt>,
278 preprocess_failure: Option<String>,
281 file_index: Option<(usize, usize)>,
283 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
287 status_style: crate::ansi::Style,
291 status_flash: Option<(String, u32)>,
296 ticks_since_growth: u32,
301 case_mode: CaseMode,
305 hilite_search: bool,
309 quit_at_eof: QuitAtEof,
311 eof_hits: u8,
314 squeeze_blanks: bool,
318 header_lines: usize,
323 header_cols: usize,
324 page_size: Option<u16>,
328 render_state: crate::render::RenderState,
332 render_state_for: usize,
335 incsearch: bool,
339 status_column: bool,
343 status_marks: std::collections::HashMap<usize, char>,
347}
348
349impl Viewport {
350 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
351 let opts = RenderOpts { cols, ..RenderOpts::default() };
352 Self {
353 top_line: 0,
354 top_row: 0,
355 left_col: 0,
356 cols,
357 rows,
358 opts,
359 show_line_numbers: false,
360 source_label,
361 follow_mode: false,
362 live_mode: false,
363 prettify_label: None,
364 format_label: None,
365 filter: None,
366 grep: None,
367 or_groups: OrGroups::default(),
368 dim_mode: false,
369 visible_lines: Vec::new(),
370 visible_scanned: 0,
371 search: None,
372 display: None,
373 hex_mode: false,
374 #[cfg(feature = "image")]
375 image: None,
376 #[cfg(feature = "image")]
377 image_protocol: ImageProtocol::Ascii,
378 #[cfg(feature = "image")]
379 cell_px: (8, 16),
380 #[cfg(feature = "image")]
381 image_scaled: None,
382 image_mode: false,
383 image_no_color: false,
384 image_format: String::new(),
385 #[cfg(feature = "image")]
386 image_style: crate::image_render::AsciiStyle::Ramp,
387 image_width: None,
388 hex_group_size: 2,
389 prompt: None,
390 preprocess_failure: None,
391 file_index: None,
392 tag_active: None,
393 ansi_mode: crate::render::AnsiMode::Strict,
394 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
395 status_flash: None,
396 ticks_since_growth: 0,
397 case_mode: CaseMode::default(),
398 hilite_search: true,
399 quit_at_eof: QuitAtEof::default(),
400 eof_hits: 0,
401 squeeze_blanks: false,
402 header_lines: 0,
403 header_cols: 0,
404 page_size: None,
405 render_state: crate::render::RenderState::default(),
406 render_state_for: usize::MAX,
407 incsearch: false,
408 status_column: false,
409 status_marks: std::collections::HashMap::new(),
410 }
411 }
412
413 pub fn status_column(&self) -> bool { self.status_column }
414
415 pub fn set_status_column(&mut self, on: bool) { self.status_column = on; }
416
417 pub fn set_status_marks(&mut self, marks: std::collections::HashMap<usize, char>) {
421 self.status_marks = marks;
422 }
423
424 fn status_col_width(&self) -> u16 {
428 if self.status_column && self.ansi_mode != crate::render::AnsiMode::Raw { 1 } else { 0 }
429 }
430
431 fn status_cell(glyph: char) -> Cell {
434 Cell::Char { ch: glyph, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
435 }
436
437 fn status_glyph(&self, line_n: usize, has_match: bool) -> char {
441 if let Some(&ch) = self.status_marks.get(&line_n) {
442 ch
443 } else if has_match {
444 '*'
445 } else {
446 ' '
447 }
448 }
449
450 pub fn case_mode(&self) -> CaseMode { self.case_mode }
451
452 pub fn hilite_search(&self) -> bool { self.hilite_search }
453
454 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
455
456 pub fn incsearch(&self) -> bool { self.incsearch }
457
458 pub fn set_incsearch(&mut self, on: bool) { self.incsearch = on; }
459
460 pub fn top_row(&self) -> usize { self.top_row }
461
462 pub fn set_top(&mut self, line: usize, row: usize) {
464 self.top_line = line;
465 self.top_row = row;
466 }
467
468 pub fn incsearch_preview(&mut self, src: &dyn Source, idx: &mut LineIndex,
472 pattern: &str, direction: SearchDirection,
473 origin: (usize, usize)) {
474 if pattern.is_empty() { return; }
475 self.set_top(origin.0, origin.1);
476 if self.set_search(pattern.to_string(), direction).is_ok() {
477 self.search_repeat(src, idx, false);
478 }
479 }
480
481 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
482 self.quit_at_eof = mode;
483 self.eof_hits = 0;
484 }
485
486 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
487 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
488
489 pub fn set_header(&mut self, lines: usize, cols: usize) {
490 self.header_lines = lines;
491 self.header_cols = cols;
492 if self.top_line < self.header_lines {
495 self.top_line = self.header_lines;
496 }
497 }
498 pub fn header_lines(&self) -> usize { self.header_lines }
499 pub fn header_cols(&self) -> usize { self.header_cols }
500
501 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
502 pub fn page_size(&self) -> Option<u16> { self.page_size }
503
504 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
509 match self.quit_at_eof {
510 QuitAtEof::Off => false,
511 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
512 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
513 self.eof_hits = self.eof_hits.saturating_add(1);
514 self.eof_hits >= 2
515 }
516 _ => {
517 if !forward { self.eof_hits = 0; }
518 false
519 }
520 }
521 }
522
523 pub fn set_case_mode(&mut self, mode: CaseMode) {
527 self.case_mode = mode;
528 if let Some(s) = self.search.clone() {
529 let _ = self.set_search(s.raw, s.direction);
530 }
531 }
532
533 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
534 self.status_style = style;
535 }
536
537 pub fn status_style(&self) -> crate::ansi::Style {
538 self.status_style
539 }
540
541 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
545 self.status_flash = Some((msg.into(), ticks));
546 }
547
548 pub fn tick_flash(&mut self) {
551 if let Some((_, n)) = &mut self.status_flash {
552 *n = n.saturating_sub(1);
553 if *n == 0 {
554 self.status_flash = None;
555 }
556 }
557 }
558
559 pub fn note_growth(&mut self) {
561 self.ticks_since_growth = 0;
562 }
563
564 pub fn tick_idle(&mut self) {
567 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
568 }
569
570 pub fn is_idle(&self) -> bool {
573 self.ticks_since_growth >= 20
574 }
575
576 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
577 self.display = renderer;
578 }
579
580 pub fn set_hex_mode(&mut self, on: bool) {
581 self.hex_mode = on;
582 }
583
584 pub fn hex_mode(&self) -> bool {
586 self.hex_mode
587 }
588
589 #[cfg(feature = "image")]
590 pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
591 self.image = Some(img);
592 self.image_format = format.to_string();
593 self.image_style = style;
594 self.image_width = width;
595 self.image_mode = true;
596 self.top_line = 0;
597 self.top_row = 0;
598 self.image_scaled = None;
599 }
600
601 pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
602
603 #[cfg(feature = "image")]
604 pub fn set_image_protocol(&mut self, proto: ImageProtocol, cell_px: Option<(u16, u16)>) {
605 self.image_protocol = proto;
606 if let Some(c) = cell_px {
607 if c.0 > 0 && c.1 > 0 { self.cell_px = c; }
608 }
609 self.image_scaled = None;
610 }
611
612 #[cfg(feature = "image")]
613 pub fn image_protocol(&self) -> ImageProtocol { self.image_protocol }
614
615 pub fn image_mode(&self) -> bool { self.image_mode }
616
617 #[cfg(feature = "image")]
618 fn image_cols(&self) -> u16 {
619 self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
620 }
621
622 #[cfg(feature = "image")]
623 pub fn image_total_rows(&self) -> usize {
624 match &self.image {
625 Some(img) => {
626 let (w, h) = img.dimensions();
627 if self.image_protocol != ImageProtocol::Ascii {
628 protocol_occupied_rows(w, h, self.cols, self.cell_px, self.image_width)
629 } else {
630 crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
631 }
632 }
633 None => 0,
634 }
635 }
636
637 #[cfg(feature = "image")]
638 pub fn is_at_bottom_image(&self) -> bool {
639 let body = self.body_rows() as usize;
640 self.top_line + body >= self.image_total_rows()
641 }
642
643 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
646 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
647 self.hex_group_size = bytes_per_group;
648 }
649 }
650
651 pub fn hex_group_size(&self) -> usize {
653 self.hex_group_size
654 }
655
656 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
657 self.prompt = prompt;
658 }
659
660 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
661 self.preprocess_failure = msg;
662 }
663
664 pub fn set_file_index(&mut self, current: usize, total: usize) {
665 self.file_index = if total > 1 {
666 Some((current, total))
667 } else {
668 None
669 };
670 }
671
672 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
673 self.tag_active = info;
674 }
675
676 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
677 self.ansi_mode = mode;
678 }
679
680 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
681 self.ansi_mode
682 }
683
684 pub fn set_source_label(&mut self, label: String) {
685 self.source_label = label;
686 }
687
688 pub fn source_label_clone(&self) -> String {
689 self.source_label.clone()
690 }
691
692 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
697 let range = idx.line_range(line_n, src);
698 let raw = src.bytes(range);
699 if let Some(r) = self.display.as_ref() {
700 if let Some(rendered) = r.render_line(&raw) {
701 return std::borrow::Cow::Owned(rendered.into_bytes());
702 }
703 }
704 raw
705 }
706
707 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
711 let compiled = self.case_mode.apply_to_pattern(&raw);
712 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
713 self.search = Some(SearchState { raw, regex, direction });
714 Ok(())
715 }
716
717 pub fn clear_search(&mut self) { self.search = None; }
718
719 pub fn search_active(&self) -> bool { self.search.is_some() }
720
721 pub fn search_direction(&self) -> SearchDirection {
722 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
723 }
724
725 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
729 if idx.records_mode() {
730 self.search_repeat_records(src, idx, reverse)
731 } else {
732 self.search_repeat_lines(src, idx, reverse)
733 }
734 }
735
736 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
738 let Some(s) = self.search.as_ref() else { return false; };
739 let forward = matches!(
740 (s.direction, reverse),
741 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
742 );
743 idx.extend_to_end(src);
744 let pattern = s.regex.clone();
745 if self.hide_mode() {
746 self.extend_visible_lines(idx, src);
747 self.search_step_in_visible(&pattern, src, idx, forward)
748 } else {
749 self.search_step_in_logical(&pattern, src, idx, forward)
750 }
751 }
752
753 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
757 let Some(s) = self.search.as_ref() else { return false; };
758 let forward = matches!(
759 (s.direction, reverse),
760 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
761 );
762 let pattern = s.regex.clone();
763 idx.extend_to_end(src);
764
765 let total = idx.record_count();
766 if total == 0 { return false; }
767
768 let cur_record = idx.line_to_record(self.top_line);
769
770 let range: Box<dyn Iterator<Item = usize>> = if forward {
771 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
772 } else {
773 let earlier: Vec<usize> = (0..cur_record).rev().collect();
774 let later: Vec<usize> = (cur_record..total).rev().collect();
775 Box::new(earlier.into_iter().chain(later))
776 };
777
778 for r in range {
779 let bytes = idx.record_bytes_stripped(r, src);
780 let text = String::from_utf8_lossy(&bytes);
781 if pattern.is_match(&text) {
782 let line_range = idx.record_line_range(r);
783 self.top_line = line_range.start;
784 self.top_row = 0;
785 return true;
786 }
787 }
788 false
789 }
790
791 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
792 let display = self.line_display_bytes(src, idx, line_n);
797 let bytes = crate::ansi::strip_sgr(&display);
798 match std::str::from_utf8(&bytes) {
799 Ok(s) => pattern.is_match(s),
800 Err(_) => false,
801 }
802 }
803
804 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
805 let total = idx.line_count();
806 if total == 0 { return false; }
807 let start = self.top_line;
808 for offset in 1..=total {
811 let line_n = if forward {
812 (start + offset) % total
813 } else {
814 (start + total - offset) % total
815 };
816 if self.line_matches(pattern, src, idx, line_n) {
817 self.top_line = line_n;
818 self.top_row = 0;
819 return true;
820 }
821 }
822 false
823 }
824
825 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
826 let total = self.visible_lines.len();
827 if total == 0 { return false; }
828 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
830 for offset in 1..=total {
831 let visible_idx = if forward {
832 (cur + offset) % total
833 } else {
834 (cur + total - offset) % total
835 };
836 let line_n = self.visible_lines[visible_idx];
837 if self.line_matches(pattern, src, idx, line_n) {
838 self.top_line = line_n;
839 self.top_row = 0;
840 return true;
841 }
842 }
843 false
844 }
845
846 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
847 self.filter = filter;
848 self.visible_lines.clear();
849 self.visible_scanned = 0;
850 self.top_line = 0;
852 self.top_row = 0;
853 self.left_col = 0;
854 }
855
856 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
857 self.grep = grep;
858 self.visible_lines.clear();
859 self.visible_scanned = 0;
860 self.top_line = 0;
861 self.top_row = 0;
862 self.left_col = 0;
863 }
864
865 pub fn set_or_groups(&mut self, or_groups: OrGroups) {
866 self.or_groups = or_groups;
867 self.visible_lines.clear();
868 self.visible_scanned = 0;
869 self.top_line = 0;
870 self.top_row = 0;
871 self.left_col = 0;
872 }
873
874 pub fn or_active(&self) -> bool {
875 self.or_groups.is_active()
876 }
877
878 pub fn grep_active(&self) -> bool { self.grep.is_some() }
879
880 pub fn set_dim_mode(&mut self, on: bool) {
881 self.dim_mode = on;
882 self.visible_lines.clear();
886 self.visible_scanned = 0;
887 }
888
889 pub fn filter_active(&self) -> bool { self.filter.is_some() }
890
891 pub fn dim_mode(&self) -> bool { self.dim_mode }
892
893 fn hide_mode(&self) -> bool {
894 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
895 && !self.dim_mode
896 }
897
898 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
903 if !self.hide_mode() {
904 return;
905 }
906 if idx.records_mode() {
907 self.extend_visible_lines_records(idx, src);
908 } else {
909 self.extend_visible_lines_per_line(idx, src);
910 }
911 }
912
913 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
915 let total = idx.line_count();
916 while self.visible_scanned < total {
917 let line_n = self.visible_scanned;
918 let bytes = idx.line_bytes_stripped(line_n, src);
919 if self.line_passes(&bytes) {
920 self.visible_lines.push(line_n);
921 }
922 self.visible_scanned += 1;
923 }
924 }
925
926 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
933 self.visible_lines.clear();
934 self.visible_scanned = 0; let total_records = idx.record_count();
936 for r in 0..total_records {
937 if self.record_passes(idx, src, r) {
938 for line_n in idx.record_line_range(r) {
939 self.visible_lines.push(line_n);
940 }
941 }
942 }
943 }
944
945 fn line_passes(&self, line: &[u8]) -> bool {
951 let filter_ok = match self.filter.as_ref() {
952 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
953 None => true,
954 };
955 let grep_ok = match self.grep.as_ref() {
956 Some(g) => g.matches(line),
957 None => true,
958 };
959 filter_ok && grep_ok && self.or_groups.matches_line(line)
960 }
961
962 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
970 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
971 let bytes = if need {
972 Some(idx.record_bytes_stripped(r, src))
973 } else {
974 None
975 };
976 let filter_ok = match self.filter.as_ref() {
977 Some(f) => matches!(
978 f.evaluate_record(bytes.as_deref().unwrap()),
979 FilterMatch::Matched,
980 ),
981 None => true,
982 };
983 let grep_ok = match self.grep.as_ref() {
984 Some(g) => g.matches(bytes.as_deref().unwrap()),
985 None => true,
986 };
987 let or_ok = if self.or_groups.is_active() {
988 self.or_groups.matches_record(bytes.as_deref().unwrap())
989 } else {
990 true
991 };
992 filter_ok && grep_ok && or_ok
993 }
994
995 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
999 if !self.dim_mode {
1000 return false;
1001 }
1002 if idx.records_mode() {
1003 let r = idx.line_to_record(line_n);
1004 !self.record_passes(idx, src, r)
1005 } else {
1006 let bytes = idx.line_bytes_stripped(line_n, src);
1007 !self.line_passes(&bytes)
1008 }
1009 }
1010
1011 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
1019 let body_rows = self.body_rows() as usize;
1020 if self.hide_mode() && !self.visible_lines.is_empty() {
1021 let cur = self
1022 .visible_lines
1023 .iter()
1024 .position(|&l| l >= self.top_line)
1025 .unwrap_or(self.visible_lines.len().saturating_sub(1));
1026 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
1027 return self.visible_lines[last_pos];
1028 }
1029 let total = idx.line_count();
1030 if total == 0 {
1031 return self.top_line;
1032 }
1033 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
1034 }
1035
1036 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
1037
1038 pub fn follow_mode(&self) -> bool { self.follow_mode }
1039
1040 pub fn suspend_follow_if(&mut self, flag: bool) {
1045 if flag {
1046 self.follow_mode = false;
1047 }
1048 }
1049
1050 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
1051
1052 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
1053
1054 pub fn live_mode(&self) -> bool { self.live_mode }
1055
1056 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
1057
1058 pub fn set_prettify_label(&mut self, label: Option<String>) {
1061 self.prettify_label = label;
1062 }
1063
1064 pub fn set_format_label(&mut self, label: Option<String>) {
1067 self.format_label = label;
1068 }
1069
1070 pub fn invalidate_filter_cache(&mut self) {
1075 self.visible_lines.clear();
1076 self.visible_scanned = 0;
1077 }
1078
1079 pub fn clamp_top_line(&mut self, line_count: usize) {
1082 if line_count == 0 {
1083 self.top_line = 0;
1084 self.top_row = 0;
1085 } else if self.top_line >= line_count {
1086 self.top_line = line_count - 1;
1087 self.top_row = 0;
1088 }
1089 }
1090
1091 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
1095 #[cfg(feature = "image")]
1096 if self.image_mode {
1097 return self.is_at_bottom_image();
1098 }
1099 if self.hide_mode() {
1100 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
1104 } else {
1105 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
1109 }
1110 }
1111
1112 fn gutter_width(&self, idx: &LineIndex) -> u16 {
1114 if !self.show_line_numbers { return 0; }
1115 let n = idx.line_count().max(1);
1116 let digits = (n as f64).log10().floor() as u16 + 1;
1117 digits + 1
1118 }
1119
1120 fn render_opts(&self, gutter: u16) -> RenderOpts {
1121 let mut o = self.opts.clone();
1122 o.cols = self.cols.saturating_sub(self.status_col_width() + gutter);
1125 o.mode = self.ansi_mode;
1126 o.left_col = self.left_col; o
1128 }
1129
1130 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1131 #[cfg(feature = "image")]
1132 if self.image_mode {
1133 return self.frame_image();
1134 }
1135 if self.hex_mode {
1136 return self.frame_hex(src);
1137 }
1138 let body_rows = self.body_rows() as usize;
1139 idx.extend_to_line(self.top_line + body_rows + 1, src);
1140
1141 if self.left_col > 0 && self.hscroll_active() {
1144 let gutter_for_clamp = self.status_col_width() + self.gutter_width(idx);
1145 let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1146 let mut width_opts = self.opts.clone();
1149 width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1150 width_opts.mode = self.ansi_mode;
1151 width_opts.left_col = 0;
1152 let mut widest = 0usize;
1153 let total_lines_for_clamp = idx.line_count();
1154 if self.hide_mode() {
1155 let hide_pos = self.visible_lines.iter()
1156 .position(|&l| l >= self.top_line)
1157 .unwrap_or(self.visible_lines.len());
1158 let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1159 for vi in hide_pos..end_vi {
1160 let ln = self.visible_lines[vi];
1161 let bytes = self.line_display_bytes(src, idx, ln);
1162 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1163 }
1164 } else {
1165 let start = self.top_line.max(self.header_lines);
1166 let end = (start + body_rows).min(total_lines_for_clamp);
1167 for ln in start..end {
1168 let bytes = self.line_display_bytes(src, idx, ln);
1169 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1170 }
1171 }
1172 self.left_col = self.left_col.min(widest.saturating_sub(avail));
1173 }
1174
1175 let gutter = self.gutter_width(idx);
1176 let scol = self.status_col_width();
1177 let r_opts = self.render_opts(gutter);
1178
1179 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1183 reconstruct_render_state(src, idx, self.top_line)
1184 } else {
1185 crate::render::RenderState::default()
1186 };
1187 self.render_state = render_state.clone();
1189 self.render_state_for = self.top_line;
1190
1191 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1192 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1193 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1194 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1195 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1196 let hide = self.hide_mode();
1198 let total_lines = idx.line_count();
1199
1200 let header_rows = if !hide && !raw_passthrough {
1207 self.header_lines.min(body_rows).min(total_lines)
1208 } else {
1209 0
1210 };
1211 if header_rows > 0 {
1212 for hl in 0..header_rows {
1213 let raw = src.bytes(idx.line_range(hl, src));
1214 let display_bytes = if let Some(r) = self.display.as_ref() {
1215 match r.render_line(&raw) {
1216 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1217 None => raw.clone(),
1218 }
1219 } else {
1220 raw.clone()
1221 };
1222 let rows = render_line(&display_bytes, &r_opts, None);
1223 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1224 let mut v = Vec::with_capacity(self.cols as usize);
1225 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1226 v
1227 });
1228 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1229 if scol > 0 {
1230 let matched = self.search.as_ref()
1231 .is_some_and(|s| !find_row_highlights(&content_row, &s.regex).is_empty());
1232 let glyph = self.status_glyph(hl, matched);
1233 full.push(Self::status_cell(glyph));
1234 }
1235 if gutter > 0 {
1236 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1237 for c in label.chars() {
1238 full.push(Cell::Char {
1239 ch: c,
1240 width: 1,
1241 style: crate::ansi::Style::default(),
1242 hyperlink: None,
1243 });
1244 }
1245 }
1246 full.append(&mut content_row);
1247 body.push(full);
1248 row_styles.push(RowStyle::Normal);
1249 highlights.push(Vec::new());
1250 raw_rows.push(None);
1251 }
1252 }
1253
1254 let mut hide_pos = if hide {
1256 self.visible_lines
1257 .iter()
1258 .position(|&l| l >= self.top_line)
1259 .unwrap_or(self.visible_lines.len())
1260 } else {
1261 0
1262 };
1263 let mut line_n = if hide {
1264 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1265 } else {
1266 self.top_line.max(self.header_lines)
1269 };
1270 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1271
1272 while body.len() < body_rows {
1273 if line_n >= total_lines {
1274 let mut row = Vec::with_capacity(self.cols as usize);
1275 if scol > 0 {
1276 for _ in 0..scol { row.push(Cell::Empty); }
1277 }
1278 if gutter > 0 {
1279 for _ in 0..gutter { row.push(Cell::Empty); }
1280 }
1281 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1282 body.push(row);
1283 row_styles.push(RowStyle::Normal);
1284 highlights.push(Vec::new());
1285 raw_rows.push(None);
1286 line_n += 1;
1287 continue;
1288 }
1289 let raw = src.bytes(idx.line_range(line_n, src));
1292 if self.squeeze_blanks && line_is_blank(&raw) {
1297 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1298 let prev = src.bytes(idx.line_range(p, src));
1299 line_is_blank(&prev)
1300 });
1301 if prev_blank {
1302 line_n += 1;
1303 continue;
1304 }
1305 }
1306 let display_bytes = if let Some(r) = self.display.as_ref() {
1307 match r.render_line(&raw) {
1308 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1309 None => raw.clone(),
1310 }
1311 } else {
1312 raw.clone()
1313 };
1314 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1315 Some(&mut render_state)
1316 } else {
1317 None
1318 };
1319 let rows = render_line(&display_bytes, &r_opts, state_arg);
1320 let style = if self.filter.is_some() || self.grep.is_some() {
1321 if self.dim_mode {
1322 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1323 } else {
1324 RowStyle::Normal
1326 }
1327 } else {
1328 RowStyle::Normal
1329 };
1330
1331 let mut first_emitted_for_this_line = true;
1332 let mut status_first_row_idx: Option<usize> = None;
1337 let mut line_matched = false;
1338 for (i, mut content_row) in rows.into_iter().enumerate() {
1339 if i < skip { continue; }
1340 if body.len() >= body_rows { break; }
1341 if scol > 0 && !line_matched {
1348 if let Some(s) = self.search.as_ref() {
1349 if !find_row_highlights(&content_row, &s.regex).is_empty() {
1350 line_matched = true;
1351 }
1352 }
1353 }
1354 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1355 if scol > 0 {
1356 if status_first_row_idx.is_none() {
1357 status_first_row_idx = Some(body.len());
1358 }
1359 full.push(Self::status_cell(' '));
1362 }
1363 if gutter > 0 {
1364 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1365 for c in label.chars() {
1366 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1367 }
1368 }
1369 full.append(&mut content_row);
1370 if self.left_col > 0 && !self.opts.wrap {
1375 let marker_col = (scol + gutter) as usize;
1376 if let Some(cell) = full.get_mut(marker_col) {
1377 *cell = Cell::Char {
1378 ch: '<',
1379 width: 1,
1380 style: crate::ansi::Style { dim: true, ..Default::default() },
1381 hyperlink: None,
1382 };
1383 }
1384 }
1385 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1389 find_row_highlights(&full, &s.regex)
1390 } else {
1391 Vec::new()
1392 };
1393 body.push(full);
1394 row_styles.push(style);
1395 highlights.push(row_highlights);
1396 if raw_passthrough {
1397 if first_emitted_for_this_line {
1398 raw_rows.push(Some(raw.to_vec()));
1403 first_emitted_for_this_line = false;
1404 } else {
1405 raw_rows.push(Some(Vec::new()));
1406 }
1407 } else {
1408 raw_rows.push(None);
1409 }
1410 }
1411 if let Some(fi) = status_first_row_idx {
1415 let glyph = self.status_glyph(line_n, line_matched);
1416 if glyph != ' ' {
1417 if let Some(cell) = body[fi].first_mut() {
1418 *cell = Self::status_cell(glyph);
1419 }
1420 }
1421 }
1422 skip = 0;
1423 if hide {
1425 hide_pos += 1;
1426 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1427 } else {
1428 line_n += 1;
1429 }
1430 }
1431
1432 self.render_state_for = usize::MAX;
1435
1436 let status = self.format_status(idx, src);
1437 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1438 }
1439
1440 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1441 if let Some(p) = self.prompt.as_ref() {
1442 let ctx = self.build_prompt_context(idx, src);
1443 return p.render(&ctx);
1444 }
1445 let body_rows = self.body_rows() as usize;
1446 let total = idx.line_count();
1447 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1450 let visible_total = self.visible_lines.len();
1451 let cur = self
1453 .visible_lines
1454 .iter()
1455 .position(|&l| l >= self.top_line)
1456 .unwrap_or(visible_total);
1457 let top = cur + 1;
1458 let bottom = (cur + body_rows).min(visible_total.max(1));
1459 let total_str = if src.is_complete() {
1460 format!("{visible_total}/{total}")
1461 } else {
1462 format!("{visible_total}/{total}+")
1463 };
1464 (top, bottom, visible_total, total_str)
1465 } else {
1466 let top = self.top_line + 1;
1467 let bottom = (self.top_line + body_rows).min(total.max(1));
1468 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1469 (top, bottom, total, total_str)
1470 };
1471 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1472 let bottom_line = self.bottom_visible_line(idx);
1476 let (line_prefix, records_block) = if idx.records_mode() {
1477 let line_total = idx.line_count();
1478 let rec_total = idx.record_count();
1479 let rec_block = if line_total == 0 || rec_total == 0 {
1480 format!("R0-0/{}", rec_total)
1481 } else {
1482 let rec_top = idx.line_to_record(self.top_line) + 1;
1483 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1484 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1485 (rec_top, rec_top)
1489 } else {
1490 (rec_top, rec_bottom)
1491 };
1492 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1493 };
1494 ("L", Some(rec_block))
1495 } else {
1496 ("", None)
1497 };
1498 let middle = match records_block {
1499 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1500 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1501 };
1502 let label_with_index = match self.file_index {
1503 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1504 None => self.source_label.clone(),
1505 };
1506 let mut s = format!("{} {}", label_with_index, middle);
1507 if !self.hide_mode() && self.top_row > 0 {
1512 let line_rows = if total > 0 {
1513 let bytes = self.line_display_bytes(src, idx, self.top_line);
1514 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1515 } else { 1 };
1516 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1517 }
1518 if self.left_col > 0 {
1519 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1520 }
1521 if let Some(f) = self.filter.as_ref() {
1522 s.push_str(&format!(" [{}]", f.format_name));
1523 }
1524 if self.grep.is_some() {
1525 s.push_str(" [grep]");
1526 }
1527 if self.or_groups.is_active() {
1528 s.push_str(" [or]");
1529 }
1530 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1531 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1532 }
1533 if let Some(sr) = self.search.as_ref() {
1534 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1535 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1536 }
1537 if let Some(label) = self.prettify_label.as_ref() {
1538 s.push_str(&format!(" [pretty:{label}]"));
1539 }
1540 if self.live_mode { s.push_str(" (L)"); }
1541 if self.follow_mode {
1542 if let Some((msg, _)) = self.status_flash.as_ref() {
1543 s.push_str(" ");
1544 s.push_str(msg);
1545 } else if self.is_idle() {
1546 s.push_str(" (F idle)");
1547 } else {
1548 s.push_str(" (F)");
1549 }
1550 }
1551 if let Some(msg) = self.preprocess_failure.as_ref() {
1552 let first_line = msg.lines().next().unwrap_or("");
1553 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1554 }
1555 let tag_suffix = match &self.tag_active {
1556 Some((name, cur, total)) if *total > 1 => {
1557 format!(" [tag: {name} ({cur}/{total})]")
1558 }
1559 _ => String::new(),
1560 };
1561 s.push_str(&tag_suffix);
1562 let used = s.chars().count();
1565 let hint = ":help";
1566 if (self.cols as usize) > used + 1 + hint.chars().count() {
1567 let pad = self.cols as usize - used - hint.chars().count();
1568 s.push_str(&" ".repeat(pad));
1569 s.push_str(hint);
1570 } else {
1571 s.push(' ');
1572 s.push_str(hint);
1573 }
1574 s
1575 }
1576
1577 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1578 use crate::prompt::PromptContext;
1579
1580 let body_rows = self.body_rows() as usize;
1581 let total = idx.line_count();
1582 let top = self.top_line + 1;
1583 let bottom = (self.top_line + body_rows).min(total.max(1));
1584 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1585 let bottom_line = self.bottom_visible_line(idx);
1586
1587 let records_mode = idx.records_mode();
1588 let (rec_top, rec_bottom, rec_total) = if records_mode {
1589 let rt = idx.line_to_record(self.top_line) + 1;
1590 let rb_raw = idx.line_to_record(bottom_line) + 1;
1591 let rb = if rb_raw < rt { rt } else { rb_raw };
1592 (rt, rb, idx.record_count())
1593 } else {
1594 (0, 0, 0)
1595 };
1596
1597 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1598 let line_rows = if total > 0 {
1599 let bytes = self.line_display_bytes(src, idx, self.top_line);
1600 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1601 } else { 1 };
1602 format!("+{}/{}", self.top_row, line_rows)
1603 } else {
1604 String::new()
1605 };
1606
1607 let col_offset = if self.left_col > 0 { format!(" \u{00bb}{}", self.left_col) } else { String::new() };
1608
1609 let format_tag = self.format_label.as_ref()
1610 .map(|n| format!(" [{}]", n))
1611 .unwrap_or_default();
1612 let filter_tag = self.filter.as_ref()
1613 .map(|f| format!(" [{}]", f.format_name))
1614 .unwrap_or_default();
1615 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1616 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1617 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1618 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1619 } else {
1620 String::new()
1621 };
1622 let search_tag = self.search.as_ref()
1623 .map(|s| {
1624 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1625 format!(" [{}{}]", p, s.raw)
1626 })
1627 .unwrap_or_default();
1628 let pretty_tag = self.prettify_label.as_ref()
1629 .map(|l| format!(" [pretty:{l}]"))
1630 .unwrap_or_default();
1631 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1632 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1633 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1634 .map(|msg| {
1635 let first_line = msg.lines().next().unwrap_or("");
1636 format!(" [preprocess-failed: {}]", first_line)
1637 })
1638 .unwrap_or_default();
1639
1640 let file_index_tag = match self.file_index {
1641 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1642 None => String::new(),
1643 };
1644
1645 let tag_tag = match &self.tag_active {
1646 Some((name, cur, total)) if *total > 1 => {
1647 format!(" [tag: {name} ({cur}/{total})]")
1648 }
1649 _ => String::new(),
1650 };
1651
1652 PromptContext {
1653 label: self.source_label.clone(),
1654 top,
1655 bottom,
1656 total,
1657 pct: pct.min(100) as u8,
1658 rec_top,
1659 rec_bottom,
1660 rec_total,
1661 records_mode,
1662 wrap_offset,
1663 col_offset,
1664 format_tag,
1665 filter_tag,
1666 grep_tag,
1667 or_tag,
1668 hide_tag,
1669 search_tag,
1670 pretty_tag,
1671 live_tag,
1672 follow_tag,
1673 preprocess_failed_tag,
1674 file_index_tag,
1675 tag_tag,
1676 }
1677 }
1678
1679 fn frame_hex(&self, src: &dyn Source) -> Frame {
1680 use crate::hex::format_hex_row;
1681 use crate::render::{render_line, Cell, RenderOpts};
1682
1683 let body_rows = self.rows.saturating_sub(1) as usize;
1684 let total_bytes = src.len();
1685 let total_hex_rows = total_bytes.div_ceil(16);
1686
1687 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1688 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1689 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1690
1691 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 };
1692
1693 for row_idx in 0..body_rows {
1694 let hex_row = self.top_line + row_idx;
1695 if hex_row >= total_hex_rows {
1696 body.push(vec![Cell::Empty; self.cols as usize]);
1697 } else {
1698 let offset = hex_row * 16;
1699 let end = (offset + 16).min(total_bytes);
1700 let bytes_cow = src.bytes(offset..end);
1701 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1702 let rows = render_line(text.as_bytes(), &opts, None);
1703 body.push(rows.into_iter().next().unwrap_or_else(|| {
1704 vec![Cell::Empty; self.cols as usize]
1705 }));
1706 }
1707 row_styles.push(RowStyle::Normal);
1708 highlights.push(Vec::new());
1709 }
1710
1711 let status = self.format_status_hex(src);
1712 let raw_rows = vec![None; body.len()];
1713 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1714 }
1715
1716 fn format_status_hex(&self, src: &dyn Source) -> String {
1717 let total_bytes = src.len();
1718 let body_rows = self.rows.saturating_sub(1) as usize;
1719 let top_byte = self.top_line * 16;
1721 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1724 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1725 let label_with_index = match self.file_index {
1726 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1727 None => self.source_label.clone(),
1728 };
1729 let tag_suffix = match &self.tag_active {
1730 Some((name, cur, total)) if *total > 1 => {
1731 format!(" [tag: {name} ({cur}/{total})]")
1732 }
1733 _ => String::new(),
1734 };
1735 format!(
1736 "{} off {}-{}/{} {}% [hex]{}",
1737 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1738 )
1739 }
1740
1741 #[cfg(feature = "image")]
1742 fn frame_image(&mut self) -> Frame {
1743 use crate::render::Cell;
1744 if self.image_protocol != ImageProtocol::Ascii {
1745 return self.frame_image_protocol();
1746 }
1747 let body_rows = self.body_rows() as usize;
1748 let cols = self.cols as usize;
1749 let img = match &self.image {
1750 Some(i) => i,
1751 None => {
1752 let body = vec![vec![Cell::Empty; cols]; body_rows];
1753 return Frame {
1754 body,
1755 row_styles: vec![RowStyle::Normal; body_rows],
1756 highlights: vec![Vec::new(); body_rows],
1757 status: self.image_format.clone(),
1758 status_style: self.status_style,
1759 raw_rows: vec![None; body_rows],
1760 image_blob: None,
1761 };
1762 }
1763 };
1764 let color = !self.image_no_color;
1765 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1766 let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1767 let max_off = grid_w.saturating_sub(cols);
1768 if self.left_col > max_off { self.left_col = max_off; }
1769 let off = self.left_col;
1770 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1771 for r in 0..body_rows {
1772 let gi = self.top_line + r;
1773 if gi < grid.len() {
1774 let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1775 while row.len() < cols { row.push(Cell::Empty); }
1776 body.push(row);
1777 } else {
1778 body.push(vec![Cell::Empty; cols]);
1779 }
1780 }
1781 let status = self.format_status_image(grid.len());
1782 Frame {
1783 body,
1784 row_styles: vec![RowStyle::Normal; body_rows],
1785 highlights: vec![Vec::new(); body_rows],
1786 status,
1787 status_style: self.status_style,
1788 raw_rows: vec![None; body_rows],
1789 image_blob: None,
1790 }
1791 }
1792
1793 #[cfg(feature = "image")]
1794 fn format_status_image(&self, total_rows: usize) -> String {
1795 let body = self.body_rows() as usize;
1796 let top = self.top_line + 1;
1797 let bottom = (self.top_line + body).min(total_rows.max(1));
1798 let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1799 let mut s = format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1800 if self.left_col > 0 {
1801 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1802 }
1803 s
1804 }
1805
1806 #[cfg(feature = "image")]
1807 fn frame_image_protocol(&mut self) -> Frame {
1808 use crate::render::Cell;
1809 let body_rows = self.body_rows() as usize;
1810 let cols = self.cols as usize;
1811 let status_style = self.status_style;
1812 let blank = |status: String, blob: Option<Vec<u8>>| Frame {
1813 body: vec![vec![Cell::Empty; cols]; body_rows],
1814 row_styles: vec![RowStyle::Normal; body_rows],
1815 highlights: vec![Vec::new(); body_rows],
1816 status,
1817 status_style,
1818 raw_rows: vec![None; body_rows],
1819 image_blob: blob,
1820 };
1821 let (iw, ih) = match &self.image {
1822 Some(i) => i.dimensions(),
1823 None => return blank(self.image_format.clone(), None),
1824 };
1825 let ch = self.cell_px.1.max(1) as u32;
1826 let (scaled_w, scaled_h) = protocol_scaled_dims(iw, ih, self.cols, self.cell_px, self.image_width);
1827
1828 let need = self.image_scaled.as_ref().map(|(c, _)| *c != scaled_w as u16).unwrap_or(true);
1830 if need {
1831 let src = self.image.as_ref().unwrap();
1832 let scaled = image::imageops::resize(src, scaled_w, scaled_h, image::imageops::FilterType::Triangle);
1833 self.image_scaled = Some((scaled_w as u16, scaled));
1834 }
1835
1836 let total_rows = protocol_occupied_rows(iw, ih, self.cols, self.cell_px, self.image_width);
1837 let max_top = total_rows.saturating_sub(body_rows);
1838 if self.top_line > max_top { self.top_line = max_top; }
1839 self.left_col = 0; let y0 = (self.top_line as u32 * ch).min(scaled_h);
1842 let band_h = ((body_rows as u32) * ch).min(scaled_h - y0).max(1);
1843 let scaled = &self.image_scaled.as_ref().unwrap().1;
1844 let band = image::imageops::crop_imm(scaled, 0, y0, scaled_w, band_h).to_image();
1845 let blob = match self.image_protocol {
1846 ImageProtocol::Kitty => crate::image_protocol::encode_kitty(&band),
1847 ImageProtocol::Sixel => crate::image_protocol::encode_sixel(&band),
1848 ImageProtocol::Ascii => unreachable!("frame_image_protocol only entered for non-Ascii"),
1849 };
1850 let status = self.format_status_image_protocol(total_rows);
1851 blank(status, Some(blob))
1852 }
1853
1854 #[cfg(feature = "image")]
1855 fn format_status_image_protocol(&self, total_rows: usize) -> String {
1856 let body = self.body_rows() as usize;
1857 let top = self.top_line + 1;
1858 let bottom = (self.top_line + body).min(total_rows.max(1));
1859 let proto = match self.image_protocol {
1860 ImageProtocol::Kitty => "kitty",
1861 ImageProtocol::Sixel => "sixel",
1862 ImageProtocol::Ascii => "ascii",
1863 };
1864 let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1865 format!("{} {} {} [{}] rows {}-{}/{}", self.source_label, dims, self.image_format, proto, top, bottom, total_rows)
1866 }
1867
1868 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1873 if delta == 0 { return; }
1874 #[cfg(feature = "image")]
1875 if self.image_mode {
1876 self.scroll_lines(delta, src, idx);
1877 return;
1878 }
1879 if self.hide_mode() {
1880 self.extend_visible_lines(idx, src);
1884 let n = self.visible_lines.len();
1885 if n == 0 {
1886 self.top_line = 0;
1887 self.top_row = 0;
1888 return;
1889 }
1890 let vi = self
1891 .visible_lines
1892 .iter()
1893 .position(|&l| l >= self.top_line)
1894 .unwrap_or(n - 1);
1895 if delta > 0 {
1896 let target = (vi + delta as usize).min(n - 1);
1897 self.top_line = self.visible_lines[target];
1898 self.top_row = 0;
1899 } else {
1900 let back = (-delta) as usize;
1901 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1902 let extra_back = back.saturating_sub(consumed_for_snap);
1903 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1904 self.top_row = 0;
1905 }
1906 return;
1907 }
1908 if delta > 0 {
1909 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1910 let total = idx.line_count();
1911 if total == 0 { return; }
1912 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1913 self.top_line = target;
1914 self.top_row = 0;
1915 } else {
1916 let back = (-delta) as usize;
1917 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1922 let extra_back = back.saturating_sub(consumed_for_snap);
1923 self.top_line = self.top_line.saturating_sub(extra_back);
1924 self.top_row = 0;
1925 }
1926 }
1927
1928 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1929 if delta == 0 { return; }
1930 #[cfg(feature = "image")]
1931 if self.image_mode {
1932 let total = self.image_total_rows();
1933 let body = self.body_rows() as usize;
1934 let max_top = total.saturating_sub(body);
1935 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
1936 self.top_line = next as usize;
1937 self.top_row = 0;
1938 return;
1939 }
1940 if self.hide_mode() {
1941 self.extend_visible_lines(idx, src);
1945 let n = self.visible_lines.len();
1946 if n == 0 {
1947 self.top_line = 0;
1948 self.top_row = 0;
1949 return;
1950 }
1951 let mut vi = self
1952 .visible_lines
1953 .iter()
1954 .position(|&l| l >= self.top_line)
1955 .unwrap_or(n - 1);
1956 if self.visible_lines[vi] != self.top_line {
1959 self.top_row = 0;
1960 }
1961 self.top_line = self.visible_lines[vi];
1962 let r_opts = self.render_opts(self.gutter_width(idx));
1963 if delta > 0 {
1964 let mut remaining = delta as usize;
1965 while remaining > 0 {
1966 let line = self.visible_lines[vi];
1967 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1968 if self.top_row + 1 < rows {
1969 self.top_row += 1;
1970 } else if vi + 1 < n {
1971 self.top_row = 0;
1972 vi += 1;
1973 self.top_line = self.visible_lines[vi];
1974 } else {
1975 break;
1976 }
1977 remaining -= 1;
1978 }
1979 let anchor = self.hide_bottom_anchor(src, idx);
1980 if (self.top_line, self.top_row) > anchor {
1981 self.top_line = anchor.0;
1982 self.top_row = anchor.1;
1983 }
1984 } else {
1985 let mut remaining = (-delta) as usize;
1986 while remaining > 0 {
1987 if self.top_row > 0 {
1988 self.top_row -= 1;
1989 } else if vi > 0 {
1990 vi -= 1;
1991 self.top_line = self.visible_lines[vi];
1992 let line = self.visible_lines[vi];
1993 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1994 self.top_row = rows.saturating_sub(1);
1995 } else {
1996 break;
1997 }
1998 remaining -= 1;
1999 }
2000 }
2001 return;
2002 }
2003 if delta > 0 {
2004 let mut remaining = delta as usize;
2005 while remaining > 0 {
2006 idx.extend_to_line(self.top_line + 1, src);
2007 let total = idx.line_count();
2008 if total == 0 { break; }
2009 let bytes = self.line_display_bytes(src, idx, self.top_line);
2010 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2011 if self.top_row + 1 < line_rows {
2012 self.top_row += 1;
2013 } else if self.top_line + 1 < total {
2014 self.top_row = 0;
2015 self.top_line += 1;
2016 } else {
2017 break;
2018 }
2019 remaining -= 1;
2020 }
2021 if idx.scanned_through() >= src.len() {
2026 let anchor = self.bottom_anchor(src, idx);
2027 if (self.top_line, self.top_row) > anchor {
2028 self.top_line = anchor.0;
2029 self.top_row = anchor.1;
2030 }
2031 }
2032 } else {
2033 let mut remaining = (-delta) as usize;
2034 while remaining > 0 {
2035 if self.top_row > 0 {
2036 self.top_row -= 1;
2037 } else if self.top_line > 0 {
2038 self.top_line -= 1;
2039 let bytes = self.line_display_bytes(src, idx, self.top_line);
2040 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2041 self.top_row = line_rows.saturating_sub(1);
2042 } else {
2043 break;
2044 }
2045 remaining -= 1;
2046 }
2047 }
2048 }
2049
2050 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2051 let n = self.page_size
2052 .map(|p| p as i64)
2053 .unwrap_or_else(|| self.body_rows() as i64);
2054 self.scroll_lines(n, src, idx);
2055 }
2056
2057 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2058 let n = self.page_size
2059 .map(|p| p as i64)
2060 .unwrap_or_else(|| self.body_rows() as i64);
2061 self.scroll_lines(-n, src, idx);
2062 }
2063
2064 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2065 let n = (self.body_rows() / 2).max(1) as i64;
2066 self.scroll_lines(n, src, idx);
2067 }
2068
2069 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2070 let n = (self.body_rows() / 2).max(1) as i64;
2071 self.scroll_lines(-n, src, idx);
2072 }
2073
2074 pub fn goto_top(&mut self) {
2075 self.top_line = 0;
2076 self.top_row = 0;
2077 }
2078
2079 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2086 let body = self.body_rows() as usize;
2087 let total = idx.line_count();
2088 if total == 0 || body == 0 {
2089 return (0, 0);
2090 }
2091 let r_opts = self.render_opts(self.gutter_width(idx));
2092 let mut remaining = body;
2093 let mut line = total - 1;
2094 loop {
2095 let bytes = self.line_display_bytes(src, idx, line);
2096 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
2097 if line_rows >= remaining {
2098 return (line, line_rows - remaining);
2099 }
2100 remaining -= line_rows;
2101 if line == 0 {
2102 return (0, 0);
2103 }
2104 line -= 1;
2105 }
2106 }
2107
2108 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2113 let body = self.body_rows() as usize;
2114 let n = self.visible_lines.len();
2115 if n == 0 || body == 0 {
2116 return (0, 0);
2117 }
2118 let r_opts = self.render_opts(self.gutter_width(idx));
2119 let mut remaining = body;
2120 let mut vi = n - 1;
2121 loop {
2122 let line = self.visible_lines[vi];
2123 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2124 if rows >= remaining {
2125 return (line, rows - remaining);
2126 }
2127 remaining -= rows;
2128 if vi == 0 {
2129 return (self.visible_lines[0], 0);
2130 }
2131 vi -= 1;
2132 }
2133 }
2134
2135 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2136 #[cfg(feature = "image")]
2137 if self.image_mode {
2138 let body = self.body_rows() as usize;
2139 self.top_line = self.image_total_rows().saturating_sub(body);
2140 self.top_row = 0;
2141 return;
2142 }
2143 idx.extend_to_end(src);
2144 if self.hide_mode() {
2145 self.extend_visible_lines(idx, src);
2146 let (line, row) = self.hide_bottom_anchor(src, idx);
2147 self.top_line = line;
2148 self.top_row = row;
2149 } else {
2150 let (line, row) = self.bottom_anchor(src, idx);
2151 self.top_line = line;
2152 self.top_row = row;
2153 }
2154 }
2155
2156 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2158 idx.extend_to_line(n, src);
2159 let target = n.min(idx.line_count().saturating_sub(1));
2160 self.top_line = target;
2161 self.top_row = 0;
2162 }
2163
2164 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2166 while idx.record_count() <= n && idx.scanned_through() < src.len() {
2170 idx.extend_to_end(src);
2171 }
2172 if idx.record_count() == 0 {
2173 return;
2174 }
2175 let target = n.min(idx.record_count().saturating_sub(1));
2176 let line_range = idx.record_line_range(target);
2177 self.top_line = line_range.start;
2178 self.top_row = 0;
2179 }
2180
2181 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
2184 let p = p.min(100) as usize;
2185 let target_byte = src.len().saturating_mul(p) / 100;
2186 idx.extend_to_byte_for_query(src, target_byte);
2187 let line_n = idx.line_at_byte(target_byte)
2188 .or_else(|| {
2189 let lc = idx.line_count();
2191 if lc > 0 { Some(lc - 1) } else { None }
2192 })
2193 .unwrap_or(0);
2194 self.top_line = line_n;
2195 self.top_row = 0;
2196 }
2197
2198 pub fn top_line(&self) -> usize {
2200 self.top_line
2201 }
2202
2203 pub fn resize(&mut self, cols: u16, rows: u16) {
2204 self.cols = cols.max(1);
2205 self.rows = rows.max(2);
2206 self.opts.cols = self.cols;
2207 }
2208
2209 pub fn toggle_line_numbers(&mut self) {
2210 self.show_line_numbers = !self.show_line_numbers;
2211 }
2212
2213 pub fn toggle_chop(&mut self) {
2214 self.opts.wrap = !self.opts.wrap;
2215 if self.opts.wrap {
2216 self.left_col = 0;
2217 }
2218 }
2219
2220 const HSCROLL_STEP: usize = 8;
2221
2222 pub fn hscroll_active(&self) -> bool {
2226 #[cfg(feature = "image")]
2227 if self.image.is_some() {
2228 return true;
2229 }
2230 !self.opts.wrap
2231 && !self.hex_mode
2232 && self.ansi_mode != crate::render::AnsiMode::Raw
2233 }
2234
2235 fn hscroll_by(&mut self, delta: isize) {
2236 if !self.hscroll_active() {
2237 return;
2238 }
2239 self.left_col = (self.left_col as isize + delta).max(0) as usize;
2240 }
2242
2243 pub fn hscroll_left_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2244 pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2245 pub fn hscroll_left_step(&mut self) { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2246 pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2247
2248 pub fn hscroll_left_cols(&mut self, n: u16) { self.hscroll_by(-(n as isize)); }
2250 pub fn hscroll_right_cols(&mut self, n: u16) { self.hscroll_by(n as isize); }
2252
2253 pub fn left_col(&self) -> usize { self.left_col }
2254
2255 pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2258
2259 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2263}
2264
2265#[cfg(feature = "image")]
2270pub fn protocol_scaled_dims(img_w: u32, img_h: u32, cols: u16,
2271 cell_px: (u16, u16), width_cols: Option<usize>) -> (u32, u32) {
2272 let target_cols = width_cols.unwrap_or(cols as usize).max(1) as u32;
2273 let scaled_w = (target_cols * cell_px.0.max(1) as u32).max(1);
2274 let img_w = img_w.max(1);
2275 let scaled_h = (img_h as u64 * scaled_w as u64 / img_w as u64).max(1) as u32;
2276 (scaled_w, scaled_h)
2277}
2278
2279#[cfg(feature = "image")]
2282pub fn protocol_occupied_rows(img_w: u32, img_h: u32, cols: u16,
2283 cell_px: (u16, u16), width_cols: Option<usize>) -> usize {
2284 let (_, scaled_h) = protocol_scaled_dims(img_w, img_h, cols, cell_px, width_cols);
2285 (scaled_h as usize).div_ceil(cell_px.1.max(1) as usize).max(1)
2286}
2287
2288#[cfg(test)]
2289mod tests {
2290 use super::*;
2291 use crate::source::MockSource;
2292
2293 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2294 let m = MockSource::new();
2295 m.append(content);
2296 m.finish();
2297 let idx = LineIndex::new();
2298 (m, idx)
2299 }
2300
2301 fn first_cell_char(row: &[Cell]) -> char {
2304 match row.first() {
2305 Some(Cell::Char { ch, .. }) => *ch,
2306 other => panic!("expected Char in first cell, got {:?}", other),
2307 }
2308 }
2309
2310 #[test]
2311 fn status_column_shows_mark_then_search_glyphs() {
2312 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2315 let mut v = Viewport::new(20, 5, "f".into()); v.opts.wrap = false;
2317 v.set_status_column(true);
2318 let mut marks = std::collections::HashMap::new();
2319 marks.insert(1usize, 'a');
2320 v.set_status_marks(marks);
2321 v.set_search("cc".into(), SearchDirection::Forward).unwrap();
2322
2323 let frame = v.frame(&m, &mut idx);
2324 assert_eq!(first_cell_char(&frame.body[0]), ' ', "line 0: no mark, no match");
2325 assert_eq!(first_cell_char(&frame.body[1]), 'a', "line 1: mark letter");
2326 assert_eq!(first_cell_char(&frame.body[2]), '*', "line 2: search match");
2327 }
2328
2329 #[test]
2330 fn status_column_mark_beats_search_match() {
2331 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2334 let mut v = Viewport::new(20, 5, "f".into());
2335 v.opts.wrap = false;
2336 v.set_status_column(true);
2337 let mut marks = std::collections::HashMap::new();
2338 marks.insert(1usize, 'z');
2339 v.set_status_marks(marks);
2340 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2341
2342 let frame = v.frame(&m, &mut idx);
2343 assert_eq!(first_cell_char(&frame.body[1]), 'z', "mark beats search-match");
2344 }
2345
2346 #[test]
2347 fn status_column_matches_content_not_gutter_digits() {
2348 let (m, mut idx) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2355 let mut v = Viewport::new(40, 14, "f".into()); v.opts.wrap = false;
2357 v.show_line_numbers = true;
2358 v.set_status_column(true);
2359 v.set_search("5".into(), SearchDirection::Forward).unwrap();
2360
2361 let frame = v.frame(&m, &mut idx);
2362 for i in 0..12 {
2366 assert_eq!(
2367 first_cell_char(&frame.body[i]), ' ',
2368 "body row {i}: no content match for '5' but status column flagged it"
2369 );
2370 }
2371
2372 let (m2, mut idx2) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2374 let mut v2 = Viewport::new(40, 14, "f".into());
2375 v2.opts.wrap = false;
2376 v2.show_line_numbers = true;
2377 v2.set_status_column(true);
2378 v2.set_search("ee".into(), SearchDirection::Forward).unwrap();
2379 let frame2 = v2.frame(&m2, &mut idx2);
2380 assert_eq!(first_cell_char(&frame2.body[4]), '*', "line 5 content 'ee' matches search");
2381 }
2382
2383 #[test]
2384 fn status_column_off_leaves_first_cell_as_content() {
2385 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2388 let mut v = Viewport::new(20, 5, "f".into());
2389 v.opts.wrap = false;
2390 let mut marks = std::collections::HashMap::new();
2392 marks.insert(1usize, 'a');
2393 v.set_status_marks(marks);
2394 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2395
2396 let frame = v.frame(&m, &mut idx);
2397 assert_eq!(first_cell_char(&frame.body[0]), 'a', "line 0 content unchanged");
2398 assert_eq!(first_cell_char(&frame.body[1]), 'b', "line 1 content unchanged");
2399 assert_eq!(first_cell_char(&frame.body[2]), 'c', "line 2 content unchanged");
2400 }
2401
2402 #[test]
2403 fn frame_renders_body_height_rows() {
2404 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2405 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
2407 assert_eq!(frame.body.len(), 4);
2408 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2409 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2410 }
2411
2412 #[test]
2413 fn scroll_down_advances_top_line() {
2414 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2417 let mut v = Viewport::new(10, 5, "test".into());
2418 v.scroll_lines(2, &m, &mut idx);
2419 assert_eq!(v.top_line, 2);
2420 assert_eq!(v.top_row, 0);
2421 }
2422
2423 #[test]
2424 fn scroll_up_clamps_at_zero() {
2425 let (m, mut idx) = setup(b"a\nb\nc\n");
2426 let mut v = Viewport::new(10, 5, "test".into());
2427 v.scroll_lines(-5, &m, &mut idx);
2428 assert_eq!(v.top_line, 0);
2429 assert_eq!(v.top_row, 0);
2430 }
2431
2432 #[test]
2433 fn scroll_down_clamps_at_last_line() {
2434 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2439 let mut v = Viewport::new(10, 5, "test".into());
2440 v.scroll_lines(50, &m, &mut idx);
2441 assert_eq!((v.top_line, v.top_row), (4, 0));
2442 assert!(v.is_at_bottom(&m, &idx));
2443 }
2444
2445 #[test]
2446 fn scroll_logical_lines_skips_wrap_rows() {
2447 let mut content = vec![b'X'; 500];
2449 content.push(b'\n');
2450 content.extend_from_slice(b"second\n");
2451 content.extend_from_slice(b"third\n");
2452 let (m, mut idx) = setup(&content);
2453 let mut v = Viewport::new(10, 8, "f".into());
2454 v.scroll_logical_lines(1, &m, &mut idx);
2455 assert_eq!((v.top_line, v.top_row), (1, 0));
2456 v.scroll_logical_lines(1, &m, &mut idx);
2457 assert_eq!((v.top_line, v.top_row), (2, 0));
2458 }
2459
2460 #[test]
2461 fn scroll_logical_lines_back_snaps_to_line_start() {
2462 let mut content = vec![b'A'; 50];
2467 content.push(b'\n');
2468 content.extend_from_slice(&[b'B'; 50]);
2469 content.push(b'\n');
2470 content.extend_from_slice(&[b'C'; 50]);
2471 content.push(b'\n');
2472 let (m, mut idx) = setup(&content);
2473 let mut v = Viewport::new(10, 8, "f".into());
2474 v.scroll_lines(7, &m, &mut idx);
2475 assert_eq!(v.top_line, 1, "should be on line 1");
2476 assert!(v.top_row > 0, "should be inside line 1's wraps");
2477 v.scroll_logical_lines(-1, &m, &mut idx);
2478 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2479 v.scroll_logical_lines(-1, &m, &mut idx);
2480 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2481 }
2482
2483 #[test]
2484 fn scroll_down_walks_wraps_of_last_line() {
2485 let mut content = b"first\n".to_vec();
2489 content.extend_from_slice(&[b'X'; 60]);
2490 content.push(b'\n');
2491 let (m, mut idx) = setup(&content);
2492 let mut v = Viewport::new(10, 5, "f".into());
2493 v.scroll_lines(1, &m, &mut idx);
2494 assert_eq!((v.top_line, v.top_row), (1, 0));
2495 v.scroll_lines(1, &m, &mut idx);
2496 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2497 v.scroll_lines(1, &m, &mut idx);
2498 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2499 v.scroll_lines(5, &m, &mut idx);
2501 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2502 }
2503
2504 #[test]
2505 fn scroll_down_walks_wrap_rows_within_long_line() {
2506 let mut content = vec![b'X'; 30];
2510 content.push(b'\n');
2511 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2512 let (m, mut idx) = setup(&content);
2513 let mut v = Viewport::new(10, 5, "f".into());
2514 v.scroll_lines(1, &m, &mut idx);
2515 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2516 v.scroll_lines(1, &m, &mut idx);
2517 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2518 v.scroll_lines(1, &m, &mut idx);
2519 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2520 }
2521
2522 #[test]
2523 fn status_line_shows_range_and_pct() {
2524 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2525 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2527 assert!(frame.status.starts_with("f 1-4/10"));
2528 }
2529
2530 #[test]
2531 fn page_down_advances_by_body_rows() {
2532 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2533 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2535 assert_eq!(v.top_line, 4);
2536 }
2537
2538 #[test]
2539 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2540 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2541 let mut v = Viewport::new(10, 5, "f".into());
2542 v.page_down(&m, &mut idx);
2543 v.page_up(&m, &mut idx);
2544 assert_eq!(v.top_line, 0);
2545 assert_eq!(v.top_row, 0);
2546 }
2547
2548 #[test]
2549 fn half_page_down_advances_by_half_body() {
2550 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2553 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2555 assert_eq!(v.top_line, 3);
2556 }
2557
2558 #[test]
2559 fn goto_top_resets_position() {
2560 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2561 let mut v = Viewport::new(10, 5, "f".into());
2562 v.scroll_lines(2, &m, &mut idx);
2563 v.goto_top();
2564 assert_eq!(v.top_line, 0);
2565 assert_eq!(v.top_row, 0);
2566 }
2567
2568 #[test]
2569 fn goto_bottom_scrolls_to_last_page() {
2570 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2571 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2573 assert_eq!(v.top_line, 6);
2575 }
2576
2577 #[test]
2578 #[cfg(feature = "image")]
2579 fn protocol_image_occupied_rows_fit_width() {
2580 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), None), 50);
2582 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), Some(25)), 25);
2584 }
2585
2586 #[cfg(feature = "image")]
2587 #[test]
2588 fn frame_image_protocol_sets_image_blob() {
2589 use image::{Rgba, RgbaImage};
2590 let mut vp = Viewport::new(40, 10, "cat.png".into());
2591 let mut idx = LineIndex::new();
2592 let m = MockSource::new();
2593 vp.set_image(RgbaImage::from_pixel(20, 40, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2594 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2595 let frame = vp.frame(&m, &mut idx);
2596 assert!(frame.image_blob.is_some(), "Kitty protocol frame carries an image blob");
2597 vp.set_image_protocol(crate::viewport::ImageProtocol::Ascii, None);
2599 let frame2 = vp.frame(&m, &mut idx);
2600 assert!(frame2.image_blob.is_none(), "ASCII protocol frame has no blob");
2601 }
2602
2603 #[cfg(feature = "image")]
2604 #[test]
2605 fn protocol_image_clamps_vertical_scroll() {
2606 use image::{Rgba, RgbaImage};
2607 let mut vp = Viewport::new(40, 10, "cat.png".into()); let mut idx = LineIndex::new();
2609 let m = MockSource::new();
2610 vp.set_image(RgbaImage::from_pixel(20, 2000, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2612 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2613 for _ in 0..10_000 {
2615 vp.scroll_lines(1, &m, &mut idx);
2616 }
2617 let _ = vp.frame(&m, &mut idx);
2618 let total = crate::viewport::protocol_occupied_rows(20, 2000, 40, (8, 16), None);
2620 let body = vp.body_rows() as usize;
2621 assert_eq!(
2622 vp.top_line(),
2623 total.saturating_sub(body),
2624 "scroll reaches exactly the protocol image bottom"
2625 );
2626 }
2627
2628 #[cfg(feature = "image")]
2629 #[test]
2630 fn image_mode_frame_renders_and_scrolls() {
2631 use image::{Rgba, RgbaImage};
2632 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2633 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2635 assert!(v.image_mode());
2636 let total = v.image_total_rows();
2637 assert!(total > 5, "tall image should exceed the body");
2638 assert!(!v.is_at_bottom_image(), "starts at top");
2639 let mut idx = LineIndex::new();
2640 let m = MockSource::new();
2641 let frame = v.frame(&m, &mut idx);
2642 assert_eq!(frame.body.len(), 5);
2643 v.goto_bottom(&m, &mut idx);
2644 assert!(v.is_at_bottom_image());
2645 }
2646
2647 #[cfg(feature = "image")]
2648 #[test]
2649 fn frame_image_slices_at_left_col() {
2650 use crate::render::Cell;
2651 use image::{Rgba, RgbaImage};
2652
2653 let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2659 let mut v = Viewport::new(10, 4, "wide.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2661 assert!(v.hscroll_active(), "image mode should make hscroll active");
2662
2663 let mut idx = LineIndex::new();
2664 let m = MockSource::new();
2665
2666 assert_eq!(v.left_col(), 0);
2668 let frame0 = v.frame(&m, &mut idx);
2669 assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2670 assert_eq!(frame0.body[0].len(), 10);
2672 assert!(
2674 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2675 "no scroll marker expected on image frame at left_col=0"
2676 );
2677 let cell_at_col0 = frame0.body[0][0].clone();
2679 let cell_at_col8 = frame0.body[0][8].clone();
2680
2681 v.hscroll_right_step();
2683 assert_eq!(v.left_col(), 8);
2684 let frame1 = v.frame(&m, &mut idx);
2685 assert_eq!(frame1.body[0].len(), 10);
2686 assert!(
2688 !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2689 "no scroll marker expected on image frame after hscroll_right_step"
2690 );
2691 assert_eq!(
2693 frame1.body[0][0], cell_at_col8,
2694 "after hscroll_right_step the first visible cell should be grid col 8"
2695 );
2696 assert_ne!(
2700 frame1.body[0][0], cell_at_col0,
2701 "the scrolled first cell must differ from the unscrolled one"
2702 );
2703 }
2704
2705 #[test]
2706 fn goto_line_positions_top_line() {
2707 let m = MockSource::new();
2708 m.append(b"a\nb\nc\nd\ne\n");
2709 let mut idx = LineIndex::new();
2710 idx.extend_to_end(&m);
2711 let mut v = Viewport::new(20, 5, "f".into());
2712 v.goto_line(3, &m, &mut idx);
2713 assert_eq!(v.top_line(), 3);
2714 }
2715
2716 #[test]
2717 fn goto_line_clamps_to_last_line() {
2718 let m = MockSource::new();
2719 m.append(b"a\nb\n");
2720 let mut idx = LineIndex::new();
2721 idx.extend_to_end(&m);
2722 let mut v = Viewport::new(20, 5, "f".into());
2723 v.goto_line(999, &m, &mut idx);
2724 assert_eq!(v.top_line(), 1);
2725 }
2726
2727 #[test]
2728 fn goto_record_positions_at_record_start_line() {
2729 let m = MockSource::new();
2730 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
2731 let mut idx = LineIndex::new();
2732 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2733 idx.extend_to_end(&m);
2734 let mut v = Viewport::new(20, 5, "f".into());
2735 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2737 }
2738
2739 #[test]
2740 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2741 let m = MockSource::new();
2742 m.append(b"a\nb\nc\n");
2743 let mut idx = LineIndex::new();
2744 idx.extend_to_end(&m);
2745 let mut v = Viewport::new(20, 5, "f".into());
2746 v.goto_record(2, &m, &mut idx);
2747 assert_eq!(v.top_line(), 2);
2748 }
2749
2750 #[test]
2751 fn goto_percent_50_lands_in_middle() {
2752 let m = MockSource::new();
2753 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2755 idx.extend_to_end(&m);
2756 let mut v = Viewport::new(20, 5, "f".into());
2757 v.goto_percent(50, &m, &mut idx);
2758 assert_eq!(v.top_line(), 2); }
2760
2761 #[test]
2762 fn goto_percent_100_lands_at_last_line() {
2763 let m = MockSource::new();
2764 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2766 idx.extend_to_end(&m);
2767 let mut v = Viewport::new(20, 5, "f".into());
2768 v.goto_percent(100, &m, &mut idx);
2769 assert_eq!(v.top_line(), 2);
2770 }
2771
2772 #[test]
2773 fn goto_percent_0_lands_at_first_line() {
2774 let m = MockSource::new();
2775 m.append(b"a\nb\nc\n");
2776 let mut idx = LineIndex::new();
2777 idx.extend_to_end(&m);
2778 let mut v = Viewport::new(20, 5, "f".into());
2779 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2781 v.goto_percent(0, &m, &mut idx);
2782 assert_eq!(v.top_line(), 0);
2783 }
2784
2785 #[test]
2786 fn resize_updates_dimensions_and_render_opts() {
2787 let (m, mut idx) = setup(b"1\n2\n");
2788 let mut v = Viewport::new(10, 5, "f".into());
2789 v.resize(40, 12);
2790 assert_eq!(v.cols, 40);
2791 assert_eq!(v.rows, 12);
2792 assert_eq!(v.opts.cols, 40);
2793 let _ = v.frame(&m, &mut idx);
2794 }
2795
2796 #[test]
2797 fn toggle_line_numbers_changes_gutter() {
2798 let (m, mut idx) = setup(b"a\nb\nc\n");
2799 let mut v = Viewport::new(10, 5, "f".into());
2800 let frame_off = v.frame(&m, &mut idx);
2801 v.toggle_line_numbers();
2802 let frame_on = v.frame(&m, &mut idx);
2803 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2805 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2806 }
2807
2808 #[test]
2809 fn toggle_chop_changes_wrap_mode() {
2810 let (m, mut idx) = setup(b"abcdefghij\n");
2811 let mut v = Viewport::new(4, 5, "f".into());
2812 v.toggle_chop();
2813 let frame = v.frame(&m, &mut idx);
2814 assert_eq!(frame.body[0][..4],
2817 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2818 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2819 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2820 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2821 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2823 }
2824
2825 #[test]
2828 fn is_at_bottom_initially_only_when_source_fits() {
2829 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2832 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2833 }
2834
2835 #[test]
2836 fn is_at_bottom_false_when_top_and_more_lines_below() {
2837 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);
2840 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2841 }
2842
2843 #[test]
2844 fn is_at_bottom_true_after_goto_bottom() {
2845 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2846 let mut v = Viewport::new(10, 5, "f".into());
2847 v.goto_bottom(&m, &mut idx);
2848 assert!(v.is_at_bottom(&m, &idx));
2849 }
2850
2851 #[test]
2852 fn status_shows_follow_suffix_when_follow_mode_on() {
2853 let (m, mut idx) = setup(b"a\nb\n");
2854 let mut v = Viewport::new(20, 5, "f".into());
2855 let frame_off = v.frame(&m, &mut idx);
2856 assert!(!frame_off.status.contains("(F)"));
2857 v.set_follow_mode(true);
2858 let frame_on = v.frame(&m, &mut idx);
2859 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2860 }
2861
2862 #[test]
2863 fn toggle_follow_flips_state() {
2864 let mut v = Viewport::new(10, 5, "f".into());
2865 assert!(!v.follow_mode());
2866 v.toggle_follow();
2867 assert!(v.follow_mode());
2868 v.toggle_follow();
2869 assert!(!v.follow_mode());
2870 }
2871
2872 #[test]
2873 fn idle_indicator_kicks_in_at_threshold() {
2874 let (m, mut idx) = setup(b"a\nb\n");
2875 let mut v = Viewport::new(20, 5, "f".into());
2876 v.set_follow_mode(true);
2877 for _ in 0..19 { v.tick_idle(); }
2879 let f1 = v.frame(&m, &mut idx);
2880 assert!(f1.status.contains("(F)"));
2881 assert!(!f1.status.contains("idle"));
2882 v.tick_idle();
2884 let f2 = v.frame(&m, &mut idx);
2885 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2886 }
2887
2888 #[test]
2889 fn note_growth_resets_idle() {
2890 let (m, mut idx) = setup(b"a\nb\n");
2891 let mut v = Viewport::new(20, 5, "f".into());
2892 v.set_follow_mode(true);
2893 for _ in 0..25 { v.tick_idle(); }
2894 assert!(v.is_idle());
2895 v.note_growth();
2896 assert!(!v.is_idle());
2897 let f = v.frame(&m, &mut idx);
2898 assert!(!f.status.contains("idle"));
2899 }
2900
2901 #[test]
2902 fn qae_off_never_quits_even_at_bottom() {
2903 let (m, mut idx) = setup(b"a\n");
2904 let mut v = Viewport::new(20, 5, "f".into());
2905 v.set_quit_at_eof(QuitAtEof::Off);
2906 v.goto_bottom(&m, &mut idx);
2907 assert!(!v.note_motion_for_eof(true, &m, &idx));
2908 }
2909
2910 #[test]
2911 fn qae_first_quits_immediately_at_bottom() {
2912 let (m, mut idx) = setup(b"a\n");
2913 let mut v = Viewport::new(20, 5, "f".into());
2914 v.set_quit_at_eof(QuitAtEof::First);
2915 v.goto_bottom(&m, &mut idx);
2916 assert!(v.note_motion_for_eof(true, &m, &idx));
2917 }
2918
2919 #[test]
2920 fn qae_first_only_quits_at_eof_not_mid_file() {
2921 let mut content = Vec::new();
2922 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2923 let (m, mut idx) = setup(&content);
2924 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2926 v.set_quit_at_eof(QuitAtEof::First);
2927 assert!(!v.is_at_bottom(&m, &idx));
2929 assert!(!v.note_motion_for_eof(true, &m, &idx));
2930 }
2931
2932 #[test]
2933 fn qae_second_quits_on_second_hit() {
2934 let (m, mut idx) = setup(b"a\n");
2935 let mut v = Viewport::new(20, 5, "f".into());
2936 v.set_quit_at_eof(QuitAtEof::Second);
2937 v.goto_bottom(&m, &mut idx);
2938 assert!(!v.note_motion_for_eof(true, &m, &idx));
2940 assert!(v.note_motion_for_eof(true, &m, &idx));
2942 }
2943
2944 #[test]
2945 fn squeeze_collapses_consecutive_blanks() {
2946 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2948 let mut v = Viewport::new(10, 8, "f".into());
2949 v.set_squeeze_blanks(true);
2950 let f = v.frame(&m, &mut idx);
2951 let stringify = |row: &Vec<Cell>| -> String {
2953 row.iter().filter_map(|c| match c {
2954 Cell::Char { ch, .. } => Some(*ch),
2955 _ => None,
2956 }).collect::<String>().trim().to_string()
2957 };
2958 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2959 assert_eq!(&rows[0], "a");
2961 assert_eq!(&rows[1], "");
2962 assert_eq!(&rows[2], "b");
2963 }
2964
2965 #[test]
2966 fn header_pins_top_rows_when_scrolling() {
2967 let mut content = Vec::new();
2969 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2970 let (m, mut idx) = setup(&content);
2971 let mut v = Viewport::new(20, 6, "f".into());
2972 v.set_header(2, 0);
2973 v.scroll_lines(5, &m, &mut idx);
2977 let f = v.frame(&m, &mut idx);
2978 let chs = |row: &Vec<Cell>| -> String {
2979 row.iter().filter_map(|c| match c {
2980 Cell::Char { ch, .. } => Some(*ch),
2981 _ => None,
2982 }).collect::<String>().trim().to_string()
2983 };
2984 assert_eq!(&chs(&f.body[0]), "line0");
2986 assert_eq!(&chs(&f.body[1]), "line1");
2987 assert_eq!(&chs(&f.body[2]), "line7");
2989 }
2990
2991 #[test]
2992 fn page_size_when_set_overrides_body_rows() {
2993 let mut content = Vec::new();
2994 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2995 let (m, mut idx) = setup(&content);
2996 let mut v = Viewport::new(20, 10, "f".into());
2997 v.set_page_size(Some(3));
2998 let before = v.top_line();
2999 v.page_down(&m, &mut idx);
3000 assert_eq!(v.top_line(), before + 3);
3001 v.page_up(&m, &mut idx);
3002 assert_eq!(v.top_line(), before);
3003 }
3004
3005 #[test]
3006 fn page_size_unset_uses_body_rows() {
3007 let mut content = Vec::new();
3008 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3009 let (m, mut idx) = setup(&content);
3010 let mut v = Viewport::new(20, 10, "f".into());
3011 v.page_down(&m, &mut idx);
3013 assert_eq!(v.top_line(), 9);
3014 }
3015
3016 #[test]
3017 fn header_zero_lines_renders_like_no_header() {
3018 let mut content = Vec::new();
3019 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3020 let (m, mut idx) = setup(&content);
3021 let mut v = Viewport::new(20, 6, "f".into());
3022 v.set_header(0, 0);
3023 let f = v.frame(&m, &mut idx);
3024 let chs = |row: &Vec<Cell>| -> String {
3025 row.iter().filter_map(|c| match c {
3026 Cell::Char { ch, .. } => Some(*ch),
3027 _ => None,
3028 }).collect::<String>().trim().to_string()
3029 };
3030 assert_eq!(&chs(&f.body[0]), "line0");
3031 assert_eq!(&chs(&f.body[1]), "line1");
3032 }
3033
3034 #[test]
3035 fn squeeze_off_preserves_blanks() {
3036 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3037 let mut v = Viewport::new(10, 8, "f".into());
3038 let f = v.frame(&m, &mut idx);
3040 let stringify = |row: &Vec<Cell>| -> String {
3041 row.iter().filter_map(|c| match c {
3042 Cell::Char { ch, .. } => Some(*ch),
3043 _ => None,
3044 }).collect::<String>().trim().to_string()
3045 };
3046 let rows: Vec<String> = f.body.iter().map(stringify).collect();
3047 assert_eq!(&rows[0], "a");
3049 assert_eq!(&rows[1], "");
3050 assert_eq!(&rows[2], "");
3051 assert_eq!(&rows[3], "");
3052 assert_eq!(&rows[4], "b");
3053 }
3054
3055 #[test]
3056 fn qae_second_resets_on_backward_motion() {
3057 let (m, mut idx) = setup(b"a\n");
3058 let mut v = Viewport::new(20, 5, "f".into());
3059 v.set_quit_at_eof(QuitAtEof::Second);
3060 v.goto_bottom(&m, &mut idx);
3061 assert!(!v.note_motion_for_eof(true, &m, &idx));
3062 v.note_motion_for_eof(false, &m, &idx);
3064 assert!(!v.note_motion_for_eof(true, &m, &idx));
3066 assert!(v.note_motion_for_eof(true, &m, &idx));
3068 }
3069
3070 #[test]
3071 fn flash_message_overrides_follow_suffix() {
3072 let (m, mut idx) = setup(b"a\nb\n");
3073 let mut v = Viewport::new(40, 5, "f".into());
3074 v.set_follow_mode(true);
3075 v.flash("(F reopened)", 3);
3076 let f = v.frame(&m, &mut idx);
3077 assert!(f.status.contains("(F reopened)"), "{}", f.status);
3078 assert!(!f.status.contains("(F idle)"));
3079 }
3080
3081 #[test]
3082 fn flash_countdown_clears() {
3083 let mut v = Viewport::new(10, 5, "f".into());
3084 v.flash("hello", 2);
3085 v.tick_flash();
3086 assert!(v.status_flash.is_some());
3087 v.tick_flash();
3088 assert!(v.status_flash.is_none());
3089 }
3090
3091 #[test]
3092 fn suspend_follow_if_off_is_noop() {
3093 let mut v = Viewport::new(10, 5, "f".into());
3094 v.set_follow_mode(true);
3095 v.suspend_follow_if(false);
3096 assert!(v.follow_mode());
3097 }
3098
3099 #[test]
3100 fn suspend_follow_if_on_flips_off() {
3101 let mut v = Viewport::new(10, 5, "f".into());
3102 v.set_follow_mode(true);
3103 v.suspend_follow_if(true);
3104 assert!(!v.follow_mode());
3105 }
3106
3107 #[test]
3108 fn case_mode_sensitive_returns_pattern_unchanged() {
3109 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
3110 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
3111 }
3112
3113 #[test]
3114 fn case_mode_insensitive_prepends_i_flag() {
3115 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
3116 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
3117 }
3118
3119 #[test]
3120 fn case_mode_smart_lowercase_is_insensitive() {
3121 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
3122 }
3123
3124 #[test]
3125 fn case_mode_smart_with_uppercase_is_sensitive() {
3126 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
3127 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
3128 }
3129
3130 #[test]
3131 fn set_case_mode_recompiles_active_search() {
3132 let (m, mut idx) = setup(b"hello WORLD\n");
3133 let mut v = Viewport::new(40, 5, "f".into());
3134 v.set_search("world".into(), SearchDirection::Forward).unwrap();
3135 assert!(!v.search_repeat(&m, &mut idx, false));
3137 v.set_case_mode(CaseMode::Insensitive);
3139 assert!(v.search_repeat(&m, &mut idx, false));
3140 }
3141
3142 #[test]
3143 fn status_shows_prettify_label_when_set() {
3144 let (m, mut idx) = setup(b"a\n");
3145 let mut v = Viewport::new(40, 5, "f".into());
3146 let frame_off = v.frame(&m, &mut idx);
3147 assert!(!frame_off.status.contains("[pretty"));
3148 v.set_prettify_label(Some("json".into()));
3149 let frame_on = v.frame(&m, &mut idx);
3150 assert!(frame_on.status.contains("[pretty:json]"),
3151 "expected [pretty:json] in status, got: {}", frame_on.status);
3152 v.set_prettify_label(Some("json:err".into()));
3153 let frame_err = v.frame(&m, &mut idx);
3154 assert!(frame_err.status.contains("[pretty:json:err]"),
3155 "expected [pretty:json:err] in status, got: {}", frame_err.status);
3156 }
3157
3158 #[test]
3159 fn status_shows_l_suffix_when_live_mode_on() {
3160 let (m, mut idx) = setup(b"a\nb\n");
3161 let mut v = Viewport::new(20, 5, "f".into());
3162 let frame_off = v.frame(&m, &mut idx);
3163 assert!(!frame_off.status.contains("(L)"));
3164 v.set_live_mode(true);
3165 let frame_on = v.frame(&m, &mut idx);
3166 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
3167 }
3168
3169 #[test]
3170 fn clamp_top_line_pulls_back_when_total_shrinks() {
3171 let mut v = Viewport::new(20, 5, "f".into());
3172 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
3181 let (m, mut idx) = setup(b"only\n");
3183 let _ = v.frame(&m, &mut idx);
3184 }
3185
3186 fn simulate_growth_tick(
3189 v: &mut Viewport,
3190 src: &MockSource,
3191 idx: &mut LineIndex,
3192 ) {
3193 if !v.follow_mode() { return; }
3194 let was_at_bottom = v.is_at_bottom(src, idx);
3195 let lines_before = idx.line_count();
3196 idx.notice_new_bytes(src);
3197 if idx.line_count() != lines_before && was_at_bottom {
3198 v.goto_bottom(src, idx);
3199 }
3200 }
3201
3202 #[test]
3203 fn auto_scroll_engages_when_at_bottom() {
3204 let m = MockSource::new();
3205 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
3207 let mut v = Viewport::new(10, 5, "f".into());
3208 v.set_follow_mode(true);
3209 idx.extend_to_end(&m);
3210 assert!(v.is_at_bottom(&m, &idx));
3211 let top_before = {
3212 let f = v.frame(&m, &mut idx);
3213 f.status.clone() };
3215 let _ = top_before;
3216 m.append(b"5\n6\n7\n8\n");
3218 simulate_growth_tick(&mut v, &m, &mut idx);
3219 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
3221 let frame = v.frame(&m, &mut idx);
3222 let last_row = &frame.body[frame.body.len() - 1];
3225 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
3226 }
3227
3228 #[test]
3229 fn auto_scroll_suppressed_when_scrolled_up() {
3230 let m = MockSource::new();
3231 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
3233 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
3235 idx.extend_to_end(&m);
3236 v.goto_bottom(&m, &mut idx);
3237 v.scroll_lines(-2, &m, &mut idx);
3239 assert!(!v.is_at_bottom(&m, &idx));
3240 let frame_before = v.frame(&m, &mut idx);
3241 let top_first_cell_before = frame_before.body[0][0].clone();
3242 m.append(b"9\n10\n");
3244 simulate_growth_tick(&mut v, &m, &mut idx);
3245 let frame_after = v.frame(&m, &mut idx);
3247 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
3248 }
3249
3250 #[test]
3253 fn set_search_compiles_regex() {
3254 let mut v = Viewport::new(10, 5, "f".into());
3255 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
3256 assert!(v.search_active());
3257 }
3258
3259 #[test]
3260 fn set_search_rejects_bad_regex() {
3261 let mut v = Viewport::new(10, 5, "f".into());
3262 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
3263 assert!(!err.is_empty());
3264 assert!(!v.search_active(), "no search should be set on error");
3265 }
3266
3267 #[test]
3268 fn search_step_forward_finds_match_after_top() {
3269 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3270 let mut v = Viewport::new(20, 5, "f".into());
3271 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3272 let found = v.search_repeat(&m, &mut idx, false);
3273 assert!(found);
3274 assert_eq!(v.top_line, 2);
3276 }
3277
3278 #[test]
3279 fn search_step_backward_finds_match_before_top() {
3280 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3281 let mut v = Viewport::new(20, 5, "f".into());
3282 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
3284 let found = v.search_repeat(&m, &mut idx, false);
3285 assert!(found);
3286 assert_eq!(v.top_line, 0);
3287 }
3288
3289 #[test]
3290 fn search_wraps_at_end() {
3291 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3292 let mut v = Viewport::new(20, 5, "f".into());
3293 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
3295 let found = v.search_repeat(&m, &mut idx, false);
3296 assert!(found, "search should wrap forward past EOF");
3297 assert_eq!(v.top_line, 0);
3298 }
3299
3300 #[test]
3301 fn search_no_match_returns_false_and_does_not_move() {
3302 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3303 let mut v = Viewport::new(20, 5, "f".into());
3304 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
3305 let found = v.search_repeat(&m, &mut idx, false);
3306 assert!(!found);
3307 assert_eq!(v.top_line, 0);
3308 }
3309
3310 #[test]
3311 fn frame_records_highlight_ranges_for_matches() {
3312 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
3313 let mut v = Viewport::new(20, 5, "f".into());
3314 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3315 let frame = v.frame(&m, &mut idx);
3316 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3318 assert!(frame.highlights[0].is_empty());
3319 assert!(frame.highlights[1].is_empty());
3320 assert_eq!(frame.highlights[2], vec![0..5]);
3321 assert!(frame.highlights[3].is_empty());
3322 }
3323
3324 #[test]
3325 fn frame_highlights_substring_inside_a_row() {
3326 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
3327 let mut v = Viewport::new(40, 5, "f".into());
3328 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3329 let frame = v.frame(&m, &mut idx);
3330 assert_eq!(frame.highlights[0], vec![18..22]);
3332 assert!(frame.highlights[1].is_empty());
3333 }
3334
3335 #[test]
3336 fn search_highlight_with_filter_dim_keeps_row_dim() {
3337 let (m, mut idx) = setup(b"alpha\nbeta\n");
3340 let mut v = Viewport::new(20, 5, "f".into());
3341 let fmt = crate::format::LogFormat::compile(
3342 "simple",
3343 r"^(?P<line>.+)$",
3344 )
3345 .unwrap();
3346 let f = crate::filter::CompiledFilter::compile(
3347 &fmt,
3348 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
3349 CaseMode::Sensitive,
3350 )
3351 .unwrap();
3352 v.set_filter(Some(f));
3353 v.set_dim_mode(true);
3354 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3355 let frame = v.frame(&m, &mut idx);
3356 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3357 assert_eq!(frame.row_styles[1], RowStyle::Dim);
3358 assert_eq!(frame.highlights[1], vec![0..4]);
3359 }
3360
3361 #[test]
3362 fn grep_only_hides_non_matching_lines() {
3363 use crate::grep::GrepPredicate;
3364 let src = crate::source::MockSource::new();
3365 src.append(b"keep this error\n");
3366 src.append(b"drop this one\n");
3367 src.append(b"another error line\n");
3368 src.finish();
3369 let mut idx = crate::line_index::LineIndex::new();
3370 idx.extend_to_end(&src);
3371
3372 let mut v = Viewport::new(40, 5, "test".into());
3373 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
3374 v.extend_visible_lines(&idx, &src);
3375
3376 let frame = v.frame(&src, &mut idx);
3378 let body_text: Vec<String> = frame.body.iter()
3379 .map(|row| row.iter().filter_map(|c| match c {
3380 crate::render::Cell::Char { ch, .. } => Some(*ch),
3381 _ => None,
3382 }).collect())
3383 .collect();
3384 assert!(body_text[0].contains("keep this error"));
3385 assert!(body_text[1].contains("another error line"));
3386 assert!(frame.status.contains("[grep]"));
3387 }
3388
3389 #[test]
3390 fn incsearch_preview_anchors_from_origin_not_previous_match() {
3391 let src = crate::source::MockSource::new();
3399 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();
3410 let mut idx = crate::line_index::LineIndex::new();
3411
3412 let origin = (2usize, 0usize);
3413 let mut vp = Viewport::new(20, 4, "test".into()); vp.set_top(origin.0, origin.1);
3415 assert_eq!(vp.top_line(), 2);
3416
3417 vp.incsearch_preview(&src, &mut idx, "target", SearchDirection::Forward, origin);
3419 assert_eq!(vp.top_line(), 8, "should land on the far-below match");
3420 assert_eq!(vp.top_row(), 0);
3421
3422 vp.incsearch_preview(&src, &mut idx, "mark", SearchDirection::Forward, origin);
3428 assert_eq!(
3429 vp.top_line(), 4,
3430 "preview must reset to origin before scanning, landing on the match \
3431 after origin rather than continuing forward from the previous match"
3432 );
3433 assert_eq!(vp.top_row(), 0);
3434 }
3435
3436 #[test]
3437 fn incsearch_preview_empty_or_invalid_is_noop() {
3438 let (src, mut idx) = setup(b"alpha\nbeta\n[unbalanced\n");
3439 let mut vp = Viewport::new(20, 4, "test".into());
3440 vp.set_top(1, 0);
3441 vp.incsearch_preview(&src, &mut idx, "", SearchDirection::Forward, (0, 0));
3443 assert_eq!(vp.top_line(), 1);
3444 vp.incsearch_preview(&src, &mut idx, "(", SearchDirection::Forward, (0, 0));
3446 assert_eq!(vp.top_line(), 0);
3447 }
3448
3449 #[test]
3450 fn filter_and_grep_combine_with_and() {
3451 use crate::grep::GrepPredicate;
3452 let fmt = crate::format::LogFormat::compile(
3453 "simple",
3454 r"^(?P<level>\w+) (?P<msg>.+)$",
3455 ).unwrap();
3456 let f = crate::filter::CompiledFilter::compile(
3457 &fmt,
3458 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
3459 CaseMode::Sensitive,
3460 ).unwrap();
3461 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3462
3463 let src = crate::source::MockSource::new();
3464 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();
3469 let mut idx = crate::line_index::LineIndex::new();
3470 idx.extend_to_end(&src);
3471
3472 let mut v = Viewport::new(80, 5, "test".into());
3473 v.set_filter(Some(f));
3474 v.set_grep(Some(g));
3475 v.extend_visible_lines(&idx, &src);
3476 assert_eq!(v.visible_lines(), &[0usize]);
3477 }
3478
3479 #[test]
3480 fn search_status_shows_pattern() {
3481 let (m, mut idx) = setup(b"x\n");
3482 let mut v = Viewport::new(20, 5, "f".into());
3483 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3484 let frame = v.frame(&m, &mut idx);
3485 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3486 }
3487
3488 #[test]
3489 fn repeat_search_after_first_match_advances() {
3490 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3491 let mut v = Viewport::new(40, 5, "f".into());
3492 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3493 assert!(v.search_repeat(&m, &mut idx, false));
3494 assert_eq!(v.top_line, 1, "first foo");
3495 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3496 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3497 assert_eq!(v.top_line, 3, "should advance to next foo");
3498 }
3499
3500 #[test]
3501 fn auto_scroll_paused_when_follow_off() {
3502 let m = MockSource::new();
3503 m.append(b"1\n2\n3\n4\n");
3504 let mut idx = LineIndex::new();
3505 let mut v = Viewport::new(10, 5, "f".into());
3506 idx.extend_to_end(&m);
3508 let frame_before = v.frame(&m, &mut idx);
3509 let top_first_cell = frame_before.body[0][0].clone();
3510 m.append(b"5\n6\n7\n8\n");
3511 simulate_growth_tick(&mut v, &m, &mut idx);
3512 let frame_after = v.frame(&m, &mut idx);
3513 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3514 }
3515
3516 #[test]
3519 fn search_jumps_to_next_matching_record() {
3520 let m = MockSource::new();
3521 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
3522 let mut idx = LineIndex::new();
3523 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3524 idx.extend_to_end(&m);
3525 let mut v = Viewport::new(40, 10, "f".into());
3526 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3527 let hit = v.search_repeat(&m, &mut idx, false);
3528 assert!(hit, "should find 'charlie' in record 2");
3529 assert_eq!(v.top_line(), 3); }
3531
3532 #[test]
3533 fn search_finds_cross_line_match_in_record_with_s_flag() {
3534 let m = MockSource::new();
3535 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
3536 let mut idx = LineIndex::new();
3537 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3538 idx.extend_to_end(&m);
3539 let mut v = Viewport::new(40, 10, "f".into());
3540 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3541 let hit = v.search_repeat(&m, &mut idx, false);
3542 assert!(hit, "should match across \\n inside record 0 with (?s)");
3543 assert_eq!(v.top_line(), 0);
3544 }
3545
3546 #[test]
3547 fn search_repeat_with_no_match_returns_false() {
3548 let m = MockSource::new();
3549 m.append(b"[1] alpha\n[2] bravo\n");
3550 let mut idx = LineIndex::new();
3551 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3552 idx.extend_to_end(&m);
3553 let mut v = Viewport::new(40, 10, "f".into());
3554 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3555 let hit = v.search_repeat(&m, &mut idx, false);
3556 assert!(!hit);
3557 }
3558
3559 #[test]
3562 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3563 let m = MockSource::new();
3566 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
3567 let mut idx = LineIndex::new();
3568 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3569 idx.extend_to_end(&m);
3570 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3571 let mut v = Viewport::new(40, 10, "f".into());
3572 v.set_grep(Some(grep));
3573 v.extend_visible_lines(&idx, &m);
3574 assert_eq!(v.visible_lines(), &[0usize, 1]);
3577 }
3578
3579 #[test]
3580 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3581 let m = MockSource::new();
3587 m.append(
3588 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
3589 );
3590 let mut idx = LineIndex::new();
3591 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3592 idx.extend_to_end(&m);
3593 let fmt = crate::format::LogFormat::compile(
3594 "rec",
3595 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3596 )
3597 .unwrap();
3598 let f = crate::filter::CompiledFilter::compile(
3599 &fmt,
3600 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3601 CaseMode::Sensitive,
3602 )
3603 .unwrap();
3604 let mut v = Viewport::new(40, 10, "f".into());
3605 v.set_filter(Some(f));
3606 v.extend_visible_lines(&idx, &m);
3607 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3609 }
3610
3611 #[test]
3612 fn grep_matches_across_record_newlines_in_records_mode() {
3613 let m = MockSource::new();
3615 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
3616 let mut idx = LineIndex::new();
3617 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3618 idx.extend_to_end(&m);
3619 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3620 let mut v = Viewport::new(40, 10, "f".into());
3621 v.set_grep(Some(grep));
3622 v.extend_visible_lines(&idx, &m);
3623 assert_eq!(v.visible_lines(), &[0usize, 1]);
3625 }
3626
3627 #[test]
3628 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3629 let m = MockSource::new();
3632 m.append(b"[1] head\n cont\n[2] other\n cont\n");
3633 let mut idx = LineIndex::new();
3634 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3635 idx.extend_to_end(&m);
3636 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3637 let mut v = Viewport::new(40, 10, "f".into());
3638 v.set_grep(Some(grep));
3639 v.set_dim_mode(true);
3640 v.extend_visible_lines(&idx, &m);
3641 assert_eq!(v.visible_lines(), &[] as &[usize]);
3643 assert!(!v.should_dim_line(0, &idx, &m));
3645 assert!(!v.should_dim_line(1, &idx, &m));
3646 assert!(v.should_dim_line(2, &idx, &m));
3648 assert!(v.should_dim_line(3, &idx, &m));
3649 }
3650
3651 #[test]
3652 fn status_unchanged_when_records_inactive() {
3653 let (m, mut idx) = setup(b"a\nb\nc\n");
3654 let mut v = Viewport::new(20, 5, "f".into());
3655 let frame = v.frame(&m, &mut idx);
3656 let status = &frame.status;
3657 assert!(status.contains("1-3/3"), "got: {status}");
3659 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3660 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3661 }
3662
3663 #[test]
3664 fn status_r_block_uses_real_lines_in_hide_mode() {
3665 let m = MockSource::new();
3674 let mut buf = Vec::new();
3677 for n in 0..10 {
3678 let kind = if n >= 8 { "B" } else { "A" };
3679 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3680 }
3681 m.append(&buf);
3682 m.finish();
3683
3684 let mut idx = LineIndex::new();
3685 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3686 idx.extend_to_end(&m);
3687
3688 let fmt = crate::format::LogFormat::compile(
3689 "rec",
3690 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3691 )
3692 .unwrap();
3693 let f = crate::filter::CompiledFilter::compile(
3694 &fmt,
3695 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3696 CaseMode::Sensitive,
3697 )
3698 .unwrap();
3699
3700 let mut v = Viewport::new(80, 5, "f".into());
3703 v.set_filter(Some(f));
3704 v.extend_visible_lines(&idx, &m);
3705
3706 v.goto_record(8, &m, &mut idx);
3708
3709 let frame = v.frame(&m, &mut idx);
3710 assert!(
3712 frame.status.contains("R9-10/10"),
3713 "expected R9-10/10 in status, got: {}",
3714 frame.status,
3715 );
3716 }
3717
3718 #[test]
3719 fn status_dual_readout_when_records_active() {
3720 let m = MockSource::new();
3721 m.append(b"[1] a\n cont\n[2] b\n");
3722 m.finish();
3723 let mut idx = LineIndex::new();
3724 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3725 idx.extend_to_end(&m);
3726 let mut v = Viewport::new(20, 5, "f".into());
3727 let frame = v.frame(&m, &mut idx);
3728 let status = &frame.status;
3729 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3730 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3731 }
3732
3733 #[test]
3734 fn format_status_uses_custom_template_when_set() {
3735 let m = MockSource::new();
3736 m.append(b"a\nb\nc\n");
3737 m.finish();
3738 let mut idx = LineIndex::new();
3739 idx.extend_to_end(&m);
3740 let mut v = Viewport::new(20, 5, "f".into());
3741 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3742 v.set_prompt(Some(prompt));
3743 let frame = v.frame(&m, &mut idx);
3744 assert_eq!(frame.status, "f 100%");
3745 }
3746
3747 #[test]
3748 fn status_shows_preprocess_failed_tag_when_set() {
3749 let m = MockSource::new();
3750 m.append(b"a\n");
3751 let mut idx = LineIndex::new();
3752 idx.extend_to_end(&m);
3753 let mut v = Viewport::new(40, 5, "f".into());
3754 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3755 let frame = v.frame(&m, &mut idx);
3756 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3757 "got: {}", frame.status);
3758 }
3759
3760 #[test]
3761 fn default_status_includes_help_hint() {
3762 let (m, mut idx) = setup(b"a\nb\nc\n");
3763 let mut v = Viewport::new(80, 5, "f".into());
3764 let frame = v.frame(&m, &mut idx);
3765 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3766 }
3767
3768 #[test]
3769 fn custom_prompt_does_not_get_help_hint() {
3770 let (m, mut idx) = setup(b"a\nb\nc\n");
3771 let mut v = Viewport::new(80, 5, "f".into());
3772 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3773 let frame = v.frame(&m, &mut idx);
3774 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3775 }
3776
3777 #[test]
3778 fn status_shows_file_index_when_multifile() {
3779 let m = MockSource::new();
3780 m.append(b"a\n");
3781 let mut idx = LineIndex::new();
3782 idx.extend_to_end(&m);
3783 let mut v = Viewport::new(60, 5, "f.log".into());
3784 v.set_file_index(0, 3);
3785 let frame = v.frame(&m, &mut idx);
3786 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3787 }
3788
3789 #[test]
3790 fn status_omits_file_index_when_single_file() {
3791 let m = MockSource::new();
3792 m.append(b"a\n");
3793 let mut idx = LineIndex::new();
3794 idx.extend_to_end(&m);
3795 let mut v = Viewport::new(60, 5, "f.log".into());
3796 v.set_file_index(0, 1);
3797 let frame = v.frame(&m, &mut idx);
3798 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3799 }
3800
3801 #[test]
3802 fn status_shows_tag_active_when_multimatch() {
3803 let m = MockSource::new();
3804 m.append(b"a\n");
3805 let mut idx = LineIndex::new();
3806 idx.extend_to_end(&m);
3807 let mut v = Viewport::new(80, 5, "f.log".into());
3808 v.set_tag_active(Some(("foo".into(), 2, 3)));
3809 let frame = v.frame(&m, &mut idx);
3810 assert!(
3811 frame.status.contains("[tag: foo (2/3)]"),
3812 "got: {}",
3813 frame.status
3814 );
3815 }
3816
3817 #[test]
3818 fn status_omits_tag_active_when_single_match() {
3819 let m = MockSource::new();
3820 m.append(b"a\n");
3821 let mut idx = LineIndex::new();
3822 idx.extend_to_end(&m);
3823 let mut v = Viewport::new(80, 5, "f.log".into());
3824 v.set_tag_active(Some(("foo".into(), 1, 1)));
3825 let frame = v.frame(&m, &mut idx);
3826 assert!(
3827 !frame.status.contains("[tag:"),
3828 "should not show indicator for single match: {}",
3829 frame.status
3830 );
3831 }
3832
3833 #[test]
3834 fn hscroll_noop_when_wrapping() {
3835 let mut v = Viewport::new(80, 24, "t".into());
3836 v.hscroll_right_step();
3838 assert_eq!(v.left_col(), 0);
3839 }
3840
3841 #[test]
3842 fn hscroll_active_in_chop_and_clamps_at_zero() {
3843 let mut v = Viewport::new(80, 24, "t".into());
3844 v.toggle_chop(); assert!(v.hscroll_active());
3846 v.hscroll_right_step();
3847 assert_eq!(v.left_col(), 8);
3848 v.hscroll_right_half();
3849 assert_eq!(v.left_col(), 8 + 40); v.hscroll_left_half();
3851 assert_eq!(v.left_col(), 8);
3852 v.hscroll_left_half();
3853 assert_eq!(v.left_col(), 0); }
3855
3856 #[test]
3857 fn hscroll_by_explicit_cols_moves_left_col() {
3858 let mut v = Viewport::new(80, 24, "t".into());
3860 v.toggle_chop(); v.hscroll_right_cols(12);
3862 assert_eq!(v.left_col(), 12);
3863 v.hscroll_right_cols(12);
3864 assert_eq!(v.left_col(), 24);
3865 v.hscroll_left_cols(12);
3866 assert_eq!(v.left_col(), 12);
3867 v.hscroll_left_cols(99);
3868 assert_eq!(v.left_col(), 0); }
3870
3871 #[test]
3872 fn hscroll_resets_to_zero_when_wrap_turned_on() {
3873 let mut v = Viewport::new(80, 24, "t".into());
3874 v.toggle_chop(); v.hscroll_right_step();
3876 assert_eq!(v.left_col(), 8);
3877 v.toggle_chop(); assert_eq!(v.left_col(), 0);
3879 }
3880
3881 #[test]
3882 fn reset_hscroll_zeroes_left_col() {
3883 let mut v = Viewport::new(80, 24, "t".into());
3885 v.toggle_chop();
3886 v.hscroll_right_step();
3887 assert_eq!(v.left_col(), 8);
3888 v.reset_hscroll();
3889 assert_eq!(v.left_col(), 0);
3890 }
3891
3892 #[test]
3895 fn reconstruct_picks_up_state_from_prior_lines() {
3896 let m = MockSource::new();
3897 m.append(b"\x1b[31mline 1\n");
3898 m.append(b"line 2 (still red, no reset)\n");
3899 m.append(b"line 3\n");
3900 let mut idx = LineIndex::new();
3901 idx.extend_to_end(&m);
3902 let state = reconstruct_render_state(&m, &idx, 2);
3903 assert_eq!(
3904 state.style.fg,
3905 Some(crate::ansi::Color::Ansi(1)),
3906 "red SGR from line 0 should persist to line 2"
3907 );
3908 }
3909
3910 #[test]
3911 fn reconstruct_respects_reset_between_lines() {
3912 let m = MockSource::new();
3913 m.append(b"\x1b[31mline 1\x1b[0m\n");
3914 m.append(b"line 2 (default)\n");
3915 let mut idx = LineIndex::new();
3916 idx.extend_to_end(&m);
3917 let state = reconstruct_render_state(&m, &idx, 1);
3918 assert_eq!(state.style.fg, None);
3919 }
3920
3921 #[test]
3922 fn reconstruct_caps_walkback_at_max_lines() {
3923 let m = MockSource::new();
3924 m.append(b"\x1b[31mvery early\n");
3925 for _ in 0..300 {
3926 m.append(b"line\n");
3927 }
3928 let mut idx = LineIndex::new();
3929 idx.extend_to_end(&m);
3930 let state = reconstruct_render_state(&m, &idx, 290);
3933 assert_eq!(state.style.fg, None);
3934 }
3935
3936 #[test]
3937 fn or_groups_narrow_within_required_line_mode() {
3938 let mut raw = crate::or::OrSpecRaw::new();
3939 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
3940 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
3941 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3942 let mut v = Viewport::new(80, 24, "t".into());
3943 v.set_or_groups(og);
3944 assert!(v.or_active());
3945 assert!(v.line_passes(b"login failed"));
3946 assert!(v.line_passes(b"access denied"));
3947 assert!(!v.line_passes(b"login ok"));
3948 }
3949
3950 #[test]
3951 fn status_shows_or_indicator_when_active() {
3952 let mut raw = crate::or::OrSpecRaw::new();
3953 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
3954 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3955 let (m, mut idx) = setup(b"x\ny\nx\n");
3956 idx.extend_to_end(&m);
3957 let mut v = Viewport::new(80, 5, "f".into());
3958 v.set_or_groups(og);
3959 v.extend_visible_lines(&idx, &m);
3960 let status = v.format_status(&idx, &m);
3961 assert!(status.contains("[or]"), "expected [or] in status: {status}");
3962 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
3963 }
3964
3965 #[test]
3966 fn status_shows_col_offset_when_scrolled() {
3967 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
3969 let (m, mut idx) = setup(content);
3970 let mut v = Viewport::new(10, 3, "t".into());
3971 v.toggle_chop(); v.hscroll_right_step(); let f = v.frame(&m, &mut idx);
3974 assert!(
3975 f.status.contains('\u{00bb}'),
3976 "expected » in status after hscroll_right_step, got: {}",
3977 f.status
3978 );
3979 }
3980
3981 #[test]
3982 fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
3983 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
3992 let (m, mut idx) = setup(content);
3993
3994 let mut v = Viewport::new(10, 3, "t".into());
3996 v.toggle_chop(); let frame0 = v.frame(&m, &mut idx);
4000 assert_eq!(
4001 frame0.body[0][0],
4002 Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4003 "at left_col=0 first cell should be 'A'"
4004 );
4005 assert!(
4007 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
4008 "no left marker expected at left_col=0"
4009 );
4010
4011 v.hscroll_right_step();
4013 assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
4014
4015 let frame1 = v.frame(&m, &mut idx);
4016 assert_eq!(
4018 frame1.body[0][0],
4019 Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
4020 "after scrolling right, first cell should be the '<' left marker"
4021 );
4022 assert_eq!(
4026 frame1.body[0][1],
4027 Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4028 "second cell should be 'J' (display column left_col+1 = 9)"
4029 );
4030 }
4031}