1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::or::OrGroups;
8use crate::line_index::LineIndex;
9use crate::render::{count_rows, render_line, Cell, RenderOpts};
10use crate::source::Source;
11
12const MAX_RECONSTRUCT_LINES: usize = 256;
16
17fn reconstruct_render_state(
24 src: &dyn Source,
25 idx: &crate::line_index::LineIndex,
26 target_line: usize,
27) -> crate::render::RenderState {
28 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
29 let mut state = crate::render::RenderState::default();
30 for line_no in start..target_line {
31 let range = idx.line_range(line_no, src);
32 let raw = src.bytes(range);
33 for &b in raw.as_ref() {
34 let _ = crate::ansi::step(
35 &mut state.parse,
36 &mut state.style,
37 &mut state.hyperlink,
38 b,
39 );
40 }
41 }
42 state
43}
44
45fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
51 let mut text = String::new();
52 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
53 for (col, cell) in row.iter().enumerate() {
54 match cell {
55 Cell::Char { ch, .. } => {
56 starts.push(col);
57 text.push(*ch);
58 }
59 Cell::Empty => {
60 starts.push(col);
61 text.push(' ');
62 }
63 Cell::Continuation => {}
64 }
65 }
66 starts.push(row.len());
67 (text, starts)
68}
69
70fn line_is_blank(bytes: &[u8]) -> bool {
75 bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
76}
77
78fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
82 if row.is_empty() {
83 return Vec::new();
84 }
85 let last_content_col = row
86 .iter()
87 .enumerate()
88 .rev()
89 .find_map(|(c, cell)| match cell {
90 Cell::Char { width, .. } => Some(c + *width as usize),
91 Cell::Continuation => Some(c + 1),
92 Cell::Empty => None,
93 })
94 .unwrap_or(0);
95 if last_content_col == 0 {
96 return Vec::new();
97 }
98 let (text, starts) = row_text_and_starts(row);
99 let mut out = Vec::new();
100 for m in regex.find_iter(&text) {
101 if m.start() == m.end() {
102 continue;
103 }
104 let char_start = text[..m.start()].chars().count();
105 let char_end = text[..m.end()].chars().count();
106 if char_start >= starts.len() - 1 || char_end <= char_start {
107 continue;
108 }
109 let col_start = starts[char_start];
110 let col_end = starts[char_end].min(last_content_col);
111 if col_end > col_start {
112 out.push(col_start..col_end);
113 }
114 }
115 out
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RowStyle {
120 Normal,
121 Dim,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SearchDirection {
128 Forward,
129 Backward,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum CaseMode {
138 #[default]
139 Sensitive,
140 Smart,
141 Insensitive,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum QuitAtEof {
150 #[default]
151 Off,
152 Second,
153 First,
154}
155
156impl CaseMode {
157 pub fn apply_to_pattern(self, pattern: &str) -> String {
160 match self {
161 CaseMode::Sensitive => pattern.to_string(),
162 CaseMode::Insensitive => format!("(?i){pattern}"),
163 CaseMode::Smart => {
164 if pattern.chars().any(|c| c.is_uppercase()) {
165 pattern.to_string()
166 } else {
167 format!("(?i){pattern}")
168 }
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone)]
175pub struct SearchState {
176 pub raw: String,
177 pub regex: Regex,
178 pub direction: SearchDirection,
179}
180
181#[derive(Debug, Clone)]
182pub struct Frame {
183 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
190 pub status: String,
191 pub status_style: crate::ansi::Style,
193 pub raw_rows: Vec<Option<Vec<u8>>>,
201}
202
203pub struct Viewport {
204 top_line: usize,
205 top_row: usize,
206 cols: u16,
207 rows: u16,
208 pub opts: RenderOpts,
209 pub show_line_numbers: bool,
210 pub source_label: String,
211 follow_mode: bool,
212 live_mode: bool,
213 prettify_label: Option<String>,
214 format_label: Option<String>,
215 filter: Option<CompiledFilter>,
216 grep: Option<GrepPredicate>,
217 or_groups: OrGroups,
218 dim_mode: bool,
219 visible_lines: Vec<usize>,
222 visible_scanned: usize,
225 search: Option<SearchState>,
226 display: Option<crate::format::DisplayRenderer>,
230 hex_mode: bool,
231 #[cfg(feature = "image")]
232 image: Option<image::RgbaImage>,
233 image_mode: bool,
234 image_no_color: bool,
235 #[cfg_attr(not(feature = "image"), allow(dead_code))]
236 image_format: String,
237 #[cfg(feature = "image")]
238 image_style: crate::image_render::AsciiStyle,
239 #[cfg_attr(not(feature = "image"), allow(dead_code))]
240 image_width: Option<usize>,
241 hex_group_size: usize,
244 prompt: Option<crate::prompt::ParsedPrompt>,
247 preprocess_failure: Option<String>,
250 file_index: Option<(usize, usize)>,
252 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
256 status_style: crate::ansi::Style,
260 status_flash: Option<(String, u32)>,
265 ticks_since_growth: u32,
270 case_mode: CaseMode,
274 hilite_search: bool,
278 quit_at_eof: QuitAtEof,
280 eof_hits: u8,
283 squeeze_blanks: bool,
287 header_lines: usize,
292 header_cols: usize,
293 page_size: Option<u16>,
297 render_state: crate::render::RenderState,
301 render_state_for: usize,
304}
305
306impl Viewport {
307 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
308 let opts = RenderOpts { cols, ..RenderOpts::default() };
309 Self {
310 top_line: 0,
311 top_row: 0,
312 cols,
313 rows,
314 opts,
315 show_line_numbers: false,
316 source_label,
317 follow_mode: false,
318 live_mode: false,
319 prettify_label: None,
320 format_label: None,
321 filter: None,
322 grep: None,
323 or_groups: OrGroups::default(),
324 dim_mode: false,
325 visible_lines: Vec::new(),
326 visible_scanned: 0,
327 search: None,
328 display: None,
329 hex_mode: false,
330 #[cfg(feature = "image")]
331 image: None,
332 image_mode: false,
333 image_no_color: false,
334 image_format: String::new(),
335 #[cfg(feature = "image")]
336 image_style: crate::image_render::AsciiStyle::Ramp,
337 image_width: None,
338 hex_group_size: 2,
339 prompt: None,
340 preprocess_failure: None,
341 file_index: None,
342 tag_active: None,
343 ansi_mode: crate::render::AnsiMode::Strict,
344 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
345 status_flash: None,
346 ticks_since_growth: 0,
347 case_mode: CaseMode::default(),
348 hilite_search: true,
349 quit_at_eof: QuitAtEof::default(),
350 eof_hits: 0,
351 squeeze_blanks: false,
352 header_lines: 0,
353 header_cols: 0,
354 page_size: None,
355 render_state: crate::render::RenderState::default(),
356 render_state_for: usize::MAX,
357 }
358 }
359
360 pub fn case_mode(&self) -> CaseMode { self.case_mode }
361
362 pub fn hilite_search(&self) -> bool { self.hilite_search }
363
364 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
365
366 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
367 self.quit_at_eof = mode;
368 self.eof_hits = 0;
369 }
370
371 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
372 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
373
374 pub fn set_header(&mut self, lines: usize, cols: usize) {
375 self.header_lines = lines;
376 self.header_cols = cols;
377 if self.top_line < self.header_lines {
380 self.top_line = self.header_lines;
381 }
382 }
383 pub fn header_lines(&self) -> usize { self.header_lines }
384 pub fn header_cols(&self) -> usize { self.header_cols }
385
386 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
387 pub fn page_size(&self) -> Option<u16> { self.page_size }
388
389 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
394 match self.quit_at_eof {
395 QuitAtEof::Off => false,
396 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
397 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
398 self.eof_hits = self.eof_hits.saturating_add(1);
399 self.eof_hits >= 2
400 }
401 _ => {
402 if !forward { self.eof_hits = 0; }
403 false
404 }
405 }
406 }
407
408 pub fn set_case_mode(&mut self, mode: CaseMode) {
412 self.case_mode = mode;
413 if let Some(s) = self.search.clone() {
414 let _ = self.set_search(s.raw, s.direction);
415 }
416 }
417
418 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
419 self.status_style = style;
420 }
421
422 pub fn status_style(&self) -> crate::ansi::Style {
423 self.status_style
424 }
425
426 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
430 self.status_flash = Some((msg.into(), ticks));
431 }
432
433 pub fn tick_flash(&mut self) {
436 if let Some((_, n)) = &mut self.status_flash {
437 *n = n.saturating_sub(1);
438 if *n == 0 {
439 self.status_flash = None;
440 }
441 }
442 }
443
444 pub fn note_growth(&mut self) {
446 self.ticks_since_growth = 0;
447 }
448
449 pub fn tick_idle(&mut self) {
452 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
453 }
454
455 pub fn is_idle(&self) -> bool {
458 self.ticks_since_growth >= 20
459 }
460
461 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
462 self.display = renderer;
463 }
464
465 pub fn set_hex_mode(&mut self, on: bool) {
466 self.hex_mode = on;
467 }
468
469 pub fn hex_mode(&self) -> bool {
471 self.hex_mode
472 }
473
474 #[cfg(feature = "image")]
475 pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
476 self.image = Some(img);
477 self.image_format = format.to_string();
478 self.image_style = style;
479 self.image_width = width;
480 self.image_mode = true;
481 self.top_line = 0;
482 self.top_row = 0;
483 }
484
485 pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
486
487 pub fn image_mode(&self) -> bool { self.image_mode }
488
489 #[cfg(feature = "image")]
490 fn image_cols(&self) -> u16 {
491 self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
492 }
493
494 #[cfg(feature = "image")]
495 pub fn image_total_rows(&self) -> usize {
496 match &self.image {
497 Some(img) => {
498 let (w, h) = img.dimensions();
499 crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
500 }
501 None => 0,
502 }
503 }
504
505 #[cfg(feature = "image")]
506 pub fn is_at_bottom_image(&self) -> bool {
507 let body = self.body_rows() as usize;
508 self.top_line + body >= self.image_total_rows()
509 }
510
511 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
514 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
515 self.hex_group_size = bytes_per_group;
516 }
517 }
518
519 pub fn hex_group_size(&self) -> usize {
521 self.hex_group_size
522 }
523
524 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
525 self.prompt = prompt;
526 }
527
528 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
529 self.preprocess_failure = msg;
530 }
531
532 pub fn set_file_index(&mut self, current: usize, total: usize) {
533 self.file_index = if total > 1 {
534 Some((current, total))
535 } else {
536 None
537 };
538 }
539
540 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
541 self.tag_active = info;
542 }
543
544 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
545 self.ansi_mode = mode;
546 }
547
548 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
549 self.ansi_mode
550 }
551
552 pub fn set_source_label(&mut self, label: String) {
553 self.source_label = label;
554 }
555
556 pub fn source_label_clone(&self) -> String {
557 self.source_label.clone()
558 }
559
560 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
565 let range = idx.line_range(line_n, src);
566 let raw = src.bytes(range);
567 if let Some(r) = self.display.as_ref() {
568 if let Some(rendered) = r.render_line(&raw) {
569 return std::borrow::Cow::Owned(rendered.into_bytes());
570 }
571 }
572 raw
573 }
574
575 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
579 let compiled = self.case_mode.apply_to_pattern(&raw);
580 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
581 self.search = Some(SearchState { raw, regex, direction });
582 Ok(())
583 }
584
585 pub fn clear_search(&mut self) { self.search = None; }
586
587 pub fn search_active(&self) -> bool { self.search.is_some() }
588
589 pub fn search_direction(&self) -> SearchDirection {
590 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
591 }
592
593 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
597 if idx.records_mode() {
598 self.search_repeat_records(src, idx, reverse)
599 } else {
600 self.search_repeat_lines(src, idx, reverse)
601 }
602 }
603
604 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
606 let Some(s) = self.search.as_ref() else { return false; };
607 let forward = matches!(
608 (s.direction, reverse),
609 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
610 );
611 idx.extend_to_end(src);
612 let pattern = s.regex.clone();
613 if self.hide_mode() {
614 self.extend_visible_lines(idx, src);
615 self.search_step_in_visible(&pattern, src, idx, forward)
616 } else {
617 self.search_step_in_logical(&pattern, src, idx, forward)
618 }
619 }
620
621 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
625 let Some(s) = self.search.as_ref() else { return false; };
626 let forward = matches!(
627 (s.direction, reverse),
628 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
629 );
630 let pattern = s.regex.clone();
631 idx.extend_to_end(src);
632
633 let total = idx.record_count();
634 if total == 0 { return false; }
635
636 let cur_record = idx.line_to_record(self.top_line);
637
638 let range: Box<dyn Iterator<Item = usize>> = if forward {
639 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
640 } else {
641 let earlier: Vec<usize> = (0..cur_record).rev().collect();
642 let later: Vec<usize> = (cur_record..total).rev().collect();
643 Box::new(earlier.into_iter().chain(later))
644 };
645
646 for r in range {
647 let bytes = idx.record_bytes_stripped(r, src);
648 let text = String::from_utf8_lossy(&bytes);
649 if pattern.is_match(&text) {
650 let line_range = idx.record_line_range(r);
651 self.top_line = line_range.start;
652 self.top_row = 0;
653 return true;
654 }
655 }
656 false
657 }
658
659 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
660 let display = self.line_display_bytes(src, idx, line_n);
665 let bytes = crate::ansi::strip_sgr(&display);
666 match std::str::from_utf8(&bytes) {
667 Ok(s) => pattern.is_match(s),
668 Err(_) => false,
669 }
670 }
671
672 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
673 let total = idx.line_count();
674 if total == 0 { return false; }
675 let start = self.top_line;
676 for offset in 1..=total {
679 let line_n = if forward {
680 (start + offset) % total
681 } else {
682 (start + total - offset) % total
683 };
684 if self.line_matches(pattern, src, idx, line_n) {
685 self.top_line = line_n;
686 self.top_row = 0;
687 return true;
688 }
689 }
690 false
691 }
692
693 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
694 let total = self.visible_lines.len();
695 if total == 0 { return false; }
696 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
698 for offset in 1..=total {
699 let visible_idx = if forward {
700 (cur + offset) % total
701 } else {
702 (cur + total - offset) % total
703 };
704 let line_n = self.visible_lines[visible_idx];
705 if self.line_matches(pattern, src, idx, line_n) {
706 self.top_line = line_n;
707 self.top_row = 0;
708 return true;
709 }
710 }
711 false
712 }
713
714 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
715 self.filter = filter;
716 self.visible_lines.clear();
717 self.visible_scanned = 0;
718 self.top_line = 0;
720 self.top_row = 0;
721 }
722
723 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
724 self.grep = grep;
725 self.visible_lines.clear();
726 self.visible_scanned = 0;
727 self.top_line = 0;
728 self.top_row = 0;
729 }
730
731 pub fn set_or_groups(&mut self, or_groups: OrGroups) {
732 self.or_groups = or_groups;
733 self.visible_lines.clear();
734 self.visible_scanned = 0;
735 self.top_line = 0;
736 self.top_row = 0;
737 }
738
739 pub fn or_active(&self) -> bool {
740 self.or_groups.is_active()
741 }
742
743 pub fn grep_active(&self) -> bool { self.grep.is_some() }
744
745 pub fn set_dim_mode(&mut self, on: bool) {
746 self.dim_mode = on;
747 self.visible_lines.clear();
751 self.visible_scanned = 0;
752 }
753
754 pub fn filter_active(&self) -> bool { self.filter.is_some() }
755
756 pub fn dim_mode(&self) -> bool { self.dim_mode }
757
758 fn hide_mode(&self) -> bool {
759 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
760 && !self.dim_mode
761 }
762
763 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
768 if !self.hide_mode() {
769 return;
770 }
771 if idx.records_mode() {
772 self.extend_visible_lines_records(idx, src);
773 } else {
774 self.extend_visible_lines_per_line(idx, src);
775 }
776 }
777
778 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
780 let total = idx.line_count();
781 while self.visible_scanned < total {
782 let line_n = self.visible_scanned;
783 let bytes = idx.line_bytes_stripped(line_n, src);
784 if self.line_passes(&bytes) {
785 self.visible_lines.push(line_n);
786 }
787 self.visible_scanned += 1;
788 }
789 }
790
791 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
798 self.visible_lines.clear();
799 self.visible_scanned = 0; let total_records = idx.record_count();
801 for r in 0..total_records {
802 if self.record_passes(idx, src, r) {
803 for line_n in idx.record_line_range(r) {
804 self.visible_lines.push(line_n);
805 }
806 }
807 }
808 }
809
810 fn line_passes(&self, line: &[u8]) -> bool {
816 let filter_ok = match self.filter.as_ref() {
817 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
818 None => true,
819 };
820 let grep_ok = match self.grep.as_ref() {
821 Some(g) => g.matches(line),
822 None => true,
823 };
824 filter_ok && grep_ok && self.or_groups.matches_line(line)
825 }
826
827 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
835 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
836 let bytes = if need {
837 Some(idx.record_bytes_stripped(r, src))
838 } else {
839 None
840 };
841 let filter_ok = match self.filter.as_ref() {
842 Some(f) => matches!(
843 f.evaluate_record(bytes.as_deref().unwrap()),
844 FilterMatch::Matched,
845 ),
846 None => true,
847 };
848 let grep_ok = match self.grep.as_ref() {
849 Some(g) => g.matches(bytes.as_deref().unwrap()),
850 None => true,
851 };
852 let or_ok = if self.or_groups.is_active() {
853 self.or_groups.matches_record(bytes.as_deref().unwrap())
854 } else {
855 true
856 };
857 filter_ok && grep_ok && or_ok
858 }
859
860 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
864 if !self.dim_mode {
865 return false;
866 }
867 if idx.records_mode() {
868 let r = idx.line_to_record(line_n);
869 !self.record_passes(idx, src, r)
870 } else {
871 let bytes = idx.line_bytes_stripped(line_n, src);
872 !self.line_passes(&bytes)
873 }
874 }
875
876 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
884 let body_rows = self.body_rows() as usize;
885 if self.hide_mode() && !self.visible_lines.is_empty() {
886 let cur = self
887 .visible_lines
888 .iter()
889 .position(|&l| l >= self.top_line)
890 .unwrap_or(self.visible_lines.len().saturating_sub(1));
891 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
892 return self.visible_lines[last_pos];
893 }
894 let total = idx.line_count();
895 if total == 0 {
896 return self.top_line;
897 }
898 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
899 }
900
901 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
902
903 pub fn follow_mode(&self) -> bool { self.follow_mode }
904
905 pub fn suspend_follow_if(&mut self, flag: bool) {
910 if flag {
911 self.follow_mode = false;
912 }
913 }
914
915 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
916
917 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
918
919 pub fn live_mode(&self) -> bool { self.live_mode }
920
921 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
922
923 pub fn set_prettify_label(&mut self, label: Option<String>) {
926 self.prettify_label = label;
927 }
928
929 pub fn set_format_label(&mut self, label: Option<String>) {
932 self.format_label = label;
933 }
934
935 pub fn invalidate_filter_cache(&mut self) {
940 self.visible_lines.clear();
941 self.visible_scanned = 0;
942 }
943
944 pub fn clamp_top_line(&mut self, line_count: usize) {
947 if line_count == 0 {
948 self.top_line = 0;
949 self.top_row = 0;
950 } else if self.top_line >= line_count {
951 self.top_line = line_count - 1;
952 self.top_row = 0;
953 }
954 }
955
956 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
960 #[cfg(feature = "image")]
961 if self.image_mode {
962 return self.is_at_bottom_image();
963 }
964 if self.hide_mode() {
965 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
969 } else {
970 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
974 }
975 }
976
977 fn gutter_width(&self, idx: &LineIndex) -> u16 {
979 if !self.show_line_numbers { return 0; }
980 let n = idx.line_count().max(1);
981 let digits = (n as f64).log10().floor() as u16 + 1;
982 digits + 1
983 }
984
985 fn render_opts(&self, gutter: u16) -> RenderOpts {
986 let mut o = self.opts.clone();
987 o.cols = self.cols.saturating_sub(gutter);
988 o.mode = self.ansi_mode;
989 o
990 }
991
992 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
993 #[cfg(feature = "image")]
994 if self.image_mode {
995 return self.frame_image();
996 }
997 if self.hex_mode {
998 return self.frame_hex(src);
999 }
1000 let body_rows = self.body_rows() as usize;
1001 idx.extend_to_line(self.top_line + body_rows + 1, src);
1002
1003 let gutter = self.gutter_width(idx);
1004 let r_opts = self.render_opts(gutter);
1005
1006 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1010 reconstruct_render_state(src, idx, self.top_line)
1011 } else {
1012 crate::render::RenderState::default()
1013 };
1014 self.render_state = render_state.clone();
1016 self.render_state_for = self.top_line;
1017
1018 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1019 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1020 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1021 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1022 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1023 let hide = self.hide_mode();
1025 let total_lines = idx.line_count();
1026
1027 let header_rows = if !hide && !raw_passthrough {
1034 self.header_lines.min(body_rows).min(total_lines)
1035 } else {
1036 0
1037 };
1038 if header_rows > 0 {
1039 for hl in 0..header_rows {
1040 let raw = src.bytes(idx.line_range(hl, src));
1041 let display_bytes = if let Some(r) = self.display.as_ref() {
1042 match r.render_line(&raw) {
1043 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1044 None => raw.clone(),
1045 }
1046 } else {
1047 raw.clone()
1048 };
1049 let rows = render_line(&display_bytes, &r_opts, None);
1050 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1051 let mut v = Vec::with_capacity(self.cols as usize);
1052 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1053 v
1054 });
1055 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1056 if gutter > 0 {
1057 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1058 for c in label.chars() {
1059 full.push(Cell::Char {
1060 ch: c,
1061 width: 1,
1062 style: crate::ansi::Style::default(),
1063 hyperlink: None,
1064 });
1065 }
1066 }
1067 full.append(&mut content_row);
1068 body.push(full);
1069 row_styles.push(RowStyle::Normal);
1070 highlights.push(Vec::new());
1071 raw_rows.push(None);
1072 }
1073 }
1074
1075 let mut hide_pos = if hide {
1077 self.visible_lines
1078 .iter()
1079 .position(|&l| l >= self.top_line)
1080 .unwrap_or(self.visible_lines.len())
1081 } else {
1082 0
1083 };
1084 let mut line_n = if hide {
1085 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1086 } else {
1087 self.top_line.max(self.header_lines)
1090 };
1091 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1092
1093 while body.len() < body_rows {
1094 if line_n >= total_lines {
1095 let mut row = Vec::with_capacity(self.cols as usize);
1096 if gutter > 0 {
1097 for _ in 0..gutter { row.push(Cell::Empty); }
1098 }
1099 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1100 body.push(row);
1101 row_styles.push(RowStyle::Normal);
1102 highlights.push(Vec::new());
1103 raw_rows.push(None);
1104 line_n += 1;
1105 continue;
1106 }
1107 let raw = src.bytes(idx.line_range(line_n, src));
1110 if self.squeeze_blanks && line_is_blank(&raw) {
1115 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1116 let prev = src.bytes(idx.line_range(p, src));
1117 line_is_blank(&prev)
1118 });
1119 if prev_blank {
1120 line_n += 1;
1121 continue;
1122 }
1123 }
1124 let display_bytes = if let Some(r) = self.display.as_ref() {
1125 match r.render_line(&raw) {
1126 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1127 None => raw.clone(),
1128 }
1129 } else {
1130 raw.clone()
1131 };
1132 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1133 Some(&mut render_state)
1134 } else {
1135 None
1136 };
1137 let rows = render_line(&display_bytes, &r_opts, state_arg);
1138 let style = if self.filter.is_some() || self.grep.is_some() {
1139 if self.dim_mode {
1140 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1141 } else {
1142 RowStyle::Normal
1144 }
1145 } else {
1146 RowStyle::Normal
1147 };
1148
1149 let mut first_emitted_for_this_line = true;
1150 for (i, mut content_row) in rows.into_iter().enumerate() {
1151 if i < skip { continue; }
1152 if body.len() >= body_rows { break; }
1153 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1154 if gutter > 0 {
1155 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1156 for c in label.chars() {
1157 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1158 }
1159 }
1160 full.append(&mut content_row);
1161 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1165 find_row_highlights(&full, &s.regex)
1166 } else {
1167 Vec::new()
1168 };
1169 body.push(full);
1170 row_styles.push(style);
1171 highlights.push(row_highlights);
1172 if raw_passthrough {
1173 if first_emitted_for_this_line {
1174 raw_rows.push(Some(raw.to_vec()));
1179 first_emitted_for_this_line = false;
1180 } else {
1181 raw_rows.push(Some(Vec::new()));
1182 }
1183 } else {
1184 raw_rows.push(None);
1185 }
1186 }
1187 skip = 0;
1188 if hide {
1190 hide_pos += 1;
1191 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1192 } else {
1193 line_n += 1;
1194 }
1195 }
1196
1197 self.render_state_for = usize::MAX;
1200
1201 let status = self.format_status(idx, src);
1202 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1203 }
1204
1205 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1206 if let Some(p) = self.prompt.as_ref() {
1207 let ctx = self.build_prompt_context(idx, src);
1208 return p.render(&ctx);
1209 }
1210 let body_rows = self.body_rows() as usize;
1211 let total = idx.line_count();
1212 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1215 let visible_total = self.visible_lines.len();
1216 let cur = self
1218 .visible_lines
1219 .iter()
1220 .position(|&l| l >= self.top_line)
1221 .unwrap_or(visible_total);
1222 let top = cur + 1;
1223 let bottom = (cur + body_rows).min(visible_total.max(1));
1224 let total_str = if src.is_complete() {
1225 format!("{visible_total}/{total}")
1226 } else {
1227 format!("{visible_total}/{total}+")
1228 };
1229 (top, bottom, visible_total, total_str)
1230 } else {
1231 let top = self.top_line + 1;
1232 let bottom = (self.top_line + body_rows).min(total.max(1));
1233 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1234 (top, bottom, total, total_str)
1235 };
1236 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1237 let bottom_line = self.bottom_visible_line(idx);
1241 let (line_prefix, records_block) = if idx.records_mode() {
1242 let line_total = idx.line_count();
1243 let rec_total = idx.record_count();
1244 let rec_block = if line_total == 0 || rec_total == 0 {
1245 format!("R0-0/{}", rec_total)
1246 } else {
1247 let rec_top = idx.line_to_record(self.top_line) + 1;
1248 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1249 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1250 (rec_top, rec_top)
1254 } else {
1255 (rec_top, rec_bottom)
1256 };
1257 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1258 };
1259 ("L", Some(rec_block))
1260 } else {
1261 ("", None)
1262 };
1263 let middle = match records_block {
1264 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1265 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1266 };
1267 let label_with_index = match self.file_index {
1268 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1269 None => self.source_label.clone(),
1270 };
1271 let mut s = format!("{} {}", label_with_index, middle);
1272 if !self.hide_mode() && self.top_row > 0 {
1277 let line_rows = if total > 0 {
1278 let bytes = self.line_display_bytes(src, idx, self.top_line);
1279 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1280 } else { 1 };
1281 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1282 }
1283 if let Some(f) = self.filter.as_ref() {
1284 s.push_str(&format!(" [{}]", f.format_name));
1285 }
1286 if self.grep.is_some() {
1287 s.push_str(" [grep]");
1288 }
1289 if self.or_groups.is_active() {
1290 s.push_str(" [or]");
1291 }
1292 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1293 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1294 }
1295 if let Some(sr) = self.search.as_ref() {
1296 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1297 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1298 }
1299 if let Some(label) = self.prettify_label.as_ref() {
1300 s.push_str(&format!(" [pretty:{label}]"));
1301 }
1302 if self.live_mode { s.push_str(" (L)"); }
1303 if self.follow_mode {
1304 if let Some((msg, _)) = self.status_flash.as_ref() {
1305 s.push_str(" ");
1306 s.push_str(msg);
1307 } else if self.is_idle() {
1308 s.push_str(" (F idle)");
1309 } else {
1310 s.push_str(" (F)");
1311 }
1312 }
1313 if let Some(msg) = self.preprocess_failure.as_ref() {
1314 let first_line = msg.lines().next().unwrap_or("");
1315 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1316 }
1317 let tag_suffix = match &self.tag_active {
1318 Some((name, cur, total)) if *total > 1 => {
1319 format!(" [tag: {name} ({cur}/{total})]")
1320 }
1321 _ => String::new(),
1322 };
1323 s.push_str(&tag_suffix);
1324 let used = s.chars().count();
1327 let hint = ":help";
1328 if (self.cols as usize) > used + 1 + hint.chars().count() {
1329 let pad = self.cols as usize - used - hint.chars().count();
1330 s.push_str(&" ".repeat(pad));
1331 s.push_str(hint);
1332 } else {
1333 s.push(' ');
1334 s.push_str(hint);
1335 }
1336 s
1337 }
1338
1339 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1340 use crate::prompt::PromptContext;
1341
1342 let body_rows = self.body_rows() as usize;
1343 let total = idx.line_count();
1344 let top = self.top_line + 1;
1345 let bottom = (self.top_line + body_rows).min(total.max(1));
1346 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1347 let bottom_line = self.bottom_visible_line(idx);
1348
1349 let records_mode = idx.records_mode();
1350 let (rec_top, rec_bottom, rec_total) = if records_mode {
1351 let rt = idx.line_to_record(self.top_line) + 1;
1352 let rb_raw = idx.line_to_record(bottom_line) + 1;
1353 let rb = if rb_raw < rt { rt } else { rb_raw };
1354 (rt, rb, idx.record_count())
1355 } else {
1356 (0, 0, 0)
1357 };
1358
1359 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1360 let line_rows = if total > 0 {
1361 let bytes = self.line_display_bytes(src, idx, self.top_line);
1362 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1363 } else { 1 };
1364 format!("+{}/{}", self.top_row, line_rows)
1365 } else {
1366 String::new()
1367 };
1368
1369 let format_tag = self.format_label.as_ref()
1370 .map(|n| format!(" [{}]", n))
1371 .unwrap_or_default();
1372 let filter_tag = self.filter.as_ref()
1373 .map(|f| format!(" [{}]", f.format_name))
1374 .unwrap_or_default();
1375 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1376 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1377 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1378 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1379 } else {
1380 String::new()
1381 };
1382 let search_tag = self.search.as_ref()
1383 .map(|s| {
1384 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1385 format!(" [{}{}]", p, s.raw)
1386 })
1387 .unwrap_or_default();
1388 let pretty_tag = self.prettify_label.as_ref()
1389 .map(|l| format!(" [pretty:{l}]"))
1390 .unwrap_or_default();
1391 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1392 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1393 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1394 .map(|msg| {
1395 let first_line = msg.lines().next().unwrap_or("");
1396 format!(" [preprocess-failed: {}]", first_line)
1397 })
1398 .unwrap_or_default();
1399
1400 let file_index_tag = match self.file_index {
1401 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1402 None => String::new(),
1403 };
1404
1405 let tag_tag = match &self.tag_active {
1406 Some((name, cur, total)) if *total > 1 => {
1407 format!(" [tag: {name} ({cur}/{total})]")
1408 }
1409 _ => String::new(),
1410 };
1411
1412 PromptContext {
1413 label: self.source_label.clone(),
1414 top,
1415 bottom,
1416 total,
1417 pct: pct.min(100) as u8,
1418 rec_top,
1419 rec_bottom,
1420 rec_total,
1421 records_mode,
1422 wrap_offset,
1423 format_tag,
1424 filter_tag,
1425 grep_tag,
1426 or_tag,
1427 hide_tag,
1428 search_tag,
1429 pretty_tag,
1430 live_tag,
1431 follow_tag,
1432 preprocess_failed_tag,
1433 file_index_tag,
1434 tag_tag,
1435 }
1436 }
1437
1438 fn frame_hex(&self, src: &dyn Source) -> Frame {
1439 use crate::hex::format_hex_row;
1440 use crate::render::{render_line, Cell, RenderOpts};
1441
1442 let body_rows = self.rows.saturating_sub(1) as usize;
1443 let total_bytes = src.len();
1444 let total_hex_rows = total_bytes.div_ceil(16);
1445
1446 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1447 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1448 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1449
1450 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
1451
1452 for row_idx in 0..body_rows {
1453 let hex_row = self.top_line + row_idx;
1454 if hex_row >= total_hex_rows {
1455 body.push(vec![Cell::Empty; self.cols as usize]);
1456 } else {
1457 let offset = hex_row * 16;
1458 let end = (offset + 16).min(total_bytes);
1459 let bytes_cow = src.bytes(offset..end);
1460 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1461 let rows = render_line(text.as_bytes(), &opts, None);
1462 body.push(rows.into_iter().next().unwrap_or_else(|| {
1463 vec![Cell::Empty; self.cols as usize]
1464 }));
1465 }
1466 row_styles.push(RowStyle::Normal);
1467 highlights.push(Vec::new());
1468 }
1469
1470 let status = self.format_status_hex(src);
1471 let raw_rows = vec![None; body.len()];
1472 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1473 }
1474
1475 fn format_status_hex(&self, src: &dyn Source) -> String {
1476 let total_bytes = src.len();
1477 let body_rows = self.rows.saturating_sub(1) as usize;
1478 let top_byte = self.top_line * 16;
1480 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1483 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1484 let label_with_index = match self.file_index {
1485 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1486 None => self.source_label.clone(),
1487 };
1488 let tag_suffix = match &self.tag_active {
1489 Some((name, cur, total)) if *total > 1 => {
1490 format!(" [tag: {name} ({cur}/{total})]")
1491 }
1492 _ => String::new(),
1493 };
1494 format!(
1495 "{} off {}-{}/{} {}% [hex]{}",
1496 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1497 )
1498 }
1499
1500 #[cfg(feature = "image")]
1501 fn frame_image(&self) -> Frame {
1502 use crate::render::Cell;
1503 let body_rows = self.body_rows() as usize;
1504 let cols = self.cols as usize;
1505 let img = match &self.image {
1506 Some(i) => i,
1507 None => {
1508 let body = vec![vec![Cell::Empty; cols]; body_rows];
1509 return Frame {
1510 body,
1511 row_styles: vec![RowStyle::Normal; body_rows],
1512 highlights: vec![Vec::new(); body_rows],
1513 status: self.image_format.clone(),
1514 status_style: self.status_style,
1515 raw_rows: vec![None; body_rows],
1516 };
1517 }
1518 };
1519 let color = !self.image_no_color;
1520 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1521 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1522 for r in 0..body_rows {
1523 let gi = self.top_line + r;
1524 if gi < grid.len() {
1525 let mut row = grid[gi].clone();
1526 row.truncate(cols);
1527 while row.len() < cols { row.push(Cell::Empty); }
1528 body.push(row);
1529 } else {
1530 body.push(vec![Cell::Empty; cols]);
1531 }
1532 }
1533 let status = self.format_status_image(grid.len());
1534 Frame {
1535 body,
1536 row_styles: vec![RowStyle::Normal; body_rows],
1537 highlights: vec![Vec::new(); body_rows],
1538 status,
1539 status_style: self.status_style,
1540 raw_rows: vec![None; body_rows],
1541 }
1542 }
1543
1544 #[cfg(feature = "image")]
1545 fn format_status_image(&self, total_rows: usize) -> String {
1546 let body = self.body_rows() as usize;
1547 let top = self.top_line + 1;
1548 let bottom = (self.top_line + body).min(total_rows.max(1));
1549 let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1550 format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows)
1551 }
1552
1553 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1558 if delta == 0 { return; }
1559 #[cfg(feature = "image")]
1560 if self.image_mode {
1561 self.scroll_lines(delta, src, idx);
1562 return;
1563 }
1564 if self.hide_mode() {
1565 self.extend_visible_lines(idx, src);
1569 let n = self.visible_lines.len();
1570 if n == 0 {
1571 self.top_line = 0;
1572 self.top_row = 0;
1573 return;
1574 }
1575 let vi = self
1576 .visible_lines
1577 .iter()
1578 .position(|&l| l >= self.top_line)
1579 .unwrap_or(n - 1);
1580 if delta > 0 {
1581 let target = (vi + delta as usize).min(n - 1);
1582 self.top_line = self.visible_lines[target];
1583 self.top_row = 0;
1584 } else {
1585 let back = (-delta) as usize;
1586 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1587 let extra_back = back.saturating_sub(consumed_for_snap);
1588 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1589 self.top_row = 0;
1590 }
1591 return;
1592 }
1593 if delta > 0 {
1594 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1595 let total = idx.line_count();
1596 if total == 0 { return; }
1597 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1598 self.top_line = target;
1599 self.top_row = 0;
1600 } else {
1601 let back = (-delta) as usize;
1602 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1607 let extra_back = back.saturating_sub(consumed_for_snap);
1608 self.top_line = self.top_line.saturating_sub(extra_back);
1609 self.top_row = 0;
1610 }
1611 }
1612
1613 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1614 if delta == 0 { return; }
1615 #[cfg(feature = "image")]
1616 if self.image_mode {
1617 let total = self.image_total_rows();
1618 let body = self.body_rows() as usize;
1619 let max_top = total.saturating_sub(body);
1620 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
1621 self.top_line = next as usize;
1622 self.top_row = 0;
1623 return;
1624 }
1625 if self.hide_mode() {
1626 self.extend_visible_lines(idx, src);
1630 let n = self.visible_lines.len();
1631 if n == 0 {
1632 self.top_line = 0;
1633 self.top_row = 0;
1634 return;
1635 }
1636 let mut vi = self
1637 .visible_lines
1638 .iter()
1639 .position(|&l| l >= self.top_line)
1640 .unwrap_or(n - 1);
1641 if self.visible_lines[vi] != self.top_line {
1644 self.top_row = 0;
1645 }
1646 self.top_line = self.visible_lines[vi];
1647 let r_opts = self.render_opts(self.gutter_width(idx));
1648 if delta > 0 {
1649 let mut remaining = delta as usize;
1650 while remaining > 0 {
1651 let line = self.visible_lines[vi];
1652 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1653 if self.top_row + 1 < rows {
1654 self.top_row += 1;
1655 } else if vi + 1 < n {
1656 self.top_row = 0;
1657 vi += 1;
1658 self.top_line = self.visible_lines[vi];
1659 } else {
1660 break;
1661 }
1662 remaining -= 1;
1663 }
1664 let anchor = self.hide_bottom_anchor(src, idx);
1665 if (self.top_line, self.top_row) > anchor {
1666 self.top_line = anchor.0;
1667 self.top_row = anchor.1;
1668 }
1669 } else {
1670 let mut remaining = (-delta) as usize;
1671 while remaining > 0 {
1672 if self.top_row > 0 {
1673 self.top_row -= 1;
1674 } else if vi > 0 {
1675 vi -= 1;
1676 self.top_line = self.visible_lines[vi];
1677 let line = self.visible_lines[vi];
1678 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1679 self.top_row = rows.saturating_sub(1);
1680 } else {
1681 break;
1682 }
1683 remaining -= 1;
1684 }
1685 }
1686 return;
1687 }
1688 if delta > 0 {
1689 let mut remaining = delta as usize;
1690 while remaining > 0 {
1691 idx.extend_to_line(self.top_line + 1, src);
1692 let total = idx.line_count();
1693 if total == 0 { break; }
1694 let bytes = self.line_display_bytes(src, idx, self.top_line);
1695 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1696 if self.top_row + 1 < line_rows {
1697 self.top_row += 1;
1698 } else if self.top_line + 1 < total {
1699 self.top_row = 0;
1700 self.top_line += 1;
1701 } else {
1702 break;
1703 }
1704 remaining -= 1;
1705 }
1706 if idx.scanned_through() >= src.len() {
1711 let anchor = self.bottom_anchor(src, idx);
1712 if (self.top_line, self.top_row) > anchor {
1713 self.top_line = anchor.0;
1714 self.top_row = anchor.1;
1715 }
1716 }
1717 } else {
1718 let mut remaining = (-delta) as usize;
1719 while remaining > 0 {
1720 if self.top_row > 0 {
1721 self.top_row -= 1;
1722 } else if self.top_line > 0 {
1723 self.top_line -= 1;
1724 let bytes = self.line_display_bytes(src, idx, self.top_line);
1725 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1726 self.top_row = line_rows.saturating_sub(1);
1727 } else {
1728 break;
1729 }
1730 remaining -= 1;
1731 }
1732 }
1733 }
1734
1735 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1736 let n = self.page_size
1737 .map(|p| p as i64)
1738 .unwrap_or_else(|| self.body_rows() as i64);
1739 self.scroll_lines(n, src, idx);
1740 }
1741
1742 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1743 let n = self.page_size
1744 .map(|p| p as i64)
1745 .unwrap_or_else(|| self.body_rows() as i64);
1746 self.scroll_lines(-n, src, idx);
1747 }
1748
1749 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1750 let n = (self.body_rows() / 2).max(1) as i64;
1751 self.scroll_lines(n, src, idx);
1752 }
1753
1754 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1755 let n = (self.body_rows() / 2).max(1) as i64;
1756 self.scroll_lines(-n, src, idx);
1757 }
1758
1759 pub fn goto_top(&mut self) {
1760 self.top_line = 0;
1761 self.top_row = 0;
1762 }
1763
1764 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1771 let body = self.body_rows() as usize;
1772 let total = idx.line_count();
1773 if total == 0 || body == 0 {
1774 return (0, 0);
1775 }
1776 let r_opts = self.render_opts(self.gutter_width(idx));
1777 let mut remaining = body;
1778 let mut line = total - 1;
1779 loop {
1780 let bytes = self.line_display_bytes(src, idx, line);
1781 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1782 if line_rows >= remaining {
1783 return (line, line_rows - remaining);
1784 }
1785 remaining -= line_rows;
1786 if line == 0 {
1787 return (0, 0);
1788 }
1789 line -= 1;
1790 }
1791 }
1792
1793 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1798 let body = self.body_rows() as usize;
1799 let n = self.visible_lines.len();
1800 if n == 0 || body == 0 {
1801 return (0, 0);
1802 }
1803 let r_opts = self.render_opts(self.gutter_width(idx));
1804 let mut remaining = body;
1805 let mut vi = n - 1;
1806 loop {
1807 let line = self.visible_lines[vi];
1808 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1809 if rows >= remaining {
1810 return (line, rows - remaining);
1811 }
1812 remaining -= rows;
1813 if vi == 0 {
1814 return (self.visible_lines[0], 0);
1815 }
1816 vi -= 1;
1817 }
1818 }
1819
1820 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1821 #[cfg(feature = "image")]
1822 if self.image_mode {
1823 let body = self.body_rows() as usize;
1824 self.top_line = self.image_total_rows().saturating_sub(body);
1825 self.top_row = 0;
1826 return;
1827 }
1828 idx.extend_to_end(src);
1829 if self.hide_mode() {
1830 self.extend_visible_lines(idx, src);
1831 let (line, row) = self.hide_bottom_anchor(src, idx);
1832 self.top_line = line;
1833 self.top_row = row;
1834 } else {
1835 let (line, row) = self.bottom_anchor(src, idx);
1836 self.top_line = line;
1837 self.top_row = row;
1838 }
1839 }
1840
1841 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1843 idx.extend_to_line(n, src);
1844 let target = n.min(idx.line_count().saturating_sub(1));
1845 self.top_line = target;
1846 self.top_row = 0;
1847 }
1848
1849 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1851 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1855 idx.extend_to_end(src);
1856 }
1857 if idx.record_count() == 0 {
1858 return;
1859 }
1860 let target = n.min(idx.record_count().saturating_sub(1));
1861 let line_range = idx.record_line_range(target);
1862 self.top_line = line_range.start;
1863 self.top_row = 0;
1864 }
1865
1866 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1869 let p = p.min(100) as usize;
1870 let target_byte = src.len().saturating_mul(p) / 100;
1871 idx.extend_to_byte_for_query(src, target_byte);
1872 let line_n = idx.line_at_byte(target_byte)
1873 .or_else(|| {
1874 let lc = idx.line_count();
1876 if lc > 0 { Some(lc - 1) } else { None }
1877 })
1878 .unwrap_or(0);
1879 self.top_line = line_n;
1880 self.top_row = 0;
1881 }
1882
1883 pub fn top_line(&self) -> usize {
1885 self.top_line
1886 }
1887
1888 pub fn resize(&mut self, cols: u16, rows: u16) {
1889 self.cols = cols.max(1);
1890 self.rows = rows.max(2);
1891 self.opts.cols = self.cols;
1892 }
1893
1894 pub fn toggle_line_numbers(&mut self) {
1895 self.show_line_numbers = !self.show_line_numbers;
1896 }
1897
1898 pub fn toggle_chop(&mut self) {
1899 self.opts.wrap = !self.opts.wrap;
1900 }
1901
1902 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1906}
1907
1908#[cfg(test)]
1909mod tests {
1910 use super::*;
1911 use crate::source::MockSource;
1912
1913 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1914 let m = MockSource::new();
1915 m.append(content);
1916 m.finish();
1917 let idx = LineIndex::new();
1918 (m, idx)
1919 }
1920
1921 #[test]
1922 fn frame_renders_body_height_rows() {
1923 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1924 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1926 assert_eq!(frame.body.len(), 4);
1927 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1928 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1929 }
1930
1931 #[test]
1932 fn scroll_down_advances_top_line() {
1933 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1936 let mut v = Viewport::new(10, 5, "test".into());
1937 v.scroll_lines(2, &m, &mut idx);
1938 assert_eq!(v.top_line, 2);
1939 assert_eq!(v.top_row, 0);
1940 }
1941
1942 #[test]
1943 fn scroll_up_clamps_at_zero() {
1944 let (m, mut idx) = setup(b"a\nb\nc\n");
1945 let mut v = Viewport::new(10, 5, "test".into());
1946 v.scroll_lines(-5, &m, &mut idx);
1947 assert_eq!(v.top_line, 0);
1948 assert_eq!(v.top_row, 0);
1949 }
1950
1951 #[test]
1952 fn scroll_down_clamps_at_last_line() {
1953 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1958 let mut v = Viewport::new(10, 5, "test".into());
1959 v.scroll_lines(50, &m, &mut idx);
1960 assert_eq!((v.top_line, v.top_row), (4, 0));
1961 assert!(v.is_at_bottom(&m, &idx));
1962 }
1963
1964 #[test]
1965 fn scroll_logical_lines_skips_wrap_rows() {
1966 let mut content = vec![b'X'; 500];
1968 content.push(b'\n');
1969 content.extend_from_slice(b"second\n");
1970 content.extend_from_slice(b"third\n");
1971 let (m, mut idx) = setup(&content);
1972 let mut v = Viewport::new(10, 8, "f".into());
1973 v.scroll_logical_lines(1, &m, &mut idx);
1974 assert_eq!((v.top_line, v.top_row), (1, 0));
1975 v.scroll_logical_lines(1, &m, &mut idx);
1976 assert_eq!((v.top_line, v.top_row), (2, 0));
1977 }
1978
1979 #[test]
1980 fn scroll_logical_lines_back_snaps_to_line_start() {
1981 let mut content = vec![b'A'; 50];
1986 content.push(b'\n');
1987 content.extend_from_slice(&[b'B'; 50]);
1988 content.push(b'\n');
1989 content.extend_from_slice(&[b'C'; 50]);
1990 content.push(b'\n');
1991 let (m, mut idx) = setup(&content);
1992 let mut v = Viewport::new(10, 8, "f".into());
1993 v.scroll_lines(7, &m, &mut idx);
1994 assert_eq!(v.top_line, 1, "should be on line 1");
1995 assert!(v.top_row > 0, "should be inside line 1's wraps");
1996 v.scroll_logical_lines(-1, &m, &mut idx);
1997 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1998 v.scroll_logical_lines(-1, &m, &mut idx);
1999 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2000 }
2001
2002 #[test]
2003 fn scroll_down_walks_wraps_of_last_line() {
2004 let mut content = b"first\n".to_vec();
2008 content.extend_from_slice(&[b'X'; 60]);
2009 content.push(b'\n');
2010 let (m, mut idx) = setup(&content);
2011 let mut v = Viewport::new(10, 5, "f".into());
2012 v.scroll_lines(1, &m, &mut idx);
2013 assert_eq!((v.top_line, v.top_row), (1, 0));
2014 v.scroll_lines(1, &m, &mut idx);
2015 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2016 v.scroll_lines(1, &m, &mut idx);
2017 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2018 v.scroll_lines(5, &m, &mut idx);
2020 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2021 }
2022
2023 #[test]
2024 fn scroll_down_walks_wrap_rows_within_long_line() {
2025 let mut content = vec![b'X'; 30];
2029 content.push(b'\n');
2030 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2031 let (m, mut idx) = setup(&content);
2032 let mut v = Viewport::new(10, 5, "f".into());
2033 v.scroll_lines(1, &m, &mut idx);
2034 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2035 v.scroll_lines(1, &m, &mut idx);
2036 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2037 v.scroll_lines(1, &m, &mut idx);
2038 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2039 }
2040
2041 #[test]
2042 fn status_line_shows_range_and_pct() {
2043 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2044 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2046 assert!(frame.status.starts_with("f 1-4/10"));
2047 }
2048
2049 #[test]
2050 fn page_down_advances_by_body_rows() {
2051 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2052 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2054 assert_eq!(v.top_line, 4);
2055 }
2056
2057 #[test]
2058 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2059 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2060 let mut v = Viewport::new(10, 5, "f".into());
2061 v.page_down(&m, &mut idx);
2062 v.page_up(&m, &mut idx);
2063 assert_eq!(v.top_line, 0);
2064 assert_eq!(v.top_row, 0);
2065 }
2066
2067 #[test]
2068 fn half_page_down_advances_by_half_body() {
2069 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2072 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2074 assert_eq!(v.top_line, 3);
2075 }
2076
2077 #[test]
2078 fn goto_top_resets_position() {
2079 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2080 let mut v = Viewport::new(10, 5, "f".into());
2081 v.scroll_lines(2, &m, &mut idx);
2082 v.goto_top();
2083 assert_eq!(v.top_line, 0);
2084 assert_eq!(v.top_row, 0);
2085 }
2086
2087 #[test]
2088 fn goto_bottom_scrolls_to_last_page() {
2089 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2090 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2092 assert_eq!(v.top_line, 6);
2094 }
2095
2096 #[cfg(feature = "image")]
2097 #[test]
2098 fn image_mode_frame_renders_and_scrolls() {
2099 use image::{Rgba, RgbaImage};
2100 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2101 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2103 assert!(v.image_mode());
2104 let total = v.image_total_rows();
2105 assert!(total > 5, "tall image should exceed the body");
2106 assert!(!v.is_at_bottom_image(), "starts at top");
2107 let mut idx = LineIndex::new();
2108 let m = MockSource::new();
2109 let frame = v.frame(&m, &mut idx);
2110 assert_eq!(frame.body.len(), 5);
2111 v.goto_bottom(&m, &mut idx);
2112 assert!(v.is_at_bottom_image());
2113 }
2114
2115 #[test]
2116 fn goto_line_positions_top_line() {
2117 let m = MockSource::new();
2118 m.append(b"a\nb\nc\nd\ne\n");
2119 let mut idx = LineIndex::new();
2120 idx.extend_to_end(&m);
2121 let mut v = Viewport::new(20, 5, "f".into());
2122 v.goto_line(3, &m, &mut idx);
2123 assert_eq!(v.top_line(), 3);
2124 }
2125
2126 #[test]
2127 fn goto_line_clamps_to_last_line() {
2128 let m = MockSource::new();
2129 m.append(b"a\nb\n");
2130 let mut idx = LineIndex::new();
2131 idx.extend_to_end(&m);
2132 let mut v = Viewport::new(20, 5, "f".into());
2133 v.goto_line(999, &m, &mut idx);
2134 assert_eq!(v.top_line(), 1);
2135 }
2136
2137 #[test]
2138 fn goto_record_positions_at_record_start_line() {
2139 let m = MockSource::new();
2140 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
2141 let mut idx = LineIndex::new();
2142 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2143 idx.extend_to_end(&m);
2144 let mut v = Viewport::new(20, 5, "f".into());
2145 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2147 }
2148
2149 #[test]
2150 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2151 let m = MockSource::new();
2152 m.append(b"a\nb\nc\n");
2153 let mut idx = LineIndex::new();
2154 idx.extend_to_end(&m);
2155 let mut v = Viewport::new(20, 5, "f".into());
2156 v.goto_record(2, &m, &mut idx);
2157 assert_eq!(v.top_line(), 2);
2158 }
2159
2160 #[test]
2161 fn goto_percent_50_lands_in_middle() {
2162 let m = MockSource::new();
2163 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2165 idx.extend_to_end(&m);
2166 let mut v = Viewport::new(20, 5, "f".into());
2167 v.goto_percent(50, &m, &mut idx);
2168 assert_eq!(v.top_line(), 2); }
2170
2171 #[test]
2172 fn goto_percent_100_lands_at_last_line() {
2173 let m = MockSource::new();
2174 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2176 idx.extend_to_end(&m);
2177 let mut v = Viewport::new(20, 5, "f".into());
2178 v.goto_percent(100, &m, &mut idx);
2179 assert_eq!(v.top_line(), 2);
2180 }
2181
2182 #[test]
2183 fn goto_percent_0_lands_at_first_line() {
2184 let m = MockSource::new();
2185 m.append(b"a\nb\nc\n");
2186 let mut idx = LineIndex::new();
2187 idx.extend_to_end(&m);
2188 let mut v = Viewport::new(20, 5, "f".into());
2189 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2191 v.goto_percent(0, &m, &mut idx);
2192 assert_eq!(v.top_line(), 0);
2193 }
2194
2195 #[test]
2196 fn resize_updates_dimensions_and_render_opts() {
2197 let (m, mut idx) = setup(b"1\n2\n");
2198 let mut v = Viewport::new(10, 5, "f".into());
2199 v.resize(40, 12);
2200 assert_eq!(v.cols, 40);
2201 assert_eq!(v.rows, 12);
2202 assert_eq!(v.opts.cols, 40);
2203 let _ = v.frame(&m, &mut idx);
2204 }
2205
2206 #[test]
2207 fn toggle_line_numbers_changes_gutter() {
2208 let (m, mut idx) = setup(b"a\nb\nc\n");
2209 let mut v = Viewport::new(10, 5, "f".into());
2210 let frame_off = v.frame(&m, &mut idx);
2211 v.toggle_line_numbers();
2212 let frame_on = v.frame(&m, &mut idx);
2213 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2215 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2216 }
2217
2218 #[test]
2219 fn toggle_chop_changes_wrap_mode() {
2220 let (m, mut idx) = setup(b"abcdefghij\n");
2221 let mut v = Viewport::new(4, 5, "f".into());
2222 v.toggle_chop();
2223 let frame = v.frame(&m, &mut idx);
2224 assert_eq!(frame.body[0][..4],
2227 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2228 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2229 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2230 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2231 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2233 }
2234
2235 #[test]
2238 fn is_at_bottom_initially_only_when_source_fits() {
2239 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2242 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2243 }
2244
2245 #[test]
2246 fn is_at_bottom_false_when_top_and_more_lines_below() {
2247 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);
2250 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2251 }
2252
2253 #[test]
2254 fn is_at_bottom_true_after_goto_bottom() {
2255 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2256 let mut v = Viewport::new(10, 5, "f".into());
2257 v.goto_bottom(&m, &mut idx);
2258 assert!(v.is_at_bottom(&m, &idx));
2259 }
2260
2261 #[test]
2262 fn status_shows_follow_suffix_when_follow_mode_on() {
2263 let (m, mut idx) = setup(b"a\nb\n");
2264 let mut v = Viewport::new(20, 5, "f".into());
2265 let frame_off = v.frame(&m, &mut idx);
2266 assert!(!frame_off.status.contains("(F)"));
2267 v.set_follow_mode(true);
2268 let frame_on = v.frame(&m, &mut idx);
2269 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2270 }
2271
2272 #[test]
2273 fn toggle_follow_flips_state() {
2274 let mut v = Viewport::new(10, 5, "f".into());
2275 assert!(!v.follow_mode());
2276 v.toggle_follow();
2277 assert!(v.follow_mode());
2278 v.toggle_follow();
2279 assert!(!v.follow_mode());
2280 }
2281
2282 #[test]
2283 fn idle_indicator_kicks_in_at_threshold() {
2284 let (m, mut idx) = setup(b"a\nb\n");
2285 let mut v = Viewport::new(20, 5, "f".into());
2286 v.set_follow_mode(true);
2287 for _ in 0..19 { v.tick_idle(); }
2289 let f1 = v.frame(&m, &mut idx);
2290 assert!(f1.status.contains("(F)"));
2291 assert!(!f1.status.contains("idle"));
2292 v.tick_idle();
2294 let f2 = v.frame(&m, &mut idx);
2295 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2296 }
2297
2298 #[test]
2299 fn note_growth_resets_idle() {
2300 let (m, mut idx) = setup(b"a\nb\n");
2301 let mut v = Viewport::new(20, 5, "f".into());
2302 v.set_follow_mode(true);
2303 for _ in 0..25 { v.tick_idle(); }
2304 assert!(v.is_idle());
2305 v.note_growth();
2306 assert!(!v.is_idle());
2307 let f = v.frame(&m, &mut idx);
2308 assert!(!f.status.contains("idle"));
2309 }
2310
2311 #[test]
2312 fn qae_off_never_quits_even_at_bottom() {
2313 let (m, mut idx) = setup(b"a\n");
2314 let mut v = Viewport::new(20, 5, "f".into());
2315 v.set_quit_at_eof(QuitAtEof::Off);
2316 v.goto_bottom(&m, &mut idx);
2317 assert!(!v.note_motion_for_eof(true, &m, &idx));
2318 }
2319
2320 #[test]
2321 fn qae_first_quits_immediately_at_bottom() {
2322 let (m, mut idx) = setup(b"a\n");
2323 let mut v = Viewport::new(20, 5, "f".into());
2324 v.set_quit_at_eof(QuitAtEof::First);
2325 v.goto_bottom(&m, &mut idx);
2326 assert!(v.note_motion_for_eof(true, &m, &idx));
2327 }
2328
2329 #[test]
2330 fn qae_first_only_quits_at_eof_not_mid_file() {
2331 let mut content = Vec::new();
2332 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2333 let (m, mut idx) = setup(&content);
2334 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2336 v.set_quit_at_eof(QuitAtEof::First);
2337 assert!(!v.is_at_bottom(&m, &idx));
2339 assert!(!v.note_motion_for_eof(true, &m, &idx));
2340 }
2341
2342 #[test]
2343 fn qae_second_quits_on_second_hit() {
2344 let (m, mut idx) = setup(b"a\n");
2345 let mut v = Viewport::new(20, 5, "f".into());
2346 v.set_quit_at_eof(QuitAtEof::Second);
2347 v.goto_bottom(&m, &mut idx);
2348 assert!(!v.note_motion_for_eof(true, &m, &idx));
2350 assert!(v.note_motion_for_eof(true, &m, &idx));
2352 }
2353
2354 #[test]
2355 fn squeeze_collapses_consecutive_blanks() {
2356 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2358 let mut v = Viewport::new(10, 8, "f".into());
2359 v.set_squeeze_blanks(true);
2360 let f = v.frame(&m, &mut idx);
2361 let stringify = |row: &Vec<Cell>| -> String {
2363 row.iter().filter_map(|c| match c {
2364 Cell::Char { ch, .. } => Some(*ch),
2365 _ => None,
2366 }).collect::<String>().trim().to_string()
2367 };
2368 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2369 assert_eq!(&rows[0], "a");
2371 assert_eq!(&rows[1], "");
2372 assert_eq!(&rows[2], "b");
2373 }
2374
2375 #[test]
2376 fn header_pins_top_rows_when_scrolling() {
2377 let mut content = Vec::new();
2379 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2380 let (m, mut idx) = setup(&content);
2381 let mut v = Viewport::new(20, 6, "f".into());
2382 v.set_header(2, 0);
2383 v.scroll_lines(5, &m, &mut idx);
2387 let f = v.frame(&m, &mut idx);
2388 let chs = |row: &Vec<Cell>| -> String {
2389 row.iter().filter_map(|c| match c {
2390 Cell::Char { ch, .. } => Some(*ch),
2391 _ => None,
2392 }).collect::<String>().trim().to_string()
2393 };
2394 assert_eq!(&chs(&f.body[0]), "line0");
2396 assert_eq!(&chs(&f.body[1]), "line1");
2397 assert_eq!(&chs(&f.body[2]), "line7");
2399 }
2400
2401 #[test]
2402 fn page_size_when_set_overrides_body_rows() {
2403 let mut content = Vec::new();
2404 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2405 let (m, mut idx) = setup(&content);
2406 let mut v = Viewport::new(20, 10, "f".into());
2407 v.set_page_size(Some(3));
2408 let before = v.top_line();
2409 v.page_down(&m, &mut idx);
2410 assert_eq!(v.top_line(), before + 3);
2411 v.page_up(&m, &mut idx);
2412 assert_eq!(v.top_line(), before);
2413 }
2414
2415 #[test]
2416 fn page_size_unset_uses_body_rows() {
2417 let mut content = Vec::new();
2418 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2419 let (m, mut idx) = setup(&content);
2420 let mut v = Viewport::new(20, 10, "f".into());
2421 v.page_down(&m, &mut idx);
2423 assert_eq!(v.top_line(), 9);
2424 }
2425
2426 #[test]
2427 fn header_zero_lines_renders_like_no_header() {
2428 let mut content = Vec::new();
2429 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2430 let (m, mut idx) = setup(&content);
2431 let mut v = Viewport::new(20, 6, "f".into());
2432 v.set_header(0, 0);
2433 let f = v.frame(&m, &mut idx);
2434 let chs = |row: &Vec<Cell>| -> String {
2435 row.iter().filter_map(|c| match c {
2436 Cell::Char { ch, .. } => Some(*ch),
2437 _ => None,
2438 }).collect::<String>().trim().to_string()
2439 };
2440 assert_eq!(&chs(&f.body[0]), "line0");
2441 assert_eq!(&chs(&f.body[1]), "line1");
2442 }
2443
2444 #[test]
2445 fn squeeze_off_preserves_blanks() {
2446 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2447 let mut v = Viewport::new(10, 8, "f".into());
2448 let f = v.frame(&m, &mut idx);
2450 let stringify = |row: &Vec<Cell>| -> String {
2451 row.iter().filter_map(|c| match c {
2452 Cell::Char { ch, .. } => Some(*ch),
2453 _ => None,
2454 }).collect::<String>().trim().to_string()
2455 };
2456 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2457 assert_eq!(&rows[0], "a");
2459 assert_eq!(&rows[1], "");
2460 assert_eq!(&rows[2], "");
2461 assert_eq!(&rows[3], "");
2462 assert_eq!(&rows[4], "b");
2463 }
2464
2465 #[test]
2466 fn qae_second_resets_on_backward_motion() {
2467 let (m, mut idx) = setup(b"a\n");
2468 let mut v = Viewport::new(20, 5, "f".into());
2469 v.set_quit_at_eof(QuitAtEof::Second);
2470 v.goto_bottom(&m, &mut idx);
2471 assert!(!v.note_motion_for_eof(true, &m, &idx));
2472 v.note_motion_for_eof(false, &m, &idx);
2474 assert!(!v.note_motion_for_eof(true, &m, &idx));
2476 assert!(v.note_motion_for_eof(true, &m, &idx));
2478 }
2479
2480 #[test]
2481 fn flash_message_overrides_follow_suffix() {
2482 let (m, mut idx) = setup(b"a\nb\n");
2483 let mut v = Viewport::new(40, 5, "f".into());
2484 v.set_follow_mode(true);
2485 v.flash("(F reopened)", 3);
2486 let f = v.frame(&m, &mut idx);
2487 assert!(f.status.contains("(F reopened)"), "{}", f.status);
2488 assert!(!f.status.contains("(F idle)"));
2489 }
2490
2491 #[test]
2492 fn flash_countdown_clears() {
2493 let mut v = Viewport::new(10, 5, "f".into());
2494 v.flash("hello", 2);
2495 v.tick_flash();
2496 assert!(v.status_flash.is_some());
2497 v.tick_flash();
2498 assert!(v.status_flash.is_none());
2499 }
2500
2501 #[test]
2502 fn suspend_follow_if_off_is_noop() {
2503 let mut v = Viewport::new(10, 5, "f".into());
2504 v.set_follow_mode(true);
2505 v.suspend_follow_if(false);
2506 assert!(v.follow_mode());
2507 }
2508
2509 #[test]
2510 fn suspend_follow_if_on_flips_off() {
2511 let mut v = Viewport::new(10, 5, "f".into());
2512 v.set_follow_mode(true);
2513 v.suspend_follow_if(true);
2514 assert!(!v.follow_mode());
2515 }
2516
2517 #[test]
2518 fn case_mode_sensitive_returns_pattern_unchanged() {
2519 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2520 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2521 }
2522
2523 #[test]
2524 fn case_mode_insensitive_prepends_i_flag() {
2525 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2526 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2527 }
2528
2529 #[test]
2530 fn case_mode_smart_lowercase_is_insensitive() {
2531 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2532 }
2533
2534 #[test]
2535 fn case_mode_smart_with_uppercase_is_sensitive() {
2536 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2537 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2538 }
2539
2540 #[test]
2541 fn set_case_mode_recompiles_active_search() {
2542 let (m, mut idx) = setup(b"hello WORLD\n");
2543 let mut v = Viewport::new(40, 5, "f".into());
2544 v.set_search("world".into(), SearchDirection::Forward).unwrap();
2545 assert!(!v.search_repeat(&m, &mut idx, false));
2547 v.set_case_mode(CaseMode::Insensitive);
2549 assert!(v.search_repeat(&m, &mut idx, false));
2550 }
2551
2552 #[test]
2553 fn status_shows_prettify_label_when_set() {
2554 let (m, mut idx) = setup(b"a\n");
2555 let mut v = Viewport::new(40, 5, "f".into());
2556 let frame_off = v.frame(&m, &mut idx);
2557 assert!(!frame_off.status.contains("[pretty"));
2558 v.set_prettify_label(Some("json".into()));
2559 let frame_on = v.frame(&m, &mut idx);
2560 assert!(frame_on.status.contains("[pretty:json]"),
2561 "expected [pretty:json] in status, got: {}", frame_on.status);
2562 v.set_prettify_label(Some("json:err".into()));
2563 let frame_err = v.frame(&m, &mut idx);
2564 assert!(frame_err.status.contains("[pretty:json:err]"),
2565 "expected [pretty:json:err] in status, got: {}", frame_err.status);
2566 }
2567
2568 #[test]
2569 fn status_shows_l_suffix_when_live_mode_on() {
2570 let (m, mut idx) = setup(b"a\nb\n");
2571 let mut v = Viewport::new(20, 5, "f".into());
2572 let frame_off = v.frame(&m, &mut idx);
2573 assert!(!frame_off.status.contains("(L)"));
2574 v.set_live_mode(true);
2575 let frame_on = v.frame(&m, &mut idx);
2576 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2577 }
2578
2579 #[test]
2580 fn clamp_top_line_pulls_back_when_total_shrinks() {
2581 let mut v = Viewport::new(20, 5, "f".into());
2582 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
2591 let (m, mut idx) = setup(b"only\n");
2593 let _ = v.frame(&m, &mut idx);
2594 }
2595
2596 fn simulate_growth_tick(
2599 v: &mut Viewport,
2600 src: &MockSource,
2601 idx: &mut LineIndex,
2602 ) {
2603 if !v.follow_mode() { return; }
2604 let was_at_bottom = v.is_at_bottom(src, idx);
2605 let lines_before = idx.line_count();
2606 idx.notice_new_bytes(src);
2607 if idx.line_count() != lines_before && was_at_bottom {
2608 v.goto_bottom(src, idx);
2609 }
2610 }
2611
2612 #[test]
2613 fn auto_scroll_engages_when_at_bottom() {
2614 let m = MockSource::new();
2615 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
2617 let mut v = Viewport::new(10, 5, "f".into());
2618 v.set_follow_mode(true);
2619 idx.extend_to_end(&m);
2620 assert!(v.is_at_bottom(&m, &idx));
2621 let top_before = {
2622 let f = v.frame(&m, &mut idx);
2623 f.status.clone() };
2625 let _ = top_before;
2626 m.append(b"5\n6\n7\n8\n");
2628 simulate_growth_tick(&mut v, &m, &mut idx);
2629 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
2631 let frame = v.frame(&m, &mut idx);
2632 let last_row = &frame.body[frame.body.len() - 1];
2635 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2636 }
2637
2638 #[test]
2639 fn auto_scroll_suppressed_when_scrolled_up() {
2640 let m = MockSource::new();
2641 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
2643 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
2645 idx.extend_to_end(&m);
2646 v.goto_bottom(&m, &mut idx);
2647 v.scroll_lines(-2, &m, &mut idx);
2649 assert!(!v.is_at_bottom(&m, &idx));
2650 let frame_before = v.frame(&m, &mut idx);
2651 let top_first_cell_before = frame_before.body[0][0].clone();
2652 m.append(b"9\n10\n");
2654 simulate_growth_tick(&mut v, &m, &mut idx);
2655 let frame_after = v.frame(&m, &mut idx);
2657 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2658 }
2659
2660 #[test]
2663 fn set_search_compiles_regex() {
2664 let mut v = Viewport::new(10, 5, "f".into());
2665 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2666 assert!(v.search_active());
2667 }
2668
2669 #[test]
2670 fn set_search_rejects_bad_regex() {
2671 let mut v = Viewport::new(10, 5, "f".into());
2672 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2673 assert!(!err.is_empty());
2674 assert!(!v.search_active(), "no search should be set on error");
2675 }
2676
2677 #[test]
2678 fn search_step_forward_finds_match_after_top() {
2679 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2680 let mut v = Viewport::new(20, 5, "f".into());
2681 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2682 let found = v.search_repeat(&m, &mut idx, false);
2683 assert!(found);
2684 assert_eq!(v.top_line, 2);
2686 }
2687
2688 #[test]
2689 fn search_step_backward_finds_match_before_top() {
2690 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2691 let mut v = Viewport::new(20, 5, "f".into());
2692 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2694 let found = v.search_repeat(&m, &mut idx, false);
2695 assert!(found);
2696 assert_eq!(v.top_line, 0);
2697 }
2698
2699 #[test]
2700 fn search_wraps_at_end() {
2701 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2702 let mut v = Viewport::new(20, 5, "f".into());
2703 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2705 let found = v.search_repeat(&m, &mut idx, false);
2706 assert!(found, "search should wrap forward past EOF");
2707 assert_eq!(v.top_line, 0);
2708 }
2709
2710 #[test]
2711 fn search_no_match_returns_false_and_does_not_move() {
2712 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2713 let mut v = Viewport::new(20, 5, "f".into());
2714 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2715 let found = v.search_repeat(&m, &mut idx, false);
2716 assert!(!found);
2717 assert_eq!(v.top_line, 0);
2718 }
2719
2720 #[test]
2721 fn frame_records_highlight_ranges_for_matches() {
2722 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2723 let mut v = Viewport::new(20, 5, "f".into());
2724 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2725 let frame = v.frame(&m, &mut idx);
2726 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2728 assert!(frame.highlights[0].is_empty());
2729 assert!(frame.highlights[1].is_empty());
2730 assert_eq!(frame.highlights[2], vec![0..5]);
2731 assert!(frame.highlights[3].is_empty());
2732 }
2733
2734 #[test]
2735 fn frame_highlights_substring_inside_a_row() {
2736 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2737 let mut v = Viewport::new(40, 5, "f".into());
2738 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2739 let frame = v.frame(&m, &mut idx);
2740 assert_eq!(frame.highlights[0], vec![18..22]);
2742 assert!(frame.highlights[1].is_empty());
2743 }
2744
2745 #[test]
2746 fn search_highlight_with_filter_dim_keeps_row_dim() {
2747 let (m, mut idx) = setup(b"alpha\nbeta\n");
2750 let mut v = Viewport::new(20, 5, "f".into());
2751 let fmt = crate::format::LogFormat::compile(
2752 "simple",
2753 r"^(?P<line>.+)$",
2754 )
2755 .unwrap();
2756 let f = crate::filter::CompiledFilter::compile(
2757 &fmt,
2758 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2759 CaseMode::Sensitive,
2760 )
2761 .unwrap();
2762 v.set_filter(Some(f));
2763 v.set_dim_mode(true);
2764 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2765 let frame = v.frame(&m, &mut idx);
2766 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2767 assert_eq!(frame.row_styles[1], RowStyle::Dim);
2768 assert_eq!(frame.highlights[1], vec![0..4]);
2769 }
2770
2771 #[test]
2772 fn grep_only_hides_non_matching_lines() {
2773 use crate::grep::GrepPredicate;
2774 let src = crate::source::MockSource::new();
2775 src.append(b"keep this error\n");
2776 src.append(b"drop this one\n");
2777 src.append(b"another error line\n");
2778 src.finish();
2779 let mut idx = crate::line_index::LineIndex::new();
2780 idx.extend_to_end(&src);
2781
2782 let mut v = Viewport::new(40, 5, "test".into());
2783 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2784 v.extend_visible_lines(&idx, &src);
2785
2786 let frame = v.frame(&src, &mut idx);
2788 let body_text: Vec<String> = frame.body.iter()
2789 .map(|row| row.iter().filter_map(|c| match c {
2790 crate::render::Cell::Char { ch, .. } => Some(*ch),
2791 _ => None,
2792 }).collect())
2793 .collect();
2794 assert!(body_text[0].contains("keep this error"));
2795 assert!(body_text[1].contains("another error line"));
2796 assert!(frame.status.contains("[grep]"));
2797 }
2798
2799 #[test]
2800 fn filter_and_grep_combine_with_and() {
2801 use crate::grep::GrepPredicate;
2802 let fmt = crate::format::LogFormat::compile(
2803 "simple",
2804 r"^(?P<level>\w+) (?P<msg>.+)$",
2805 ).unwrap();
2806 let f = crate::filter::CompiledFilter::compile(
2807 &fmt,
2808 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2809 CaseMode::Sensitive,
2810 ).unwrap();
2811 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2812
2813 let src = crate::source::MockSource::new();
2814 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();
2819 let mut idx = crate::line_index::LineIndex::new();
2820 idx.extend_to_end(&src);
2821
2822 let mut v = Viewport::new(80, 5, "test".into());
2823 v.set_filter(Some(f));
2824 v.set_grep(Some(g));
2825 v.extend_visible_lines(&idx, &src);
2826 assert_eq!(v.visible_lines(), &[0usize]);
2827 }
2828
2829 #[test]
2830 fn search_status_shows_pattern() {
2831 let (m, mut idx) = setup(b"x\n");
2832 let mut v = Viewport::new(20, 5, "f".into());
2833 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2834 let frame = v.frame(&m, &mut idx);
2835 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
2836 }
2837
2838 #[test]
2839 fn repeat_search_after_first_match_advances() {
2840 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
2841 let mut v = Viewport::new(40, 5, "f".into());
2842 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2843 assert!(v.search_repeat(&m, &mut idx, false));
2844 assert_eq!(v.top_line, 1, "first foo");
2845 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2846 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
2847 assert_eq!(v.top_line, 3, "should advance to next foo");
2848 }
2849
2850 #[test]
2851 fn auto_scroll_paused_when_follow_off() {
2852 let m = MockSource::new();
2853 m.append(b"1\n2\n3\n4\n");
2854 let mut idx = LineIndex::new();
2855 let mut v = Viewport::new(10, 5, "f".into());
2856 idx.extend_to_end(&m);
2858 let frame_before = v.frame(&m, &mut idx);
2859 let top_first_cell = frame_before.body[0][0].clone();
2860 m.append(b"5\n6\n7\n8\n");
2861 simulate_growth_tick(&mut v, &m, &mut idx);
2862 let frame_after = v.frame(&m, &mut idx);
2863 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
2864 }
2865
2866 #[test]
2869 fn search_jumps_to_next_matching_record() {
2870 let m = MockSource::new();
2871 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
2872 let mut idx = LineIndex::new();
2873 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2874 idx.extend_to_end(&m);
2875 let mut v = Viewport::new(40, 10, "f".into());
2876 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
2877 let hit = v.search_repeat(&m, &mut idx, false);
2878 assert!(hit, "should find 'charlie' in record 2");
2879 assert_eq!(v.top_line(), 3); }
2881
2882 #[test]
2883 fn search_finds_cross_line_match_in_record_with_s_flag() {
2884 let m = MockSource::new();
2885 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
2886 let mut idx = LineIndex::new();
2887 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2888 idx.extend_to_end(&m);
2889 let mut v = Viewport::new(40, 10, "f".into());
2890 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
2891 let hit = v.search_repeat(&m, &mut idx, false);
2892 assert!(hit, "should match across \\n inside record 0 with (?s)");
2893 assert_eq!(v.top_line(), 0);
2894 }
2895
2896 #[test]
2897 fn search_repeat_with_no_match_returns_false() {
2898 let m = MockSource::new();
2899 m.append(b"[1] alpha\n[2] bravo\n");
2900 let mut idx = LineIndex::new();
2901 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2902 idx.extend_to_end(&m);
2903 let mut v = Viewport::new(40, 10, "f".into());
2904 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
2905 let hit = v.search_repeat(&m, &mut idx, false);
2906 assert!(!hit);
2907 }
2908
2909 #[test]
2912 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
2913 let m = MockSource::new();
2916 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
2917 let mut idx = LineIndex::new();
2918 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2919 idx.extend_to_end(&m);
2920 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2921 let mut v = Viewport::new(40, 10, "f".into());
2922 v.set_grep(Some(grep));
2923 v.extend_visible_lines(&idx, &m);
2924 assert_eq!(v.visible_lines(), &[0usize, 1]);
2927 }
2928
2929 #[test]
2930 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2931 let m = MockSource::new();
2937 m.append(
2938 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
2939 );
2940 let mut idx = LineIndex::new();
2941 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2942 idx.extend_to_end(&m);
2943 let fmt = crate::format::LogFormat::compile(
2944 "rec",
2945 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2946 )
2947 .unwrap();
2948 let f = crate::filter::CompiledFilter::compile(
2949 &fmt,
2950 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2951 CaseMode::Sensitive,
2952 )
2953 .unwrap();
2954 let mut v = Viewport::new(40, 10, "f".into());
2955 v.set_filter(Some(f));
2956 v.extend_visible_lines(&idx, &m);
2957 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2959 }
2960
2961 #[test]
2962 fn grep_matches_across_record_newlines_in_records_mode() {
2963 let m = MockSource::new();
2965 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2966 let mut idx = LineIndex::new();
2967 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2968 idx.extend_to_end(&m);
2969 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2970 let mut v = Viewport::new(40, 10, "f".into());
2971 v.set_grep(Some(grep));
2972 v.extend_visible_lines(&idx, &m);
2973 assert_eq!(v.visible_lines(), &[0usize, 1]);
2975 }
2976
2977 #[test]
2978 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2979 let m = MockSource::new();
2982 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2983 let mut idx = LineIndex::new();
2984 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2985 idx.extend_to_end(&m);
2986 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
2987 let mut v = Viewport::new(40, 10, "f".into());
2988 v.set_grep(Some(grep));
2989 v.set_dim_mode(true);
2990 v.extend_visible_lines(&idx, &m);
2991 assert_eq!(v.visible_lines(), &[] as &[usize]);
2993 assert!(!v.should_dim_line(0, &idx, &m));
2995 assert!(!v.should_dim_line(1, &idx, &m));
2996 assert!(v.should_dim_line(2, &idx, &m));
2998 assert!(v.should_dim_line(3, &idx, &m));
2999 }
3000
3001 #[test]
3002 fn status_unchanged_when_records_inactive() {
3003 let (m, mut idx) = setup(b"a\nb\nc\n");
3004 let mut v = Viewport::new(20, 5, "f".into());
3005 let frame = v.frame(&m, &mut idx);
3006 let status = &frame.status;
3007 assert!(status.contains("1-3/3"), "got: {status}");
3009 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3010 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3011 }
3012
3013 #[test]
3014 fn status_r_block_uses_real_lines_in_hide_mode() {
3015 let m = MockSource::new();
3024 let mut buf = Vec::new();
3027 for n in 0..10 {
3028 let kind = if n >= 8 { "B" } else { "A" };
3029 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3030 }
3031 m.append(&buf);
3032 m.finish();
3033
3034 let mut idx = LineIndex::new();
3035 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3036 idx.extend_to_end(&m);
3037
3038 let fmt = crate::format::LogFormat::compile(
3039 "rec",
3040 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3041 )
3042 .unwrap();
3043 let f = crate::filter::CompiledFilter::compile(
3044 &fmt,
3045 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3046 CaseMode::Sensitive,
3047 )
3048 .unwrap();
3049
3050 let mut v = Viewport::new(80, 5, "f".into());
3053 v.set_filter(Some(f));
3054 v.extend_visible_lines(&idx, &m);
3055
3056 v.goto_record(8, &m, &mut idx);
3058
3059 let frame = v.frame(&m, &mut idx);
3060 assert!(
3062 frame.status.contains("R9-10/10"),
3063 "expected R9-10/10 in status, got: {}",
3064 frame.status,
3065 );
3066 }
3067
3068 #[test]
3069 fn status_dual_readout_when_records_active() {
3070 let m = MockSource::new();
3071 m.append(b"[1] a\n cont\n[2] b\n");
3072 m.finish();
3073 let mut idx = LineIndex::new();
3074 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3075 idx.extend_to_end(&m);
3076 let mut v = Viewport::new(20, 5, "f".into());
3077 let frame = v.frame(&m, &mut idx);
3078 let status = &frame.status;
3079 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3080 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3081 }
3082
3083 #[test]
3084 fn format_status_uses_custom_template_when_set() {
3085 let m = MockSource::new();
3086 m.append(b"a\nb\nc\n");
3087 m.finish();
3088 let mut idx = LineIndex::new();
3089 idx.extend_to_end(&m);
3090 let mut v = Viewport::new(20, 5, "f".into());
3091 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3092 v.set_prompt(Some(prompt));
3093 let frame = v.frame(&m, &mut idx);
3094 assert_eq!(frame.status, "f 100%");
3095 }
3096
3097 #[test]
3098 fn status_shows_preprocess_failed_tag_when_set() {
3099 let m = MockSource::new();
3100 m.append(b"a\n");
3101 let mut idx = LineIndex::new();
3102 idx.extend_to_end(&m);
3103 let mut v = Viewport::new(40, 5, "f".into());
3104 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3105 let frame = v.frame(&m, &mut idx);
3106 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3107 "got: {}", frame.status);
3108 }
3109
3110 #[test]
3111 fn default_status_includes_help_hint() {
3112 let (m, mut idx) = setup(b"a\nb\nc\n");
3113 let mut v = Viewport::new(80, 5, "f".into());
3114 let frame = v.frame(&m, &mut idx);
3115 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3116 }
3117
3118 #[test]
3119 fn custom_prompt_does_not_get_help_hint() {
3120 let (m, mut idx) = setup(b"a\nb\nc\n");
3121 let mut v = Viewport::new(80, 5, "f".into());
3122 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3123 let frame = v.frame(&m, &mut idx);
3124 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3125 }
3126
3127 #[test]
3128 fn status_shows_file_index_when_multifile() {
3129 let m = MockSource::new();
3130 m.append(b"a\n");
3131 let mut idx = LineIndex::new();
3132 idx.extend_to_end(&m);
3133 let mut v = Viewport::new(60, 5, "f.log".into());
3134 v.set_file_index(0, 3);
3135 let frame = v.frame(&m, &mut idx);
3136 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3137 }
3138
3139 #[test]
3140 fn status_omits_file_index_when_single_file() {
3141 let m = MockSource::new();
3142 m.append(b"a\n");
3143 let mut idx = LineIndex::new();
3144 idx.extend_to_end(&m);
3145 let mut v = Viewport::new(60, 5, "f.log".into());
3146 v.set_file_index(0, 1);
3147 let frame = v.frame(&m, &mut idx);
3148 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3149 }
3150
3151 #[test]
3152 fn status_shows_tag_active_when_multimatch() {
3153 let m = MockSource::new();
3154 m.append(b"a\n");
3155 let mut idx = LineIndex::new();
3156 idx.extend_to_end(&m);
3157 let mut v = Viewport::new(80, 5, "f.log".into());
3158 v.set_tag_active(Some(("foo".into(), 2, 3)));
3159 let frame = v.frame(&m, &mut idx);
3160 assert!(
3161 frame.status.contains("[tag: foo (2/3)]"),
3162 "got: {}",
3163 frame.status
3164 );
3165 }
3166
3167 #[test]
3168 fn status_omits_tag_active_when_single_match() {
3169 let m = MockSource::new();
3170 m.append(b"a\n");
3171 let mut idx = LineIndex::new();
3172 idx.extend_to_end(&m);
3173 let mut v = Viewport::new(80, 5, "f.log".into());
3174 v.set_tag_active(Some(("foo".into(), 1, 1)));
3175 let frame = v.frame(&m, &mut idx);
3176 assert!(
3177 !frame.status.contains("[tag:"),
3178 "should not show indicator for single match: {}",
3179 frame.status
3180 );
3181 }
3182
3183 #[test]
3186 fn reconstruct_picks_up_state_from_prior_lines() {
3187 let m = MockSource::new();
3188 m.append(b"\x1b[31mline 1\n");
3189 m.append(b"line 2 (still red, no reset)\n");
3190 m.append(b"line 3\n");
3191 let mut idx = LineIndex::new();
3192 idx.extend_to_end(&m);
3193 let state = reconstruct_render_state(&m, &idx, 2);
3194 assert_eq!(
3195 state.style.fg,
3196 Some(crate::ansi::Color::Ansi(1)),
3197 "red SGR from line 0 should persist to line 2"
3198 );
3199 }
3200
3201 #[test]
3202 fn reconstruct_respects_reset_between_lines() {
3203 let m = MockSource::new();
3204 m.append(b"\x1b[31mline 1\x1b[0m\n");
3205 m.append(b"line 2 (default)\n");
3206 let mut idx = LineIndex::new();
3207 idx.extend_to_end(&m);
3208 let state = reconstruct_render_state(&m, &idx, 1);
3209 assert_eq!(state.style.fg, None);
3210 }
3211
3212 #[test]
3213 fn reconstruct_caps_walkback_at_max_lines() {
3214 let m = MockSource::new();
3215 m.append(b"\x1b[31mvery early\n");
3216 for _ in 0..300 {
3217 m.append(b"line\n");
3218 }
3219 let mut idx = LineIndex::new();
3220 idx.extend_to_end(&m);
3221 let state = reconstruct_render_state(&m, &idx, 290);
3224 assert_eq!(state.style.fg, None);
3225 }
3226
3227 #[test]
3228 fn or_groups_narrow_within_required_line_mode() {
3229 let mut raw = crate::or::OrSpecRaw::new();
3230 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
3231 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
3232 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3233 let mut v = Viewport::new(80, 24, "t".into());
3234 v.set_or_groups(og);
3235 assert!(v.or_active());
3236 assert!(v.line_passes(b"login failed"));
3237 assert!(v.line_passes(b"access denied"));
3238 assert!(!v.line_passes(b"login ok"));
3239 }
3240
3241 #[test]
3242 fn status_shows_or_indicator_when_active() {
3243 let mut raw = crate::or::OrSpecRaw::new();
3244 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
3245 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3246 let (m, mut idx) = setup(b"x\ny\nx\n");
3247 idx.extend_to_end(&m);
3248 let mut v = Viewport::new(80, 5, "f".into());
3249 v.set_or_groups(og);
3250 v.extend_visible_lines(&idx, &m);
3251 let status = v.format_status(&idx, &m);
3252 assert!(status.contains("[or]"), "expected [or] in status: {status}");
3253 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
3254 }
3255}