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