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