1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::or::OrGroups;
8use crate::line_index::LineIndex;
9use crate::render::{count_rows, render_line, Cell, RenderOpts};
10use crate::source::Source;
11
12const MAX_RECONSTRUCT_LINES: usize = 256;
16
17fn reconstruct_render_state(
24 src: &dyn Source,
25 idx: &crate::line_index::LineIndex,
26 target_line: usize,
27) -> crate::render::RenderState {
28 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
29 let mut state = crate::render::RenderState::default();
30 for line_no in start..target_line {
31 let range = idx.line_range(line_no, src);
32 let raw = src.bytes(range);
33 for &b in raw.as_ref() {
34 let _ = crate::ansi::step(
35 &mut state.parse,
36 &mut state.style,
37 &mut state.hyperlink,
38 b,
39 );
40 }
41 }
42 state
43}
44
45fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
51 let mut text = String::new();
52 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
53 for (col, cell) in row.iter().enumerate() {
54 match cell {
55 Cell::Char { ch, .. } => {
56 starts.push(col);
57 text.push(*ch);
58 }
59 Cell::Empty => {
60 starts.push(col);
61 text.push(' ');
62 }
63 Cell::Continuation => {}
64 }
65 }
66 starts.push(row.len());
67 (text, starts)
68}
69
70fn line_is_blank(bytes: &[u8]) -> bool {
75 bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
76}
77
78fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
82 if row.is_empty() {
83 return Vec::new();
84 }
85 let last_content_col = row
86 .iter()
87 .enumerate()
88 .rev()
89 .find_map(|(c, cell)| match cell {
90 Cell::Char { width, .. } => Some(c + *width as usize),
91 Cell::Continuation => Some(c + 1),
92 Cell::Empty => None,
93 })
94 .unwrap_or(0);
95 if last_content_col == 0 {
96 return Vec::new();
97 }
98 let (text, starts) = row_text_and_starts(row);
99 let mut out = Vec::new();
100 for m in regex.find_iter(&text) {
101 if m.start() == m.end() {
102 continue;
103 }
104 let char_start = text[..m.start()].chars().count();
105 let char_end = text[..m.end()].chars().count();
106 if char_start >= starts.len() - 1 || char_end <= char_start {
107 continue;
108 }
109 let col_start = starts[char_start];
110 let col_end = starts[char_end].min(last_content_col);
111 if col_end > col_start {
112 out.push(col_start..col_end);
113 }
114 }
115 out
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RowStyle {
120 Normal,
121 Dim,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SearchDirection {
128 Forward,
129 Backward,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum CaseMode {
138 #[default]
139 Sensitive,
140 Smart,
141 Insensitive,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum QuitAtEof {
150 #[default]
151 Off,
152 Second,
153 First,
154}
155
156impl CaseMode {
157 pub fn apply_to_pattern(self, pattern: &str) -> String {
160 match self {
161 CaseMode::Sensitive => pattern.to_string(),
162 CaseMode::Insensitive => format!("(?i){pattern}"),
163 CaseMode::Smart => {
164 if pattern.chars().any(|c| c.is_uppercase()) {
165 pattern.to_string()
166 } else {
167 format!("(?i){pattern}")
168 }
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone)]
175pub struct SearchState {
176 pub raw: String,
177 pub regex: Regex,
178 pub direction: SearchDirection,
179}
180
181#[derive(Debug, Clone)]
182pub struct Frame {
183 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
190 pub status: String,
191 pub status_style: crate::ansi::Style,
193 pub raw_rows: Vec<Option<Vec<u8>>>,
201 pub image_blob: Option<Vec<u8>>,
206}
207
208#[cfg(feature = "image")]
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub enum ImageProtocol {
214 #[default]
215 Ascii,
216 Kitty,
217 Sixel,
218}
219
220pub struct Viewport {
221 top_line: usize,
222 top_row: usize,
223 left_col: usize,
227 cols: u16,
228 rows: u16,
229 pub opts: RenderOpts,
230 pub show_line_numbers: bool,
231 pub source_label: String,
232 follow_mode: bool,
233 live_mode: bool,
234 prettify_label: Option<String>,
235 format_label: Option<String>,
236 filter: Option<CompiledFilter>,
237 grep: Option<GrepPredicate>,
238 or_groups: OrGroups,
239 dim_mode: bool,
240 visible_lines: Vec<usize>,
243 visible_scanned: usize,
246 search: Option<SearchState>,
247 display: Option<crate::format::DisplayRenderer>,
251 hex_mode: bool,
252 #[cfg(feature = "image")]
253 image: Option<image::RgbaImage>,
254 #[cfg(feature = "image")]
255 animation: Option<crate::anim::AnimationState>,
256 #[cfg(feature = "image")]
257 image_protocol: ImageProtocol,
258 #[cfg(feature = "image")]
261 cell_px: (u16, u16),
262 #[cfg(feature = "image")]
265 image_scaled: Option<(u16, image::RgbaImage)>,
266 image_mode: bool,
267 image_no_color: bool,
268 #[cfg_attr(not(feature = "image"), allow(dead_code))]
269 image_format: String,
270 #[cfg(feature = "image")]
271 image_style: crate::image_render::AsciiStyle,
272 #[cfg_attr(not(feature = "image"), allow(dead_code))]
273 image_width: Option<usize>,
274 hex_group_size: usize,
277 prompt: Option<crate::prompt::ParsedPrompt>,
280 preprocess_failure: Option<String>,
283 file_index: Option<(usize, usize)>,
285 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
289 status_style: crate::ansi::Style,
293 status_flash: Option<(String, u32)>,
298 ticks_since_growth: u32,
303 case_mode: CaseMode,
307 hilite_search: bool,
311 quit_at_eof: QuitAtEof,
313 eof_hits: u8,
316 squeeze_blanks: bool,
320 header_lines: usize,
325 header_cols: usize,
326 page_size: Option<u16>,
330 render_state: crate::render::RenderState,
334 render_state_for: usize,
337 incsearch: bool,
341 status_column: bool,
345 status_marks: std::collections::HashMap<usize, char>,
349}
350
351impl Viewport {
352 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
353 let opts = RenderOpts { cols, ..RenderOpts::default() };
354 Self {
355 top_line: 0,
356 top_row: 0,
357 left_col: 0,
358 cols,
359 rows,
360 opts,
361 show_line_numbers: false,
362 source_label,
363 follow_mode: false,
364 live_mode: false,
365 prettify_label: None,
366 format_label: None,
367 filter: None,
368 grep: None,
369 or_groups: OrGroups::default(),
370 dim_mode: false,
371 visible_lines: Vec::new(),
372 visible_scanned: 0,
373 search: None,
374 display: None,
375 hex_mode: false,
376 #[cfg(feature = "image")]
377 image: None,
378 #[cfg(feature = "image")]
379 animation: None,
380 #[cfg(feature = "image")]
381 image_protocol: ImageProtocol::Ascii,
382 #[cfg(feature = "image")]
383 cell_px: (8, 16),
384 #[cfg(feature = "image")]
385 image_scaled: None,
386 image_mode: false,
387 image_no_color: false,
388 image_format: String::new(),
389 #[cfg(feature = "image")]
390 image_style: crate::image_render::AsciiStyle::Ramp,
391 image_width: None,
392 hex_group_size: 2,
393 prompt: None,
394 preprocess_failure: None,
395 file_index: None,
396 tag_active: None,
397 ansi_mode: crate::render::AnsiMode::Strict,
398 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
399 status_flash: None,
400 ticks_since_growth: 0,
401 case_mode: CaseMode::default(),
402 hilite_search: true,
403 quit_at_eof: QuitAtEof::default(),
404 eof_hits: 0,
405 squeeze_blanks: false,
406 header_lines: 0,
407 header_cols: 0,
408 page_size: None,
409 render_state: crate::render::RenderState::default(),
410 render_state_for: usize::MAX,
411 incsearch: false,
412 status_column: false,
413 status_marks: std::collections::HashMap::new(),
414 }
415 }
416
417 pub fn status_column(&self) -> bool { self.status_column }
418
419 pub fn set_status_column(&mut self, on: bool) { self.status_column = on; }
420
421 pub fn set_status_marks(&mut self, marks: std::collections::HashMap<usize, char>) {
425 self.status_marks = marks;
426 }
427
428 fn status_col_width(&self) -> u16 {
432 if self.status_column && self.ansi_mode != crate::render::AnsiMode::Raw { 1 } else { 0 }
433 }
434
435 fn status_cell(glyph: char) -> Cell {
438 Cell::Char { ch: glyph, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
439 }
440
441 fn status_glyph(&self, line_n: usize, has_match: bool) -> char {
445 if let Some(&ch) = self.status_marks.get(&line_n) {
446 ch
447 } else if has_match {
448 '*'
449 } else {
450 ' '
451 }
452 }
453
454 pub fn case_mode(&self) -> CaseMode { self.case_mode }
455
456 pub fn hilite_search(&self) -> bool { self.hilite_search }
457
458 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
459
460 pub fn incsearch(&self) -> bool { self.incsearch }
461
462 pub fn set_incsearch(&mut self, on: bool) { self.incsearch = on; }
463
464 pub fn top_row(&self) -> usize { self.top_row }
465
466 pub fn set_top(&mut self, line: usize, row: usize) {
468 self.top_line = line;
469 self.top_row = row;
470 }
471
472 pub fn incsearch_preview(&mut self, src: &dyn Source, idx: &mut LineIndex,
476 pattern: &str, direction: SearchDirection,
477 origin: (usize, usize)) {
478 if pattern.is_empty() { return; }
479 self.set_top(origin.0, origin.1);
480 if self.set_search(pattern.to_string(), direction).is_ok() {
481 self.search_repeat(src, idx, false);
482 }
483 }
484
485 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
486 self.quit_at_eof = mode;
487 self.eof_hits = 0;
488 }
489
490 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
491 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
492
493 pub fn set_header(&mut self, lines: usize, cols: usize) {
494 self.header_lines = lines;
495 self.header_cols = cols;
496 if self.top_line < self.header_lines {
499 self.top_line = self.header_lines;
500 }
501 }
502 pub fn header_lines(&self) -> usize { self.header_lines }
503 pub fn header_cols(&self) -> usize { self.header_cols }
504
505 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
506 pub fn page_size(&self) -> Option<u16> { self.page_size }
507
508 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
513 match self.quit_at_eof {
514 QuitAtEof::Off => false,
515 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
516 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
517 self.eof_hits = self.eof_hits.saturating_add(1);
518 self.eof_hits >= 2
519 }
520 _ => {
521 if !forward { self.eof_hits = 0; }
522 false
523 }
524 }
525 }
526
527 pub fn set_case_mode(&mut self, mode: CaseMode) {
531 self.case_mode = mode;
532 if let Some(s) = self.search.clone() {
533 let _ = self.set_search(s.raw, s.direction);
534 }
535 }
536
537 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
538 self.status_style = style;
539 }
540
541 pub fn status_style(&self) -> crate::ansi::Style {
542 self.status_style
543 }
544
545 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
549 self.status_flash = Some((msg.into(), ticks));
550 }
551
552 pub fn tick_flash(&mut self) {
555 if let Some((_, n)) = &mut self.status_flash {
556 *n = n.saturating_sub(1);
557 if *n == 0 {
558 self.status_flash = None;
559 }
560 }
561 }
562
563 pub fn note_growth(&mut self) {
565 self.ticks_since_growth = 0;
566 }
567
568 pub fn tick_idle(&mut self) {
571 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
572 }
573
574 pub fn is_idle(&self) -> bool {
577 self.ticks_since_growth >= 20
578 }
579
580 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
581 self.display = renderer;
582 }
583
584 pub fn set_hex_mode(&mut self, on: bool) {
585 self.hex_mode = on;
586 }
587
588 pub fn hex_mode(&self) -> bool {
590 self.hex_mode
591 }
592
593 #[cfg(feature = "image")]
594 fn reset_image_view(&mut self, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
595 self.image_format = format.to_string();
596 self.image_style = style;
597 self.image_width = width;
598 self.image_mode = true;
599 self.top_line = 0;
600 self.top_row = 0;
601 self.image_scaled = None;
602 }
603
604 #[cfg(feature = "image")]
605 pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
606 self.reset_image_view(format, style, width);
607 self.image = Some(img);
608 self.animation = None;
609 }
610
611 #[cfg(feature = "image")]
612 pub fn set_animation(&mut self, anim: crate::image_render::Animation, format: &str,
613 style: crate::image_render::AsciiStyle, width: Option<usize>) {
614 self.reset_image_view(format, style, width);
615 self.image = None;
616 self.animation = Some(crate::anim::AnimationState::new(anim.frames, anim.loop_count));
617 }
618
619 #[cfg(feature = "image")]
620 pub fn has_animation(&self) -> bool { self.animation.is_some() }
621
622 #[cfg(feature = "image")]
623 fn current_image(&self) -> Option<&image::RgbaImage> {
624 match &self.animation {
625 Some(a) => Some(a.current_frame()),
626 None => self.image.as_ref(),
627 }
628 }
629
630 #[cfg(feature = "image")]
631 pub fn tick(&mut self, dt: std::time::Duration) -> bool {
632 if let Some(a) = &mut self.animation {
633 if a.advance(dt) { self.image_scaled = None; return true; }
634 }
635 false
636 }
637
638 #[cfg(feature = "image")]
639 pub fn anim_deadline(&self) -> Option<std::time::Duration> {
640 self.animation.as_ref().and_then(|a| a.next_deadline())
641 }
642
643 #[cfg(feature = "image")]
644 pub fn anim_toggle_pause(&mut self) {
645 if let Some(a) = &mut self.animation { a.toggle_pause(); self.image_scaled = None; }
646 }
647
648 #[cfg(feature = "image")]
649 pub fn anim_step(&mut self, delta: i32) {
650 if let Some(a) = &mut self.animation { a.step(delta); self.image_scaled = None; }
651 }
652
653 #[cfg(feature = "image")]
654 pub fn anim_restart(&mut self) {
655 if let Some(a) = &mut self.animation { a.restart(); self.image_scaled = None; }
656 }
657
658 #[cfg(feature = "image")]
659 fn anim_badge(&self) -> String {
660 match &self.animation {
661 Some(a) => {
662 let (i, n) = (a.frame_index() + 1, a.frame_count());
663 if a.is_finished() { format!(" [done {n}/{n}]") }
664 else if a.is_playing() { format!(" [play {i}/{n}]") }
665 else { format!(" [pause {i}/{n}]") }
666 }
667 None => String::new(),
668 }
669 }
670
671 pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
672
673 #[cfg(feature = "image")]
674 pub fn set_image_protocol(&mut self, proto: ImageProtocol, cell_px: Option<(u16, u16)>) {
675 self.image_protocol = proto;
676 if let Some(c) = cell_px {
677 if c.0 > 0 && c.1 > 0 { self.cell_px = c; }
678 }
679 self.image_scaled = None;
680 }
681
682 #[cfg(feature = "image")]
683 pub fn image_protocol(&self) -> ImageProtocol { self.image_protocol }
684
685 pub fn image_mode(&self) -> bool { self.image_mode }
686
687 #[cfg(feature = "image")]
688 fn image_cols(&self) -> u16 {
689 self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
690 }
691
692 #[cfg(feature = "image")]
693 pub fn image_total_rows(&self) -> usize {
694 match self.current_image() {
695 Some(img) => {
696 let (w, h) = img.dimensions();
697 if self.image_protocol != ImageProtocol::Ascii {
698 protocol_occupied_rows(w, h, self.cols, self.cell_px, self.image_width)
699 } else {
700 crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
701 }
702 }
703 None => 0,
704 }
705 }
706
707 #[cfg(feature = "image")]
708 pub fn is_at_bottom_image(&self) -> bool {
709 let body = self.body_rows() as usize;
710 self.top_line + body >= self.image_total_rows()
711 }
712
713 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
716 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
717 self.hex_group_size = bytes_per_group;
718 }
719 }
720
721 pub fn hex_group_size(&self) -> usize {
723 self.hex_group_size
724 }
725
726 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
727 self.prompt = prompt;
728 }
729
730 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
731 self.preprocess_failure = msg;
732 }
733
734 pub fn set_file_index(&mut self, current: usize, total: usize) {
735 self.file_index = if total > 1 {
736 Some((current, total))
737 } else {
738 None
739 };
740 }
741
742 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
743 self.tag_active = info;
744 }
745
746 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
747 self.ansi_mode = mode;
748 }
749
750 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
751 self.ansi_mode
752 }
753
754 pub fn set_source_label(&mut self, label: String) {
755 self.source_label = label;
756 }
757
758 pub fn source_label_clone(&self) -> String {
759 self.source_label.clone()
760 }
761
762 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
767 let range = idx.line_range(line_n, src);
768 let raw = src.bytes(range);
769 if let Some(r) = self.display.as_ref() {
770 if let Some(rendered) = r.render_line(&raw) {
771 return std::borrow::Cow::Owned(rendered.into_bytes());
772 }
773 }
774 raw
775 }
776
777 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
781 let compiled = self.case_mode.apply_to_pattern(&raw);
782 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
783 self.search = Some(SearchState { raw, regex, direction });
784 Ok(())
785 }
786
787 pub fn clear_search(&mut self) { self.search = None; }
788
789 pub fn search_active(&self) -> bool { self.search.is_some() }
790
791 pub fn search_direction(&self) -> SearchDirection {
792 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
793 }
794
795 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
799 if idx.records_mode() {
800 self.search_repeat_records(src, idx, reverse)
801 } else {
802 self.search_repeat_lines(src, idx, reverse)
803 }
804 }
805
806 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
808 let Some(s) = self.search.as_ref() else { return false; };
809 let forward = matches!(
810 (s.direction, reverse),
811 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
812 );
813 idx.extend_to_end(src);
814 let pattern = s.regex.clone();
815 if self.hide_mode() {
816 self.extend_visible_lines(idx, src);
817 self.search_step_in_visible(&pattern, src, idx, forward)
818 } else {
819 self.search_step_in_logical(&pattern, src, idx, forward)
820 }
821 }
822
823 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
827 let Some(s) = self.search.as_ref() else { return false; };
828 let forward = matches!(
829 (s.direction, reverse),
830 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
831 );
832 let pattern = s.regex.clone();
833 idx.extend_to_end(src);
834
835 let total = idx.record_count();
836 if total == 0 { return false; }
837
838 let cur_record = idx.line_to_record(self.top_line);
839
840 let range: Box<dyn Iterator<Item = usize>> = if forward {
841 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
842 } else {
843 let earlier: Vec<usize> = (0..cur_record).rev().collect();
844 let later: Vec<usize> = (cur_record..total).rev().collect();
845 Box::new(earlier.into_iter().chain(later))
846 };
847
848 for r in range {
849 let bytes = idx.record_bytes_stripped(r, src);
850 let text = String::from_utf8_lossy(&bytes);
851 if pattern.is_match(&text) {
852 let line_range = idx.record_line_range(r);
853 self.top_line = line_range.start;
854 self.top_row = 0;
855 return true;
856 }
857 }
858 false
859 }
860
861 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
862 let display = self.line_display_bytes(src, idx, line_n);
867 let bytes = crate::ansi::strip_sgr(&display);
868 match std::str::from_utf8(&bytes) {
869 Ok(s) => pattern.is_match(s),
870 Err(_) => false,
871 }
872 }
873
874 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
875 let total = idx.line_count();
876 if total == 0 { return false; }
877 let start = self.top_line;
878 for offset in 1..=total {
881 let line_n = if forward {
882 (start + offset) % total
883 } else {
884 (start + total - offset) % total
885 };
886 if self.line_matches(pattern, src, idx, line_n) {
887 self.top_line = line_n;
888 self.top_row = 0;
889 return true;
890 }
891 }
892 false
893 }
894
895 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
896 let total = self.visible_lines.len();
897 if total == 0 { return false; }
898 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
900 for offset in 1..=total {
901 let visible_idx = if forward {
902 (cur + offset) % total
903 } else {
904 (cur + total - offset) % total
905 };
906 let line_n = self.visible_lines[visible_idx];
907 if self.line_matches(pattern, src, idx, line_n) {
908 self.top_line = line_n;
909 self.top_row = 0;
910 return true;
911 }
912 }
913 false
914 }
915
916 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
917 self.filter = filter;
918 self.visible_lines.clear();
919 self.visible_scanned = 0;
920 self.top_line = 0;
922 self.top_row = 0;
923 self.left_col = 0;
924 }
925
926 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
927 self.grep = grep;
928 self.visible_lines.clear();
929 self.visible_scanned = 0;
930 self.top_line = 0;
931 self.top_row = 0;
932 self.left_col = 0;
933 }
934
935 pub fn set_or_groups(&mut self, or_groups: OrGroups) {
936 self.or_groups = or_groups;
937 self.visible_lines.clear();
938 self.visible_scanned = 0;
939 self.top_line = 0;
940 self.top_row = 0;
941 self.left_col = 0;
942 }
943
944 pub fn or_active(&self) -> bool {
945 self.or_groups.is_active()
946 }
947
948 pub fn grep_active(&self) -> bool { self.grep.is_some() }
949
950 pub fn set_dim_mode(&mut self, on: bool) {
951 self.dim_mode = on;
952 self.visible_lines.clear();
956 self.visible_scanned = 0;
957 }
958
959 pub fn filter_active(&self) -> bool { self.filter.is_some() }
960
961 pub fn dim_mode(&self) -> bool { self.dim_mode }
962
963 fn hide_mode(&self) -> bool {
964 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
965 && !self.dim_mode
966 }
967
968 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
973 if !self.hide_mode() {
974 return;
975 }
976 if idx.records_mode() {
977 self.extend_visible_lines_records(idx, src);
978 } else {
979 self.extend_visible_lines_per_line(idx, src);
980 }
981 }
982
983 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
985 let total = idx.line_count();
986 while self.visible_scanned < total {
987 let line_n = self.visible_scanned;
988 let bytes = idx.line_bytes_stripped(line_n, src);
989 if self.line_passes(&bytes) {
990 self.visible_lines.push(line_n);
991 }
992 self.visible_scanned += 1;
993 }
994 }
995
996 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
1003 self.visible_lines.clear();
1004 self.visible_scanned = 0; let total_records = idx.record_count();
1006 for r in 0..total_records {
1007 if self.record_passes(idx, src, r) {
1008 for line_n in idx.record_line_range(r) {
1009 self.visible_lines.push(line_n);
1010 }
1011 }
1012 }
1013 }
1014
1015 fn line_passes(&self, line: &[u8]) -> bool {
1021 let filter_ok = match self.filter.as_ref() {
1022 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
1023 None => true,
1024 };
1025 let grep_ok = match self.grep.as_ref() {
1026 Some(g) => g.matches(line),
1027 None => true,
1028 };
1029 filter_ok && grep_ok && self.or_groups.matches_line(line)
1030 }
1031
1032 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
1040 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
1041 let bytes = if need {
1042 Some(idx.record_bytes_stripped(r, src))
1043 } else {
1044 None
1045 };
1046 let filter_ok = match self.filter.as_ref() {
1047 Some(f) => matches!(
1048 f.evaluate_record(bytes.as_deref().unwrap()),
1049 FilterMatch::Matched,
1050 ),
1051 None => true,
1052 };
1053 let grep_ok = match self.grep.as_ref() {
1054 Some(g) => g.matches(bytes.as_deref().unwrap()),
1055 None => true,
1056 };
1057 let or_ok = if self.or_groups.is_active() {
1058 self.or_groups.matches_record(bytes.as_deref().unwrap())
1059 } else {
1060 true
1061 };
1062 filter_ok && grep_ok && or_ok
1063 }
1064
1065 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
1069 if !self.dim_mode {
1070 return false;
1071 }
1072 if idx.records_mode() {
1073 let r = idx.line_to_record(line_n);
1074 !self.record_passes(idx, src, r)
1075 } else {
1076 let bytes = idx.line_bytes_stripped(line_n, src);
1077 !self.line_passes(&bytes)
1078 }
1079 }
1080
1081 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
1089 let body_rows = self.body_rows() as usize;
1090 if self.hide_mode() && !self.visible_lines.is_empty() {
1091 let cur = self
1092 .visible_lines
1093 .iter()
1094 .position(|&l| l >= self.top_line)
1095 .unwrap_or(self.visible_lines.len().saturating_sub(1));
1096 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
1097 return self.visible_lines[last_pos];
1098 }
1099 let total = idx.line_count();
1100 if total == 0 {
1101 return self.top_line;
1102 }
1103 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
1104 }
1105
1106 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
1107
1108 pub fn follow_mode(&self) -> bool { self.follow_mode }
1109
1110 pub fn suspend_follow_if(&mut self, flag: bool) {
1115 if flag {
1116 self.follow_mode = false;
1117 }
1118 }
1119
1120 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
1121
1122 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
1123
1124 pub fn live_mode(&self) -> bool { self.live_mode }
1125
1126 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
1127
1128 pub fn set_prettify_label(&mut self, label: Option<String>) {
1131 self.prettify_label = label;
1132 }
1133
1134 pub fn set_format_label(&mut self, label: Option<String>) {
1137 self.format_label = label;
1138 }
1139
1140 pub fn invalidate_filter_cache(&mut self) {
1145 self.visible_lines.clear();
1146 self.visible_scanned = 0;
1147 }
1148
1149 pub fn clamp_top_line(&mut self, line_count: usize) {
1152 if line_count == 0 {
1153 self.top_line = 0;
1154 self.top_row = 0;
1155 } else if self.top_line >= line_count {
1156 self.top_line = line_count - 1;
1157 self.top_row = 0;
1158 }
1159 }
1160
1161 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
1165 #[cfg(feature = "image")]
1166 if self.image_mode {
1167 return self.is_at_bottom_image();
1168 }
1169 if self.hide_mode() {
1170 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
1174 } else {
1175 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
1179 }
1180 }
1181
1182 fn gutter_width(&self, idx: &LineIndex) -> u16 {
1184 if !self.show_line_numbers { return 0; }
1185 let n = idx.line_count().max(1);
1186 let digits = (n as f64).log10().floor() as u16 + 1;
1187 digits + 1
1188 }
1189
1190 fn render_opts(&self, gutter: u16) -> RenderOpts {
1191 let mut o = self.opts.clone();
1192 o.cols = self.cols.saturating_sub(self.status_col_width() + gutter);
1195 o.mode = self.ansi_mode;
1196 o.left_col = self.left_col; o
1198 }
1199
1200 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1201 #[cfg(feature = "image")]
1202 if self.image_mode {
1203 return self.frame_image();
1204 }
1205 if self.hex_mode {
1206 return self.frame_hex(src);
1207 }
1208 let body_rows = self.body_rows() as usize;
1209 idx.extend_to_line(self.top_line + body_rows + 1, src);
1210
1211 if self.left_col > 0 && self.hscroll_active() {
1214 let gutter_for_clamp = self.status_col_width() + self.gutter_width(idx);
1215 let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1216 let mut width_opts = self.opts.clone();
1219 width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1220 width_opts.mode = self.ansi_mode;
1221 width_opts.left_col = 0;
1222 let mut widest = 0usize;
1223 let total_lines_for_clamp = idx.line_count();
1224 if self.hide_mode() {
1225 let hide_pos = self.visible_lines.iter()
1226 .position(|&l| l >= self.top_line)
1227 .unwrap_or(self.visible_lines.len());
1228 let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1229 for vi in hide_pos..end_vi {
1230 let ln = self.visible_lines[vi];
1231 let bytes = self.line_display_bytes(src, idx, ln);
1232 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1233 }
1234 } else {
1235 let start = self.top_line.max(self.header_lines);
1236 let end = (start + body_rows).min(total_lines_for_clamp);
1237 for ln in start..end {
1238 let bytes = self.line_display_bytes(src, idx, ln);
1239 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1240 }
1241 }
1242 self.left_col = self.left_col.min(widest.saturating_sub(avail));
1243 }
1244
1245 let gutter = self.gutter_width(idx);
1246 let scol = self.status_col_width();
1247 let r_opts = self.render_opts(gutter);
1248
1249 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1253 reconstruct_render_state(src, idx, self.top_line)
1254 } else {
1255 crate::render::RenderState::default()
1256 };
1257 self.render_state = render_state.clone();
1259 self.render_state_for = self.top_line;
1260
1261 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1262 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1263 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1264 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1265 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1266 let hide = self.hide_mode();
1268 let total_lines = idx.line_count();
1269
1270 let header_rows = if !hide && !raw_passthrough {
1277 self.header_lines.min(body_rows).min(total_lines)
1278 } else {
1279 0
1280 };
1281 if header_rows > 0 {
1282 for hl in 0..header_rows {
1283 let raw = src.bytes(idx.line_range(hl, src));
1284 let display_bytes = if let Some(r) = self.display.as_ref() {
1285 match r.render_line(&raw) {
1286 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1287 None => raw.clone(),
1288 }
1289 } else {
1290 raw.clone()
1291 };
1292 let rows = render_line(&display_bytes, &r_opts, None);
1293 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1294 let mut v = Vec::with_capacity(self.cols as usize);
1295 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1296 v
1297 });
1298 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1299 if scol > 0 {
1300 let matched = self.search.as_ref()
1301 .is_some_and(|s| !find_row_highlights(&content_row, &s.regex).is_empty());
1302 let glyph = self.status_glyph(hl, matched);
1303 full.push(Self::status_cell(glyph));
1304 }
1305 if gutter > 0 {
1306 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1307 for c in label.chars() {
1308 full.push(Cell::Char {
1309 ch: c,
1310 width: 1,
1311 style: crate::ansi::Style::default(),
1312 hyperlink: None,
1313 });
1314 }
1315 }
1316 full.append(&mut content_row);
1317 body.push(full);
1318 row_styles.push(RowStyle::Normal);
1319 highlights.push(Vec::new());
1320 raw_rows.push(None);
1321 }
1322 }
1323
1324 let mut hide_pos = if hide {
1326 self.visible_lines
1327 .iter()
1328 .position(|&l| l >= self.top_line)
1329 .unwrap_or(self.visible_lines.len())
1330 } else {
1331 0
1332 };
1333 let mut line_n = if hide {
1334 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1335 } else {
1336 self.top_line.max(self.header_lines)
1339 };
1340 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1341
1342 while body.len() < body_rows {
1343 if line_n >= total_lines {
1344 let mut row = Vec::with_capacity(self.cols as usize);
1345 if scol > 0 {
1346 for _ in 0..scol { row.push(Cell::Empty); }
1347 }
1348 if gutter > 0 {
1349 for _ in 0..gutter { row.push(Cell::Empty); }
1350 }
1351 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1352 body.push(row);
1353 row_styles.push(RowStyle::Normal);
1354 highlights.push(Vec::new());
1355 raw_rows.push(None);
1356 line_n += 1;
1357 continue;
1358 }
1359 let raw = src.bytes(idx.line_range(line_n, src));
1362 if self.squeeze_blanks && line_is_blank(&raw) {
1367 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1368 let prev = src.bytes(idx.line_range(p, src));
1369 line_is_blank(&prev)
1370 });
1371 if prev_blank {
1372 line_n += 1;
1373 continue;
1374 }
1375 }
1376 let display_bytes = if let Some(r) = self.display.as_ref() {
1377 match r.render_line(&raw) {
1378 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1379 None => raw.clone(),
1380 }
1381 } else {
1382 raw.clone()
1383 };
1384 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1385 Some(&mut render_state)
1386 } else {
1387 None
1388 };
1389 let rows = render_line(&display_bytes, &r_opts, state_arg);
1390 let style = if self.filter.is_some() || self.grep.is_some() {
1391 if self.dim_mode {
1392 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1393 } else {
1394 RowStyle::Normal
1396 }
1397 } else {
1398 RowStyle::Normal
1399 };
1400
1401 let mut first_emitted_for_this_line = true;
1402 let mut status_first_row_idx: Option<usize> = None;
1407 let mut line_matched = false;
1408 for (i, mut content_row) in rows.into_iter().enumerate() {
1409 if i < skip { continue; }
1410 if body.len() >= body_rows { break; }
1411 if scol > 0 && !line_matched {
1418 if let Some(s) = self.search.as_ref() {
1419 if !find_row_highlights(&content_row, &s.regex).is_empty() {
1420 line_matched = true;
1421 }
1422 }
1423 }
1424 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1425 if scol > 0 {
1426 if status_first_row_idx.is_none() {
1427 status_first_row_idx = Some(body.len());
1428 }
1429 full.push(Self::status_cell(' '));
1432 }
1433 if gutter > 0 {
1434 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1435 for c in label.chars() {
1436 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1437 }
1438 }
1439 full.append(&mut content_row);
1440 if self.left_col > 0 && !self.opts.wrap {
1445 let marker_col = (scol + gutter) as usize;
1446 if let Some(cell) = full.get_mut(marker_col) {
1447 *cell = Cell::Char {
1448 ch: '<',
1449 width: 1,
1450 style: crate::ansi::Style { dim: true, ..Default::default() },
1451 hyperlink: None,
1452 };
1453 }
1454 }
1455 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1459 find_row_highlights(&full, &s.regex)
1460 } else {
1461 Vec::new()
1462 };
1463 body.push(full);
1464 row_styles.push(style);
1465 highlights.push(row_highlights);
1466 if raw_passthrough {
1467 if first_emitted_for_this_line {
1468 raw_rows.push(Some(raw.to_vec()));
1473 first_emitted_for_this_line = false;
1474 } else {
1475 raw_rows.push(Some(Vec::new()));
1476 }
1477 } else {
1478 raw_rows.push(None);
1479 }
1480 }
1481 if let Some(fi) = status_first_row_idx {
1485 let glyph = self.status_glyph(line_n, line_matched);
1486 if glyph != ' ' {
1487 if let Some(cell) = body[fi].first_mut() {
1488 *cell = Self::status_cell(glyph);
1489 }
1490 }
1491 }
1492 skip = 0;
1493 if hide {
1495 hide_pos += 1;
1496 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1497 } else {
1498 line_n += 1;
1499 }
1500 }
1501
1502 self.render_state_for = usize::MAX;
1505
1506 let status = self.format_status(idx, src);
1507 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1508 }
1509
1510 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1511 if let Some(p) = self.prompt.as_ref() {
1512 let ctx = self.build_prompt_context(idx, src);
1513 return p.render(&ctx);
1514 }
1515 let body_rows = self.body_rows() as usize;
1516 let total = idx.line_count();
1517 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1520 let visible_total = self.visible_lines.len();
1521 let cur = self
1523 .visible_lines
1524 .iter()
1525 .position(|&l| l >= self.top_line)
1526 .unwrap_or(visible_total);
1527 let top = cur + 1;
1528 let bottom = (cur + body_rows).min(visible_total.max(1));
1529 let total_str = if src.is_complete() {
1530 format!("{visible_total}/{total}")
1531 } else {
1532 format!("{visible_total}/{total}+")
1533 };
1534 (top, bottom, visible_total, total_str)
1535 } else {
1536 let top = self.top_line + 1;
1537 let bottom = (self.top_line + body_rows).min(total.max(1));
1538 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1539 (top, bottom, total, total_str)
1540 };
1541 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1542 let bottom_line = self.bottom_visible_line(idx);
1546 let (line_prefix, records_block) = if idx.records_mode() {
1547 let line_total = idx.line_count();
1548 let rec_total = idx.record_count();
1549 let rec_block = if line_total == 0 || rec_total == 0 {
1550 format!("R0-0/{}", rec_total)
1551 } else {
1552 let rec_top = idx.line_to_record(self.top_line) + 1;
1553 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1554 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1555 (rec_top, rec_top)
1559 } else {
1560 (rec_top, rec_bottom)
1561 };
1562 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1563 };
1564 ("L", Some(rec_block))
1565 } else {
1566 ("", None)
1567 };
1568 let middle = match records_block {
1569 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1570 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1571 };
1572 let label_with_index = match self.file_index {
1573 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1574 None => self.source_label.clone(),
1575 };
1576 let mut s = format!("{} {}", label_with_index, middle);
1577 if !self.hide_mode() && self.top_row > 0 {
1582 let line_rows = if total > 0 {
1583 let bytes = self.line_display_bytes(src, idx, self.top_line);
1584 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1585 } else { 1 };
1586 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1587 }
1588 if self.left_col > 0 {
1589 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1590 }
1591 if let Some(f) = self.filter.as_ref() {
1592 s.push_str(&format!(" [{}]", f.format_name));
1593 }
1594 if self.grep.is_some() {
1595 s.push_str(" [grep]");
1596 }
1597 if self.or_groups.is_active() {
1598 s.push_str(" [or]");
1599 }
1600 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1601 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1602 }
1603 if let Some(sr) = self.search.as_ref() {
1604 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1605 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1606 }
1607 if let Some(label) = self.prettify_label.as_ref() {
1608 s.push_str(&format!(" [pretty:{label}]"));
1609 }
1610 if self.live_mode { s.push_str(" (L)"); }
1611 if self.follow_mode {
1612 if let Some((msg, _)) = self.status_flash.as_ref() {
1613 s.push_str(" ");
1614 s.push_str(msg);
1615 } else if self.is_idle() {
1616 s.push_str(" (F idle)");
1617 } else {
1618 s.push_str(" (F)");
1619 }
1620 }
1621 if let Some(msg) = self.preprocess_failure.as_ref() {
1622 let first_line = msg.lines().next().unwrap_or("");
1623 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1624 }
1625 let tag_suffix = match &self.tag_active {
1626 Some((name, cur, total)) if *total > 1 => {
1627 format!(" [tag: {name} ({cur}/{total})]")
1628 }
1629 _ => String::new(),
1630 };
1631 s.push_str(&tag_suffix);
1632 let used = s.chars().count();
1635 let hint = ":help";
1636 if (self.cols as usize) > used + 1 + hint.chars().count() {
1637 let pad = self.cols as usize - used - hint.chars().count();
1638 s.push_str(&" ".repeat(pad));
1639 s.push_str(hint);
1640 } else {
1641 s.push(' ');
1642 s.push_str(hint);
1643 }
1644 s
1645 }
1646
1647 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1648 use crate::prompt::PromptContext;
1649
1650 let body_rows = self.body_rows() as usize;
1651 let total = idx.line_count();
1652 let top = self.top_line + 1;
1653 let bottom = (self.top_line + body_rows).min(total.max(1));
1654 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1655 let bottom_line = self.bottom_visible_line(idx);
1656
1657 let records_mode = idx.records_mode();
1658 let (rec_top, rec_bottom, rec_total) = if records_mode {
1659 let rt = idx.line_to_record(self.top_line) + 1;
1660 let rb_raw = idx.line_to_record(bottom_line) + 1;
1661 let rb = if rb_raw < rt { rt } else { rb_raw };
1662 (rt, rb, idx.record_count())
1663 } else {
1664 (0, 0, 0)
1665 };
1666
1667 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1668 let line_rows = if total > 0 {
1669 let bytes = self.line_display_bytes(src, idx, self.top_line);
1670 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1671 } else { 1 };
1672 format!("+{}/{}", self.top_row, line_rows)
1673 } else {
1674 String::new()
1675 };
1676
1677 let col_offset = if self.left_col > 0 { format!(" \u{00bb}{}", self.left_col) } else { String::new() };
1678
1679 let format_tag = self.format_label.as_ref()
1680 .map(|n| format!(" [{}]", n))
1681 .unwrap_or_default();
1682 let filter_tag = self.filter.as_ref()
1683 .map(|f| format!(" [{}]", f.format_name))
1684 .unwrap_or_default();
1685 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1686 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1687 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1688 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1689 } else {
1690 String::new()
1691 };
1692 let search_tag = self.search.as_ref()
1693 .map(|s| {
1694 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1695 format!(" [{}{}]", p, s.raw)
1696 })
1697 .unwrap_or_default();
1698 let pretty_tag = self.prettify_label.as_ref()
1699 .map(|l| format!(" [pretty:{l}]"))
1700 .unwrap_or_default();
1701 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1702 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1703 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1704 .map(|msg| {
1705 let first_line = msg.lines().next().unwrap_or("");
1706 format!(" [preprocess-failed: {}]", first_line)
1707 })
1708 .unwrap_or_default();
1709
1710 let file_index_tag = match self.file_index {
1711 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1712 None => String::new(),
1713 };
1714
1715 let tag_tag = match &self.tag_active {
1716 Some((name, cur, total)) if *total > 1 => {
1717 format!(" [tag: {name} ({cur}/{total})]")
1718 }
1719 _ => String::new(),
1720 };
1721
1722 PromptContext {
1723 label: self.source_label.clone(),
1724 top,
1725 bottom,
1726 total,
1727 pct: pct.min(100) as u8,
1728 rec_top,
1729 rec_bottom,
1730 rec_total,
1731 records_mode,
1732 wrap_offset,
1733 col_offset,
1734 format_tag,
1735 filter_tag,
1736 grep_tag,
1737 or_tag,
1738 hide_tag,
1739 search_tag,
1740 pretty_tag,
1741 live_tag,
1742 follow_tag,
1743 preprocess_failed_tag,
1744 file_index_tag,
1745 tag_tag,
1746 }
1747 }
1748
1749 fn frame_hex(&self, src: &dyn Source) -> Frame {
1750 use crate::hex::format_hex_row;
1751 use crate::render::{render_line, Cell, RenderOpts};
1752
1753 let body_rows = self.rows.saturating_sub(1) as usize;
1754 let total_bytes = src.len();
1755 let total_hex_rows = total_bytes.div_ceil(16);
1756
1757 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1758 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1759 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1760
1761 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false, left_col: 0, tab_stops: None };
1762
1763 for row_idx in 0..body_rows {
1764 let hex_row = self.top_line + row_idx;
1765 if hex_row >= total_hex_rows {
1766 body.push(vec![Cell::Empty; self.cols as usize]);
1767 } else {
1768 let offset = hex_row * 16;
1769 let end = (offset + 16).min(total_bytes);
1770 let bytes_cow = src.bytes(offset..end);
1771 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1772 let rows = render_line(text.as_bytes(), &opts, None);
1773 body.push(rows.into_iter().next().unwrap_or_else(|| {
1774 vec![Cell::Empty; self.cols as usize]
1775 }));
1776 }
1777 row_styles.push(RowStyle::Normal);
1778 highlights.push(Vec::new());
1779 }
1780
1781 let status = self.format_status_hex(src);
1782 let raw_rows = vec![None; body.len()];
1783 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1784 }
1785
1786 fn format_status_hex(&self, src: &dyn Source) -> String {
1787 let total_bytes = src.len();
1788 let body_rows = self.rows.saturating_sub(1) as usize;
1789 let top_byte = self.top_line * 16;
1791 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1794 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1795 let label_with_index = match self.file_index {
1796 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1797 None => self.source_label.clone(),
1798 };
1799 let tag_suffix = match &self.tag_active {
1800 Some((name, cur, total)) if *total > 1 => {
1801 format!(" [tag: {name} ({cur}/{total})]")
1802 }
1803 _ => String::new(),
1804 };
1805 format!(
1806 "{} off {}-{}/{} {}% [hex]{}",
1807 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1808 )
1809 }
1810
1811 #[cfg(feature = "image")]
1812 fn frame_image(&mut self) -> Frame {
1813 use crate::render::Cell;
1814 if self.image_protocol != ImageProtocol::Ascii {
1815 return self.frame_image_protocol();
1816 }
1817 let body_rows = self.body_rows() as usize;
1818 let cols = self.cols as usize;
1819 let img = match self.current_image() {
1820 Some(i) => i,
1821 None => {
1822 let body = vec![vec![Cell::Empty; cols]; body_rows];
1823 return Frame {
1824 body,
1825 row_styles: vec![RowStyle::Normal; body_rows],
1826 highlights: vec![Vec::new(); body_rows],
1827 status: self.image_format.clone(),
1828 status_style: self.status_style,
1829 raw_rows: vec![None; body_rows],
1830 image_blob: None,
1831 };
1832 }
1833 };
1834 let color = !self.image_no_color;
1835 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1836 let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1837 let max_off = grid_w.saturating_sub(cols);
1838 if self.left_col > max_off { self.left_col = max_off; }
1839 let off = self.left_col;
1840 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1841 for r in 0..body_rows {
1842 let gi = self.top_line + r;
1843 if gi < grid.len() {
1844 let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1845 while row.len() < cols { row.push(Cell::Empty); }
1846 body.push(row);
1847 } else {
1848 body.push(vec![Cell::Empty; cols]);
1849 }
1850 }
1851 let status = self.format_status_image(grid.len());
1852 Frame {
1853 body,
1854 row_styles: vec![RowStyle::Normal; body_rows],
1855 highlights: vec![Vec::new(); body_rows],
1856 status,
1857 status_style: self.status_style,
1858 raw_rows: vec![None; body_rows],
1859 image_blob: None,
1860 }
1861 }
1862
1863 #[cfg(feature = "image")]
1864 fn format_status_image(&self, total_rows: usize) -> String {
1865 let body = self.body_rows() as usize;
1866 let top = self.top_line + 1;
1867 let bottom = (self.top_line + body).min(total_rows.max(1));
1868 let dims = self.current_image().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1869 let mut s = format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1870 if self.left_col > 0 {
1871 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1872 }
1873 s.push_str(&self.anim_badge());
1874 s
1875 }
1876
1877 #[cfg(feature = "image")]
1878 fn frame_image_protocol(&mut self) -> Frame {
1879 use crate::render::Cell;
1880 let body_rows = self.body_rows() as usize;
1881 let cols = self.cols as usize;
1882 let status_style = self.status_style;
1883 let blank = |status: String, blob: Option<Vec<u8>>| Frame {
1884 body: vec![vec![Cell::Empty; cols]; body_rows],
1885 row_styles: vec![RowStyle::Normal; body_rows],
1886 highlights: vec![Vec::new(); body_rows],
1887 status,
1888 status_style,
1889 raw_rows: vec![None; body_rows],
1890 image_blob: blob,
1891 };
1892 let (iw, ih) = match self.current_image() {
1893 Some(i) => i.dimensions(),
1894 None => return blank(self.image_format.clone(), None),
1895 };
1896 let ch = self.cell_px.1.max(1) as u32;
1897 let (scaled_w, scaled_h) = protocol_scaled_dims(iw, ih, self.cols, self.cell_px, self.image_width);
1898
1899 let need = self.image_scaled.as_ref().map(|(c, _)| *c != scaled_w as u16).unwrap_or(true);
1901 if need {
1902 let scaled = {
1903 let src = self.current_image().unwrap();
1904 image::imageops::resize(src, scaled_w, scaled_h, image::imageops::FilterType::Triangle)
1905 };
1906 self.image_scaled = Some((scaled_w as u16, scaled));
1907 }
1908
1909 let total_rows = protocol_occupied_rows(iw, ih, self.cols, self.cell_px, self.image_width);
1910 let max_top = total_rows.saturating_sub(body_rows);
1911 if self.top_line > max_top { self.top_line = max_top; }
1912 self.left_col = 0; let y0 = (self.top_line as u32 * ch).min(scaled_h);
1915 let band_h = ((body_rows as u32) * ch).min(scaled_h - y0).max(1);
1916 let scaled = &self.image_scaled.as_ref().unwrap().1;
1917 let band = image::imageops::crop_imm(scaled, 0, y0, scaled_w, band_h).to_image();
1918 let blob = match self.image_protocol {
1919 ImageProtocol::Kitty => crate::image_protocol::encode_kitty(&band),
1920 ImageProtocol::Sixel => crate::image_protocol::encode_sixel(&band),
1921 ImageProtocol::Ascii => unreachable!("frame_image_protocol only entered for non-Ascii"),
1922 };
1923 let status = self.format_status_image_protocol(total_rows);
1924 blank(status, Some(blob))
1925 }
1926
1927 #[cfg(feature = "image")]
1928 fn format_status_image_protocol(&self, total_rows: usize) -> String {
1929 let body = self.body_rows() as usize;
1930 let top = self.top_line + 1;
1931 let bottom = (self.top_line + body).min(total_rows.max(1));
1932 let proto = match self.image_protocol {
1933 ImageProtocol::Kitty => "kitty",
1934 ImageProtocol::Sixel => "sixel",
1935 ImageProtocol::Ascii => "ascii",
1936 };
1937 let dims = self.current_image().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1938 format!("{} {} {} [{}] rows {}-{}/{}{}", self.source_label, dims, self.image_format, proto, top, bottom, total_rows, self.anim_badge())
1939 }
1940
1941 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1946 if delta == 0 { return; }
1947 #[cfg(feature = "image")]
1948 if self.image_mode {
1949 self.scroll_lines(delta, src, idx);
1950 return;
1951 }
1952 if self.hide_mode() {
1953 self.extend_visible_lines(idx, src);
1957 let n = self.visible_lines.len();
1958 if n == 0 {
1959 self.top_line = 0;
1960 self.top_row = 0;
1961 return;
1962 }
1963 let vi = self
1964 .visible_lines
1965 .iter()
1966 .position(|&l| l >= self.top_line)
1967 .unwrap_or(n - 1);
1968 if delta > 0 {
1969 let target = (vi + delta as usize).min(n - 1);
1970 self.top_line = self.visible_lines[target];
1971 self.top_row = 0;
1972 } else {
1973 let back = (-delta) as usize;
1974 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1975 let extra_back = back.saturating_sub(consumed_for_snap);
1976 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1977 self.top_row = 0;
1978 }
1979 return;
1980 }
1981 if delta > 0 {
1982 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1983 let total = idx.line_count();
1984 if total == 0 { return; }
1985 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1986 self.top_line = target;
1987 self.top_row = 0;
1988 } else {
1989 let back = (-delta) as usize;
1990 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1995 let extra_back = back.saturating_sub(consumed_for_snap);
1996 self.top_line = self.top_line.saturating_sub(extra_back);
1997 self.top_row = 0;
1998 }
1999 }
2000
2001 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
2002 if delta == 0 { return; }
2003 #[cfg(feature = "image")]
2004 if self.image_mode {
2005 let total = self.image_total_rows();
2006 let body = self.body_rows() as usize;
2007 let max_top = total.saturating_sub(body);
2008 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
2009 self.top_line = next as usize;
2010 self.top_row = 0;
2011 return;
2012 }
2013 if self.hide_mode() {
2014 self.extend_visible_lines(idx, src);
2018 let n = self.visible_lines.len();
2019 if n == 0 {
2020 self.top_line = 0;
2021 self.top_row = 0;
2022 return;
2023 }
2024 let mut vi = self
2025 .visible_lines
2026 .iter()
2027 .position(|&l| l >= self.top_line)
2028 .unwrap_or(n - 1);
2029 if self.visible_lines[vi] != self.top_line {
2032 self.top_row = 0;
2033 }
2034 self.top_line = self.visible_lines[vi];
2035 let r_opts = self.render_opts(self.gutter_width(idx));
2036 if delta > 0 {
2037 let mut remaining = delta as usize;
2038 while remaining > 0 {
2039 let line = self.visible_lines[vi];
2040 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2041 if self.top_row + 1 < rows {
2042 self.top_row += 1;
2043 } else if vi + 1 < n {
2044 self.top_row = 0;
2045 vi += 1;
2046 self.top_line = self.visible_lines[vi];
2047 } else {
2048 break;
2049 }
2050 remaining -= 1;
2051 }
2052 let anchor = self.hide_bottom_anchor(src, idx);
2053 if (self.top_line, self.top_row) > anchor {
2054 self.top_line = anchor.0;
2055 self.top_row = anchor.1;
2056 }
2057 } else {
2058 let mut remaining = (-delta) as usize;
2059 while remaining > 0 {
2060 if self.top_row > 0 {
2061 self.top_row -= 1;
2062 } else if vi > 0 {
2063 vi -= 1;
2064 self.top_line = self.visible_lines[vi];
2065 let line = self.visible_lines[vi];
2066 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2067 self.top_row = rows.saturating_sub(1);
2068 } else {
2069 break;
2070 }
2071 remaining -= 1;
2072 }
2073 }
2074 return;
2075 }
2076 if delta > 0 {
2077 let mut remaining = delta as usize;
2078 while remaining > 0 {
2079 idx.extend_to_line(self.top_line + 1, src);
2080 let total = idx.line_count();
2081 if total == 0 { break; }
2082 let bytes = self.line_display_bytes(src, idx, self.top_line);
2083 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2084 if self.top_row + 1 < line_rows {
2085 self.top_row += 1;
2086 } else if self.top_line + 1 < total {
2087 self.top_row = 0;
2088 self.top_line += 1;
2089 } else {
2090 break;
2091 }
2092 remaining -= 1;
2093 }
2094 if idx.scanned_through() >= src.len() {
2099 let anchor = self.bottom_anchor(src, idx);
2100 if (self.top_line, self.top_row) > anchor {
2101 self.top_line = anchor.0;
2102 self.top_row = anchor.1;
2103 }
2104 }
2105 } else {
2106 let mut remaining = (-delta) as usize;
2107 while remaining > 0 {
2108 if self.top_row > 0 {
2109 self.top_row -= 1;
2110 } else if self.top_line > 0 {
2111 self.top_line -= 1;
2112 let bytes = self.line_display_bytes(src, idx, self.top_line);
2113 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2114 self.top_row = line_rows.saturating_sub(1);
2115 } else {
2116 break;
2117 }
2118 remaining -= 1;
2119 }
2120 }
2121 }
2122
2123 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2124 let n = self.page_size
2125 .map(|p| p as i64)
2126 .unwrap_or_else(|| self.body_rows() as i64);
2127 self.scroll_lines(n, src, idx);
2128 }
2129
2130 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2131 let n = self.page_size
2132 .map(|p| p as i64)
2133 .unwrap_or_else(|| self.body_rows() as i64);
2134 self.scroll_lines(-n, src, idx);
2135 }
2136
2137 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2138 let n = (self.body_rows() / 2).max(1) as i64;
2139 self.scroll_lines(n, src, idx);
2140 }
2141
2142 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2143 let n = (self.body_rows() / 2).max(1) as i64;
2144 self.scroll_lines(-n, src, idx);
2145 }
2146
2147 pub fn goto_top(&mut self) {
2148 self.top_line = 0;
2149 self.top_row = 0;
2150 }
2151
2152 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2159 let body = self.body_rows() as usize;
2160 let total = idx.line_count();
2161 if total == 0 || body == 0 {
2162 return (0, 0);
2163 }
2164 let r_opts = self.render_opts(self.gutter_width(idx));
2165 let mut remaining = body;
2166 let mut line = total - 1;
2167 loop {
2168 let bytes = self.line_display_bytes(src, idx, line);
2169 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
2170 if line_rows >= remaining {
2171 return (line, line_rows - remaining);
2172 }
2173 remaining -= line_rows;
2174 if line == 0 {
2175 return (0, 0);
2176 }
2177 line -= 1;
2178 }
2179 }
2180
2181 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2186 let body = self.body_rows() as usize;
2187 let n = self.visible_lines.len();
2188 if n == 0 || body == 0 {
2189 return (0, 0);
2190 }
2191 let r_opts = self.render_opts(self.gutter_width(idx));
2192 let mut remaining = body;
2193 let mut vi = n - 1;
2194 loop {
2195 let line = self.visible_lines[vi];
2196 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2197 if rows >= remaining {
2198 return (line, rows - remaining);
2199 }
2200 remaining -= rows;
2201 if vi == 0 {
2202 return (self.visible_lines[0], 0);
2203 }
2204 vi -= 1;
2205 }
2206 }
2207
2208 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2209 #[cfg(feature = "image")]
2210 if self.image_mode {
2211 let body = self.body_rows() as usize;
2212 self.top_line = self.image_total_rows().saturating_sub(body);
2213 self.top_row = 0;
2214 return;
2215 }
2216 idx.extend_to_end(src);
2217 if self.hide_mode() {
2218 self.extend_visible_lines(idx, src);
2219 let (line, row) = self.hide_bottom_anchor(src, idx);
2220 self.top_line = line;
2221 self.top_row = row;
2222 } else {
2223 let (line, row) = self.bottom_anchor(src, idx);
2224 self.top_line = line;
2225 self.top_row = row;
2226 }
2227 }
2228
2229 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2231 idx.extend_to_line(n, src);
2232 let target = n.min(idx.line_count().saturating_sub(1));
2233 self.top_line = target;
2234 self.top_row = 0;
2235 }
2236
2237 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2239 while idx.record_count() <= n && idx.scanned_through() < src.len() {
2243 idx.extend_to_end(src);
2244 }
2245 if idx.record_count() == 0 {
2246 return;
2247 }
2248 let target = n.min(idx.record_count().saturating_sub(1));
2249 let line_range = idx.record_line_range(target);
2250 self.top_line = line_range.start;
2251 self.top_row = 0;
2252 }
2253
2254 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
2257 let p = p.min(100) as usize;
2258 let target_byte = src.len().saturating_mul(p) / 100;
2259 idx.extend_to_byte_for_query(src, target_byte);
2260 let line_n = idx.line_at_byte(target_byte)
2261 .or_else(|| {
2262 let lc = idx.line_count();
2264 if lc > 0 { Some(lc - 1) } else { None }
2265 })
2266 .unwrap_or(0);
2267 self.top_line = line_n;
2268 self.top_row = 0;
2269 }
2270
2271 pub fn top_line(&self) -> usize {
2273 self.top_line
2274 }
2275
2276 pub fn resize(&mut self, cols: u16, rows: u16) {
2277 self.cols = cols.max(1);
2278 self.rows = rows.max(2);
2279 self.opts.cols = self.cols;
2280 }
2281
2282 pub fn toggle_line_numbers(&mut self) {
2283 self.show_line_numbers = !self.show_line_numbers;
2284 }
2285
2286 pub fn toggle_chop(&mut self) {
2287 self.opts.wrap = !self.opts.wrap;
2288 if self.opts.wrap {
2289 self.left_col = 0;
2290 }
2291 }
2292
2293 const HSCROLL_STEP: usize = 8;
2294
2295 pub fn hscroll_active(&self) -> bool {
2299 #[cfg(feature = "image")]
2300 if self.current_image().is_some() {
2301 return true;
2302 }
2303 !self.opts.wrap
2304 && !self.hex_mode
2305 && self.ansi_mode != crate::render::AnsiMode::Raw
2306 }
2307
2308 fn hscroll_by(&mut self, delta: isize) {
2309 if !self.hscroll_active() {
2310 return;
2311 }
2312 self.left_col = (self.left_col as isize + delta).max(0) as usize;
2313 }
2315
2316 pub fn hscroll_left_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2317 pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2318 pub fn hscroll_left_step(&mut self) { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2319 pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2320
2321 pub fn hscroll_left_cols(&mut self, n: u16) { self.hscroll_by(-(n as isize)); }
2323 pub fn hscroll_right_cols(&mut self, n: u16) { self.hscroll_by(n as isize); }
2325
2326 pub fn left_col(&self) -> usize { self.left_col }
2327
2328 pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2331
2332 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2336}
2337
2338#[cfg(feature = "image")]
2343pub fn protocol_scaled_dims(img_w: u32, img_h: u32, cols: u16,
2344 cell_px: (u16, u16), width_cols: Option<usize>) -> (u32, u32) {
2345 let target_cols = width_cols.unwrap_or(cols as usize).max(1) as u32;
2346 let scaled_w = (target_cols * cell_px.0.max(1) as u32).max(1);
2347 let img_w = img_w.max(1);
2348 let scaled_h = (img_h as u64 * scaled_w as u64 / img_w as u64).max(1) as u32;
2349 (scaled_w, scaled_h)
2350}
2351
2352#[cfg(feature = "image")]
2355pub fn protocol_occupied_rows(img_w: u32, img_h: u32, cols: u16,
2356 cell_px: (u16, u16), width_cols: Option<usize>) -> usize {
2357 let (_, scaled_h) = protocol_scaled_dims(img_w, img_h, cols, cell_px, width_cols);
2358 (scaled_h as usize).div_ceil(cell_px.1.max(1) as usize).max(1)
2359}
2360
2361#[cfg(test)]
2362mod tests {
2363 use super::*;
2364 use crate::source::MockSource;
2365
2366 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2367 let m = MockSource::new();
2368 m.append(content);
2369 m.finish();
2370 let idx = LineIndex::new();
2371 (m, idx)
2372 }
2373
2374 fn first_cell_char(row: &[Cell]) -> char {
2377 match row.first() {
2378 Some(Cell::Char { ch, .. }) => *ch,
2379 other => panic!("expected Char in first cell, got {:?}", other),
2380 }
2381 }
2382
2383 #[test]
2384 fn status_column_shows_mark_then_search_glyphs() {
2385 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2388 let mut v = Viewport::new(20, 5, "f".into()); v.opts.wrap = false;
2390 v.set_status_column(true);
2391 let mut marks = std::collections::HashMap::new();
2392 marks.insert(1usize, 'a');
2393 v.set_status_marks(marks);
2394 v.set_search("cc".into(), SearchDirection::Forward).unwrap();
2395
2396 let frame = v.frame(&m, &mut idx);
2397 assert_eq!(first_cell_char(&frame.body[0]), ' ', "line 0: no mark, no match");
2398 assert_eq!(first_cell_char(&frame.body[1]), 'a', "line 1: mark letter");
2399 assert_eq!(first_cell_char(&frame.body[2]), '*', "line 2: search match");
2400 }
2401
2402 #[test]
2403 fn status_column_mark_beats_search_match() {
2404 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2407 let mut v = Viewport::new(20, 5, "f".into());
2408 v.opts.wrap = false;
2409 v.set_status_column(true);
2410 let mut marks = std::collections::HashMap::new();
2411 marks.insert(1usize, 'z');
2412 v.set_status_marks(marks);
2413 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2414
2415 let frame = v.frame(&m, &mut idx);
2416 assert_eq!(first_cell_char(&frame.body[1]), 'z', "mark beats search-match");
2417 }
2418
2419 #[test]
2420 fn status_column_matches_content_not_gutter_digits() {
2421 let (m, mut idx) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2428 let mut v = Viewport::new(40, 14, "f".into()); v.opts.wrap = false;
2430 v.show_line_numbers = true;
2431 v.set_status_column(true);
2432 v.set_search("5".into(), SearchDirection::Forward).unwrap();
2433
2434 let frame = v.frame(&m, &mut idx);
2435 for i in 0..12 {
2439 assert_eq!(
2440 first_cell_char(&frame.body[i]), ' ',
2441 "body row {i}: no content match for '5' but status column flagged it"
2442 );
2443 }
2444
2445 let (m2, mut idx2) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2447 let mut v2 = Viewport::new(40, 14, "f".into());
2448 v2.opts.wrap = false;
2449 v2.show_line_numbers = true;
2450 v2.set_status_column(true);
2451 v2.set_search("ee".into(), SearchDirection::Forward).unwrap();
2452 let frame2 = v2.frame(&m2, &mut idx2);
2453 assert_eq!(first_cell_char(&frame2.body[4]), '*', "line 5 content 'ee' matches search");
2454 }
2455
2456 #[test]
2457 fn status_column_off_leaves_first_cell_as_content() {
2458 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2461 let mut v = Viewport::new(20, 5, "f".into());
2462 v.opts.wrap = false;
2463 let mut marks = std::collections::HashMap::new();
2465 marks.insert(1usize, 'a');
2466 v.set_status_marks(marks);
2467 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2468
2469 let frame = v.frame(&m, &mut idx);
2470 assert_eq!(first_cell_char(&frame.body[0]), 'a', "line 0 content unchanged");
2471 assert_eq!(first_cell_char(&frame.body[1]), 'b', "line 1 content unchanged");
2472 assert_eq!(first_cell_char(&frame.body[2]), 'c', "line 2 content unchanged");
2473 }
2474
2475 #[test]
2476 fn frame_renders_body_height_rows() {
2477 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2478 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
2480 assert_eq!(frame.body.len(), 4);
2481 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2482 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2483 }
2484
2485 #[test]
2486 fn scroll_down_advances_top_line() {
2487 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2490 let mut v = Viewport::new(10, 5, "test".into());
2491 v.scroll_lines(2, &m, &mut idx);
2492 assert_eq!(v.top_line, 2);
2493 assert_eq!(v.top_row, 0);
2494 }
2495
2496 #[test]
2497 fn scroll_up_clamps_at_zero() {
2498 let (m, mut idx) = setup(b"a\nb\nc\n");
2499 let mut v = Viewport::new(10, 5, "test".into());
2500 v.scroll_lines(-5, &m, &mut idx);
2501 assert_eq!(v.top_line, 0);
2502 assert_eq!(v.top_row, 0);
2503 }
2504
2505 #[test]
2506 fn scroll_down_clamps_at_last_line() {
2507 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2512 let mut v = Viewport::new(10, 5, "test".into());
2513 v.scroll_lines(50, &m, &mut idx);
2514 assert_eq!((v.top_line, v.top_row), (4, 0));
2515 assert!(v.is_at_bottom(&m, &idx));
2516 }
2517
2518 #[test]
2519 fn scroll_logical_lines_skips_wrap_rows() {
2520 let mut content = vec![b'X'; 500];
2522 content.push(b'\n');
2523 content.extend_from_slice(b"second\n");
2524 content.extend_from_slice(b"third\n");
2525 let (m, mut idx) = setup(&content);
2526 let mut v = Viewport::new(10, 8, "f".into());
2527 v.scroll_logical_lines(1, &m, &mut idx);
2528 assert_eq!((v.top_line, v.top_row), (1, 0));
2529 v.scroll_logical_lines(1, &m, &mut idx);
2530 assert_eq!((v.top_line, v.top_row), (2, 0));
2531 }
2532
2533 #[test]
2534 fn scroll_logical_lines_back_snaps_to_line_start() {
2535 let mut content = vec![b'A'; 50];
2540 content.push(b'\n');
2541 content.extend_from_slice(&[b'B'; 50]);
2542 content.push(b'\n');
2543 content.extend_from_slice(&[b'C'; 50]);
2544 content.push(b'\n');
2545 let (m, mut idx) = setup(&content);
2546 let mut v = Viewport::new(10, 8, "f".into());
2547 v.scroll_lines(7, &m, &mut idx);
2548 assert_eq!(v.top_line, 1, "should be on line 1");
2549 assert!(v.top_row > 0, "should be inside line 1's wraps");
2550 v.scroll_logical_lines(-1, &m, &mut idx);
2551 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2552 v.scroll_logical_lines(-1, &m, &mut idx);
2553 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2554 }
2555
2556 #[test]
2557 fn scroll_down_walks_wraps_of_last_line() {
2558 let mut content = b"first\n".to_vec();
2562 content.extend_from_slice(&[b'X'; 60]);
2563 content.push(b'\n');
2564 let (m, mut idx) = setup(&content);
2565 let mut v = Viewport::new(10, 5, "f".into());
2566 v.scroll_lines(1, &m, &mut idx);
2567 assert_eq!((v.top_line, v.top_row), (1, 0));
2568 v.scroll_lines(1, &m, &mut idx);
2569 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2570 v.scroll_lines(1, &m, &mut idx);
2571 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2572 v.scroll_lines(5, &m, &mut idx);
2574 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2575 }
2576
2577 #[test]
2578 fn scroll_down_walks_wrap_rows_within_long_line() {
2579 let mut content = vec![b'X'; 30];
2583 content.push(b'\n');
2584 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2585 let (m, mut idx) = setup(&content);
2586 let mut v = Viewport::new(10, 5, "f".into());
2587 v.scroll_lines(1, &m, &mut idx);
2588 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2589 v.scroll_lines(1, &m, &mut idx);
2590 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2591 v.scroll_lines(1, &m, &mut idx);
2592 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2593 }
2594
2595 #[test]
2596 fn status_line_shows_range_and_pct() {
2597 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2598 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2600 assert!(frame.status.starts_with("f 1-4/10"));
2601 }
2602
2603 #[test]
2604 fn page_down_advances_by_body_rows() {
2605 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2606 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2608 assert_eq!(v.top_line, 4);
2609 }
2610
2611 #[test]
2612 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2613 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2614 let mut v = Viewport::new(10, 5, "f".into());
2615 v.page_down(&m, &mut idx);
2616 v.page_up(&m, &mut idx);
2617 assert_eq!(v.top_line, 0);
2618 assert_eq!(v.top_row, 0);
2619 }
2620
2621 #[test]
2622 fn half_page_down_advances_by_half_body() {
2623 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2626 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2628 assert_eq!(v.top_line, 3);
2629 }
2630
2631 #[test]
2632 fn goto_top_resets_position() {
2633 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2634 let mut v = Viewport::new(10, 5, "f".into());
2635 v.scroll_lines(2, &m, &mut idx);
2636 v.goto_top();
2637 assert_eq!(v.top_line, 0);
2638 assert_eq!(v.top_row, 0);
2639 }
2640
2641 #[test]
2642 fn goto_bottom_scrolls_to_last_page() {
2643 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2644 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2646 assert_eq!(v.top_line, 6);
2648 }
2649
2650 #[test]
2651 #[cfg(feature = "image")]
2652 fn protocol_image_occupied_rows_fit_width() {
2653 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), None), 50);
2655 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), Some(25)), 25);
2657 }
2658
2659 #[cfg(feature = "image")]
2660 #[test]
2661 fn frame_image_protocol_sets_image_blob() {
2662 use image::{Rgba, RgbaImage};
2663 let mut vp = Viewport::new(40, 10, "cat.png".into());
2664 let mut idx = LineIndex::new();
2665 let m = MockSource::new();
2666 vp.set_image(RgbaImage::from_pixel(20, 40, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2667 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2668 let frame = vp.frame(&m, &mut idx);
2669 assert!(frame.image_blob.is_some(), "Kitty protocol frame carries an image blob");
2670 vp.set_image_protocol(crate::viewport::ImageProtocol::Ascii, None);
2672 let frame2 = vp.frame(&m, &mut idx);
2673 assert!(frame2.image_blob.is_none(), "ASCII protocol frame has no blob");
2674 }
2675
2676 #[cfg(feature = "image")]
2677 #[test]
2678 fn protocol_image_clamps_vertical_scroll() {
2679 use image::{Rgba, RgbaImage};
2680 let mut vp = Viewport::new(40, 10, "cat.png".into()); let mut idx = LineIndex::new();
2682 let m = MockSource::new();
2683 vp.set_image(RgbaImage::from_pixel(20, 2000, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2685 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2686 for _ in 0..10_000 {
2688 vp.scroll_lines(1, &m, &mut idx);
2689 }
2690 let _ = vp.frame(&m, &mut idx);
2691 let total = crate::viewport::protocol_occupied_rows(20, 2000, 40, (8, 16), None);
2693 let body = vp.body_rows() as usize;
2694 assert_eq!(
2695 vp.top_line(),
2696 total.saturating_sub(body),
2697 "scroll reaches exactly the protocol image bottom"
2698 );
2699 }
2700
2701 #[cfg(feature = "image")]
2702 #[test]
2703 fn image_mode_frame_renders_and_scrolls() {
2704 use image::{Rgba, RgbaImage};
2705 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2706 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2708 assert!(v.image_mode());
2709 let total = v.image_total_rows();
2710 assert!(total > 5, "tall image should exceed the body");
2711 assert!(!v.is_at_bottom_image(), "starts at top");
2712 let mut idx = LineIndex::new();
2713 let m = MockSource::new();
2714 let frame = v.frame(&m, &mut idx);
2715 assert_eq!(frame.body.len(), 5);
2716 v.goto_bottom(&m, &mut idx);
2717 assert!(v.is_at_bottom_image());
2718 }
2719
2720 #[cfg(feature = "image")]
2721 #[test]
2722 fn frame_image_slices_at_left_col() {
2723 use crate::render::Cell;
2724 use image::{Rgba, RgbaImage};
2725
2726 let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2732 let mut v = Viewport::new(10, 4, "wide.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2734 assert!(v.hscroll_active(), "image mode should make hscroll active");
2735
2736 let mut idx = LineIndex::new();
2737 let m = MockSource::new();
2738
2739 assert_eq!(v.left_col(), 0);
2741 let frame0 = v.frame(&m, &mut idx);
2742 assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2743 assert_eq!(frame0.body[0].len(), 10);
2745 assert!(
2747 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2748 "no scroll marker expected on image frame at left_col=0"
2749 );
2750 let cell_at_col0 = frame0.body[0][0].clone();
2752 let cell_at_col8 = frame0.body[0][8].clone();
2753
2754 v.hscroll_right_step();
2756 assert_eq!(v.left_col(), 8);
2757 let frame1 = v.frame(&m, &mut idx);
2758 assert_eq!(frame1.body[0].len(), 10);
2759 assert!(
2761 !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2762 "no scroll marker expected on image frame after hscroll_right_step"
2763 );
2764 assert_eq!(
2766 frame1.body[0][0], cell_at_col8,
2767 "after hscroll_right_step the first visible cell should be grid col 8"
2768 );
2769 assert_ne!(
2773 frame1.body[0][0], cell_at_col0,
2774 "the scrolled first cell must differ from the unscrolled one"
2775 );
2776 }
2777
2778 #[cfg(feature = "image")]
2779 #[test]
2780 fn animation_renders_current_frame_and_advances() {
2781 use image::{Rgba, RgbaImage};
2782 use std::time::Duration;
2783 let m = MockSource::new();
2784 let mut idx = LineIndex::new();
2785 let mut vp = Viewport::new(40, 10, "x.gif".into());
2786 let frames = vec![
2787 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2788 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2789 ];
2790 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2791 crate::image_render::AsciiStyle::Ramp, None);
2792 let f0 = vp.frame(&m, &mut idx);
2793 let changed = vp.tick(Duration::from_millis(120));
2794 assert!(changed, "tick past the frame delay advances");
2795 let f1 = vp.frame(&m, &mut idx);
2796 assert_ne!(format!("{:?}", f0.body), format!("{:?}", f1.body), "frame content changed");
2797 assert!(vp.has_animation());
2798 assert!(vp.anim_deadline().is_some());
2799 }
2800
2801 #[cfg(feature = "image")]
2802 #[test]
2803 fn animation_status_badge_reflects_play_pause() {
2804 use image::{Rgba, RgbaImage};
2805 use std::time::Duration;
2806 let m = MockSource::new();
2807 let mut idx = LineIndex::new();
2808 let mut vp = Viewport::new(40, 10, "x.gif".into());
2809 let frames = vec![
2810 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2811 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2812 ];
2813 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2814 crate::image_render::AsciiStyle::Ramp, None);
2815 let playing = vp.frame(&m, &mut idx);
2816 assert!(playing.status.contains("[play 1/2]"), "status: {:?}", playing.status);
2817 vp.anim_toggle_pause();
2818 let paused = vp.frame(&m, &mut idx);
2819 assert!(paused.status.contains("[pause 1/2]"), "status: {:?}", paused.status);
2820 }
2821
2822 #[cfg(feature = "image")]
2823 #[test]
2824 fn animation_pause_stops_advance() {
2825 use image::{Rgba, RgbaImage};
2826 use std::time::Duration;
2827 let mut vp = Viewport::new(40, 10, "x.gif".into());
2828 let frames = vec![
2829 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2830 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2831 ];
2832 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2833 crate::image_render::AsciiStyle::Ramp, None);
2834 vp.anim_toggle_pause();
2835 assert!(!vp.tick(Duration::from_millis(500)), "paused tick does not advance");
2836 assert_eq!(vp.anim_deadline(), None);
2837 }
2838
2839 #[test]
2840 fn goto_line_positions_top_line() {
2841 let m = MockSource::new();
2842 m.append(b"a\nb\nc\nd\ne\n");
2843 let mut idx = LineIndex::new();
2844 idx.extend_to_end(&m);
2845 let mut v = Viewport::new(20, 5, "f".into());
2846 v.goto_line(3, &m, &mut idx);
2847 assert_eq!(v.top_line(), 3);
2848 }
2849
2850 #[test]
2851 fn goto_line_clamps_to_last_line() {
2852 let m = MockSource::new();
2853 m.append(b"a\nb\n");
2854 let mut idx = LineIndex::new();
2855 idx.extend_to_end(&m);
2856 let mut v = Viewport::new(20, 5, "f".into());
2857 v.goto_line(999, &m, &mut idx);
2858 assert_eq!(v.top_line(), 1);
2859 }
2860
2861 #[test]
2862 fn goto_record_positions_at_record_start_line() {
2863 let m = MockSource::new();
2864 m.append(b"[1] a\n cont\n[2] b\n[3] c\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(20, 5, "f".into());
2869 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2871 }
2872
2873 #[test]
2874 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2875 let m = MockSource::new();
2876 m.append(b"a\nb\nc\n");
2877 let mut idx = LineIndex::new();
2878 idx.extend_to_end(&m);
2879 let mut v = Viewport::new(20, 5, "f".into());
2880 v.goto_record(2, &m, &mut idx);
2881 assert_eq!(v.top_line(), 2);
2882 }
2883
2884 #[test]
2885 fn goto_percent_50_lands_in_middle() {
2886 let m = MockSource::new();
2887 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2889 idx.extend_to_end(&m);
2890 let mut v = Viewport::new(20, 5, "f".into());
2891 v.goto_percent(50, &m, &mut idx);
2892 assert_eq!(v.top_line(), 2); }
2894
2895 #[test]
2896 fn goto_percent_100_lands_at_last_line() {
2897 let m = MockSource::new();
2898 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2900 idx.extend_to_end(&m);
2901 let mut v = Viewport::new(20, 5, "f".into());
2902 v.goto_percent(100, &m, &mut idx);
2903 assert_eq!(v.top_line(), 2);
2904 }
2905
2906 #[test]
2907 fn goto_percent_0_lands_at_first_line() {
2908 let m = MockSource::new();
2909 m.append(b"a\nb\nc\n");
2910 let mut idx = LineIndex::new();
2911 idx.extend_to_end(&m);
2912 let mut v = Viewport::new(20, 5, "f".into());
2913 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2915 v.goto_percent(0, &m, &mut idx);
2916 assert_eq!(v.top_line(), 0);
2917 }
2918
2919 #[test]
2920 fn resize_updates_dimensions_and_render_opts() {
2921 let (m, mut idx) = setup(b"1\n2\n");
2922 let mut v = Viewport::new(10, 5, "f".into());
2923 v.resize(40, 12);
2924 assert_eq!(v.cols, 40);
2925 assert_eq!(v.rows, 12);
2926 assert_eq!(v.opts.cols, 40);
2927 let _ = v.frame(&m, &mut idx);
2928 }
2929
2930 #[test]
2931 fn toggle_line_numbers_changes_gutter() {
2932 let (m, mut idx) = setup(b"a\nb\nc\n");
2933 let mut v = Viewport::new(10, 5, "f".into());
2934 let frame_off = v.frame(&m, &mut idx);
2935 v.toggle_line_numbers();
2936 let frame_on = v.frame(&m, &mut idx);
2937 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2939 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2940 }
2941
2942 #[test]
2943 fn toggle_chop_changes_wrap_mode() {
2944 let (m, mut idx) = setup(b"abcdefghij\n");
2945 let mut v = Viewport::new(4, 5, "f".into());
2946 v.toggle_chop();
2947 let frame = v.frame(&m, &mut idx);
2948 assert_eq!(frame.body[0][..4],
2951 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2952 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2953 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2954 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2955 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2957 }
2958
2959 #[test]
2962 fn is_at_bottom_initially_only_when_source_fits() {
2963 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2966 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2967 }
2968
2969 #[test]
2970 fn is_at_bottom_false_when_top_and_more_lines_below() {
2971 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);
2974 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2975 }
2976
2977 #[test]
2978 fn is_at_bottom_true_after_goto_bottom() {
2979 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2980 let mut v = Viewport::new(10, 5, "f".into());
2981 v.goto_bottom(&m, &mut idx);
2982 assert!(v.is_at_bottom(&m, &idx));
2983 }
2984
2985 #[test]
2986 fn status_shows_follow_suffix_when_follow_mode_on() {
2987 let (m, mut idx) = setup(b"a\nb\n");
2988 let mut v = Viewport::new(20, 5, "f".into());
2989 let frame_off = v.frame(&m, &mut idx);
2990 assert!(!frame_off.status.contains("(F)"));
2991 v.set_follow_mode(true);
2992 let frame_on = v.frame(&m, &mut idx);
2993 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2994 }
2995
2996 #[test]
2997 fn toggle_follow_flips_state() {
2998 let mut v = Viewport::new(10, 5, "f".into());
2999 assert!(!v.follow_mode());
3000 v.toggle_follow();
3001 assert!(v.follow_mode());
3002 v.toggle_follow();
3003 assert!(!v.follow_mode());
3004 }
3005
3006 #[test]
3007 fn idle_indicator_kicks_in_at_threshold() {
3008 let (m, mut idx) = setup(b"a\nb\n");
3009 let mut v = Viewport::new(20, 5, "f".into());
3010 v.set_follow_mode(true);
3011 for _ in 0..19 { v.tick_idle(); }
3013 let f1 = v.frame(&m, &mut idx);
3014 assert!(f1.status.contains("(F)"));
3015 assert!(!f1.status.contains("idle"));
3016 v.tick_idle();
3018 let f2 = v.frame(&m, &mut idx);
3019 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
3020 }
3021
3022 #[test]
3023 fn note_growth_resets_idle() {
3024 let (m, mut idx) = setup(b"a\nb\n");
3025 let mut v = Viewport::new(20, 5, "f".into());
3026 v.set_follow_mode(true);
3027 for _ in 0..25 { v.tick_idle(); }
3028 assert!(v.is_idle());
3029 v.note_growth();
3030 assert!(!v.is_idle());
3031 let f = v.frame(&m, &mut idx);
3032 assert!(!f.status.contains("idle"));
3033 }
3034
3035 #[test]
3036 fn qae_off_never_quits_even_at_bottom() {
3037 let (m, mut idx) = setup(b"a\n");
3038 let mut v = Viewport::new(20, 5, "f".into());
3039 v.set_quit_at_eof(QuitAtEof::Off);
3040 v.goto_bottom(&m, &mut idx);
3041 assert!(!v.note_motion_for_eof(true, &m, &idx));
3042 }
3043
3044 #[test]
3045 fn qae_first_quits_immediately_at_bottom() {
3046 let (m, mut idx) = setup(b"a\n");
3047 let mut v = Viewport::new(20, 5, "f".into());
3048 v.set_quit_at_eof(QuitAtEof::First);
3049 v.goto_bottom(&m, &mut idx);
3050 assert!(v.note_motion_for_eof(true, &m, &idx));
3051 }
3052
3053 #[test]
3054 fn qae_first_only_quits_at_eof_not_mid_file() {
3055 let mut content = Vec::new();
3056 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
3057 let (m, mut idx) = setup(&content);
3058 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
3060 v.set_quit_at_eof(QuitAtEof::First);
3061 assert!(!v.is_at_bottom(&m, &idx));
3063 assert!(!v.note_motion_for_eof(true, &m, &idx));
3064 }
3065
3066 #[test]
3067 fn qae_second_quits_on_second_hit() {
3068 let (m, mut idx) = setup(b"a\n");
3069 let mut v = Viewport::new(20, 5, "f".into());
3070 v.set_quit_at_eof(QuitAtEof::Second);
3071 v.goto_bottom(&m, &mut idx);
3072 assert!(!v.note_motion_for_eof(true, &m, &idx));
3074 assert!(v.note_motion_for_eof(true, &m, &idx));
3076 }
3077
3078 #[test]
3079 fn squeeze_collapses_consecutive_blanks() {
3080 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3082 let mut v = Viewport::new(10, 8, "f".into());
3083 v.set_squeeze_blanks(true);
3084 let f = v.frame(&m, &mut idx);
3085 let stringify = |row: &Vec<Cell>| -> String {
3087 row.iter().filter_map(|c| match c {
3088 Cell::Char { ch, .. } => Some(*ch),
3089 _ => None,
3090 }).collect::<String>().trim().to_string()
3091 };
3092 let rows: Vec<String> = f.body.iter().map(stringify).collect();
3093 assert_eq!(&rows[0], "a");
3095 assert_eq!(&rows[1], "");
3096 assert_eq!(&rows[2], "b");
3097 }
3098
3099 #[test]
3100 fn header_pins_top_rows_when_scrolling() {
3101 let mut content = Vec::new();
3103 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3104 let (m, mut idx) = setup(&content);
3105 let mut v = Viewport::new(20, 6, "f".into());
3106 v.set_header(2, 0);
3107 v.scroll_lines(5, &m, &mut idx);
3111 let f = v.frame(&m, &mut idx);
3112 let chs = |row: &Vec<Cell>| -> String {
3113 row.iter().filter_map(|c| match c {
3114 Cell::Char { ch, .. } => Some(*ch),
3115 _ => None,
3116 }).collect::<String>().trim().to_string()
3117 };
3118 assert_eq!(&chs(&f.body[0]), "line0");
3120 assert_eq!(&chs(&f.body[1]), "line1");
3121 assert_eq!(&chs(&f.body[2]), "line7");
3123 }
3124
3125 #[test]
3126 fn page_size_when_set_overrides_body_rows() {
3127 let mut content = Vec::new();
3128 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3129 let (m, mut idx) = setup(&content);
3130 let mut v = Viewport::new(20, 10, "f".into());
3131 v.set_page_size(Some(3));
3132 let before = v.top_line();
3133 v.page_down(&m, &mut idx);
3134 assert_eq!(v.top_line(), before + 3);
3135 v.page_up(&m, &mut idx);
3136 assert_eq!(v.top_line(), before);
3137 }
3138
3139 #[test]
3140 fn page_size_unset_uses_body_rows() {
3141 let mut content = Vec::new();
3142 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3143 let (m, mut idx) = setup(&content);
3144 let mut v = Viewport::new(20, 10, "f".into());
3145 v.page_down(&m, &mut idx);
3147 assert_eq!(v.top_line(), 9);
3148 }
3149
3150 #[test]
3151 fn header_zero_lines_renders_like_no_header() {
3152 let mut content = Vec::new();
3153 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3154 let (m, mut idx) = setup(&content);
3155 let mut v = Viewport::new(20, 6, "f".into());
3156 v.set_header(0, 0);
3157 let f = v.frame(&m, &mut idx);
3158 let chs = |row: &Vec<Cell>| -> String {
3159 row.iter().filter_map(|c| match c {
3160 Cell::Char { ch, .. } => Some(*ch),
3161 _ => None,
3162 }).collect::<String>().trim().to_string()
3163 };
3164 assert_eq!(&chs(&f.body[0]), "line0");
3165 assert_eq!(&chs(&f.body[1]), "line1");
3166 }
3167
3168 #[test]
3169 fn squeeze_off_preserves_blanks() {
3170 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3171 let mut v = Viewport::new(10, 8, "f".into());
3172 let f = v.frame(&m, &mut idx);
3174 let stringify = |row: &Vec<Cell>| -> String {
3175 row.iter().filter_map(|c| match c {
3176 Cell::Char { ch, .. } => Some(*ch),
3177 _ => None,
3178 }).collect::<String>().trim().to_string()
3179 };
3180 let rows: Vec<String> = f.body.iter().map(stringify).collect();
3181 assert_eq!(&rows[0], "a");
3183 assert_eq!(&rows[1], "");
3184 assert_eq!(&rows[2], "");
3185 assert_eq!(&rows[3], "");
3186 assert_eq!(&rows[4], "b");
3187 }
3188
3189 #[test]
3190 fn qae_second_resets_on_backward_motion() {
3191 let (m, mut idx) = setup(b"a\n");
3192 let mut v = Viewport::new(20, 5, "f".into());
3193 v.set_quit_at_eof(QuitAtEof::Second);
3194 v.goto_bottom(&m, &mut idx);
3195 assert!(!v.note_motion_for_eof(true, &m, &idx));
3196 v.note_motion_for_eof(false, &m, &idx);
3198 assert!(!v.note_motion_for_eof(true, &m, &idx));
3200 assert!(v.note_motion_for_eof(true, &m, &idx));
3202 }
3203
3204 #[test]
3205 fn flash_message_overrides_follow_suffix() {
3206 let (m, mut idx) = setup(b"a\nb\n");
3207 let mut v = Viewport::new(40, 5, "f".into());
3208 v.set_follow_mode(true);
3209 v.flash("(F reopened)", 3);
3210 let f = v.frame(&m, &mut idx);
3211 assert!(f.status.contains("(F reopened)"), "{}", f.status);
3212 assert!(!f.status.contains("(F idle)"));
3213 }
3214
3215 #[test]
3216 fn flash_countdown_clears() {
3217 let mut v = Viewport::new(10, 5, "f".into());
3218 v.flash("hello", 2);
3219 v.tick_flash();
3220 assert!(v.status_flash.is_some());
3221 v.tick_flash();
3222 assert!(v.status_flash.is_none());
3223 }
3224
3225 #[test]
3226 fn suspend_follow_if_off_is_noop() {
3227 let mut v = Viewport::new(10, 5, "f".into());
3228 v.set_follow_mode(true);
3229 v.suspend_follow_if(false);
3230 assert!(v.follow_mode());
3231 }
3232
3233 #[test]
3234 fn suspend_follow_if_on_flips_off() {
3235 let mut v = Viewport::new(10, 5, "f".into());
3236 v.set_follow_mode(true);
3237 v.suspend_follow_if(true);
3238 assert!(!v.follow_mode());
3239 }
3240
3241 #[test]
3242 fn case_mode_sensitive_returns_pattern_unchanged() {
3243 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
3244 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
3245 }
3246
3247 #[test]
3248 fn case_mode_insensitive_prepends_i_flag() {
3249 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
3250 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
3251 }
3252
3253 #[test]
3254 fn case_mode_smart_lowercase_is_insensitive() {
3255 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
3256 }
3257
3258 #[test]
3259 fn case_mode_smart_with_uppercase_is_sensitive() {
3260 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
3261 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
3262 }
3263
3264 #[test]
3265 fn set_case_mode_recompiles_active_search() {
3266 let (m, mut idx) = setup(b"hello WORLD\n");
3267 let mut v = Viewport::new(40, 5, "f".into());
3268 v.set_search("world".into(), SearchDirection::Forward).unwrap();
3269 assert!(!v.search_repeat(&m, &mut idx, false));
3271 v.set_case_mode(CaseMode::Insensitive);
3273 assert!(v.search_repeat(&m, &mut idx, false));
3274 }
3275
3276 #[test]
3277 fn status_shows_prettify_label_when_set() {
3278 let (m, mut idx) = setup(b"a\n");
3279 let mut v = Viewport::new(40, 5, "f".into());
3280 let frame_off = v.frame(&m, &mut idx);
3281 assert!(!frame_off.status.contains("[pretty"));
3282 v.set_prettify_label(Some("json".into()));
3283 let frame_on = v.frame(&m, &mut idx);
3284 assert!(frame_on.status.contains("[pretty:json]"),
3285 "expected [pretty:json] in status, got: {}", frame_on.status);
3286 v.set_prettify_label(Some("json:err".into()));
3287 let frame_err = v.frame(&m, &mut idx);
3288 assert!(frame_err.status.contains("[pretty:json:err]"),
3289 "expected [pretty:json:err] in status, got: {}", frame_err.status);
3290 }
3291
3292 #[test]
3293 fn status_shows_l_suffix_when_live_mode_on() {
3294 let (m, mut idx) = setup(b"a\nb\n");
3295 let mut v = Viewport::new(20, 5, "f".into());
3296 let frame_off = v.frame(&m, &mut idx);
3297 assert!(!frame_off.status.contains("(L)"));
3298 v.set_live_mode(true);
3299 let frame_on = v.frame(&m, &mut idx);
3300 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
3301 }
3302
3303 #[test]
3304 fn clamp_top_line_pulls_back_when_total_shrinks() {
3305 let mut v = Viewport::new(20, 5, "f".into());
3306 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
3315 let (m, mut idx) = setup(b"only\n");
3317 let _ = v.frame(&m, &mut idx);
3318 }
3319
3320 fn simulate_growth_tick(
3323 v: &mut Viewport,
3324 src: &MockSource,
3325 idx: &mut LineIndex,
3326 ) {
3327 if !v.follow_mode() { return; }
3328 let was_at_bottom = v.is_at_bottom(src, idx);
3329 let lines_before = idx.line_count();
3330 idx.notice_new_bytes(src);
3331 if idx.line_count() != lines_before && was_at_bottom {
3332 v.goto_bottom(src, idx);
3333 }
3334 }
3335
3336 #[test]
3337 fn auto_scroll_engages_when_at_bottom() {
3338 let m = MockSource::new();
3339 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
3341 let mut v = Viewport::new(10, 5, "f".into());
3342 v.set_follow_mode(true);
3343 idx.extend_to_end(&m);
3344 assert!(v.is_at_bottom(&m, &idx));
3345 let top_before = {
3346 let f = v.frame(&m, &mut idx);
3347 f.status.clone() };
3349 let _ = top_before;
3350 m.append(b"5\n6\n7\n8\n");
3352 simulate_growth_tick(&mut v, &m, &mut idx);
3353 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
3355 let frame = v.frame(&m, &mut idx);
3356 let last_row = &frame.body[frame.body.len() - 1];
3359 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
3360 }
3361
3362 #[test]
3363 fn auto_scroll_suppressed_when_scrolled_up() {
3364 let m = MockSource::new();
3365 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
3367 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
3369 idx.extend_to_end(&m);
3370 v.goto_bottom(&m, &mut idx);
3371 v.scroll_lines(-2, &m, &mut idx);
3373 assert!(!v.is_at_bottom(&m, &idx));
3374 let frame_before = v.frame(&m, &mut idx);
3375 let top_first_cell_before = frame_before.body[0][0].clone();
3376 m.append(b"9\n10\n");
3378 simulate_growth_tick(&mut v, &m, &mut idx);
3379 let frame_after = v.frame(&m, &mut idx);
3381 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
3382 }
3383
3384 #[test]
3387 fn set_search_compiles_regex() {
3388 let mut v = Viewport::new(10, 5, "f".into());
3389 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
3390 assert!(v.search_active());
3391 }
3392
3393 #[test]
3394 fn set_search_rejects_bad_regex() {
3395 let mut v = Viewport::new(10, 5, "f".into());
3396 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
3397 assert!(!err.is_empty());
3398 assert!(!v.search_active(), "no search should be set on error");
3399 }
3400
3401 #[test]
3402 fn search_step_forward_finds_match_after_top() {
3403 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3404 let mut v = Viewport::new(20, 5, "f".into());
3405 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3406 let found = v.search_repeat(&m, &mut idx, false);
3407 assert!(found);
3408 assert_eq!(v.top_line, 2);
3410 }
3411
3412 #[test]
3413 fn search_step_backward_finds_match_before_top() {
3414 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3415 let mut v = Viewport::new(20, 5, "f".into());
3416 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
3418 let found = v.search_repeat(&m, &mut idx, false);
3419 assert!(found);
3420 assert_eq!(v.top_line, 0);
3421 }
3422
3423 #[test]
3424 fn search_wraps_at_end() {
3425 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3426 let mut v = Viewport::new(20, 5, "f".into());
3427 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
3429 let found = v.search_repeat(&m, &mut idx, false);
3430 assert!(found, "search should wrap forward past EOF");
3431 assert_eq!(v.top_line, 0);
3432 }
3433
3434 #[test]
3435 fn search_no_match_returns_false_and_does_not_move() {
3436 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3437 let mut v = Viewport::new(20, 5, "f".into());
3438 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
3439 let found = v.search_repeat(&m, &mut idx, false);
3440 assert!(!found);
3441 assert_eq!(v.top_line, 0);
3442 }
3443
3444 #[test]
3445 fn frame_records_highlight_ranges_for_matches() {
3446 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
3447 let mut v = Viewport::new(20, 5, "f".into());
3448 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3449 let frame = v.frame(&m, &mut idx);
3450 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3452 assert!(frame.highlights[0].is_empty());
3453 assert!(frame.highlights[1].is_empty());
3454 assert_eq!(frame.highlights[2], vec![0..5]);
3455 assert!(frame.highlights[3].is_empty());
3456 }
3457
3458 #[test]
3459 fn frame_highlights_substring_inside_a_row() {
3460 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
3461 let mut v = Viewport::new(40, 5, "f".into());
3462 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3463 let frame = v.frame(&m, &mut idx);
3464 assert_eq!(frame.highlights[0], vec![18..22]);
3466 assert!(frame.highlights[1].is_empty());
3467 }
3468
3469 #[test]
3470 fn search_highlight_with_filter_dim_keeps_row_dim() {
3471 let (m, mut idx) = setup(b"alpha\nbeta\n");
3474 let mut v = Viewport::new(20, 5, "f".into());
3475 let fmt = crate::format::LogFormat::compile(
3476 "simple",
3477 r"^(?P<line>.+)$",
3478 )
3479 .unwrap();
3480 let f = crate::filter::CompiledFilter::compile(
3481 &fmt,
3482 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
3483 CaseMode::Sensitive,
3484 )
3485 .unwrap();
3486 v.set_filter(Some(f));
3487 v.set_dim_mode(true);
3488 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3489 let frame = v.frame(&m, &mut idx);
3490 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3491 assert_eq!(frame.row_styles[1], RowStyle::Dim);
3492 assert_eq!(frame.highlights[1], vec![0..4]);
3493 }
3494
3495 #[test]
3496 fn grep_only_hides_non_matching_lines() {
3497 use crate::grep::GrepPredicate;
3498 let src = crate::source::MockSource::new();
3499 src.append(b"keep this error\n");
3500 src.append(b"drop this one\n");
3501 src.append(b"another error line\n");
3502 src.finish();
3503 let mut idx = crate::line_index::LineIndex::new();
3504 idx.extend_to_end(&src);
3505
3506 let mut v = Viewport::new(40, 5, "test".into());
3507 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
3508 v.extend_visible_lines(&idx, &src);
3509
3510 let frame = v.frame(&src, &mut idx);
3512 let body_text: Vec<String> = frame.body.iter()
3513 .map(|row| row.iter().filter_map(|c| match c {
3514 crate::render::Cell::Char { ch, .. } => Some(*ch),
3515 _ => None,
3516 }).collect())
3517 .collect();
3518 assert!(body_text[0].contains("keep this error"));
3519 assert!(body_text[1].contains("another error line"));
3520 assert!(frame.status.contains("[grep]"));
3521 }
3522
3523 #[test]
3524 fn incsearch_preview_anchors_from_origin_not_previous_match() {
3525 let src = crate::source::MockSource::new();
3533 src.append(b"zero\n"); src.append(b"one\n"); src.append(b"origin\n"); src.append(b"three\n"); src.append(b"mark\n"); src.append(b"five\n"); src.append(b"six\n"); src.append(b"seven\n"); src.append(b"target\n"); src.append(b"mark\n"); src.finish();
3544 let mut idx = crate::line_index::LineIndex::new();
3545
3546 let origin = (2usize, 0usize);
3547 let mut vp = Viewport::new(20, 4, "test".into()); vp.set_top(origin.0, origin.1);
3549 assert_eq!(vp.top_line(), 2);
3550
3551 vp.incsearch_preview(&src, &mut idx, "target", SearchDirection::Forward, origin);
3553 assert_eq!(vp.top_line(), 8, "should land on the far-below match");
3554 assert_eq!(vp.top_row(), 0);
3555
3556 vp.incsearch_preview(&src, &mut idx, "mark", SearchDirection::Forward, origin);
3562 assert_eq!(
3563 vp.top_line(), 4,
3564 "preview must reset to origin before scanning, landing on the match \
3565 after origin rather than continuing forward from the previous match"
3566 );
3567 assert_eq!(vp.top_row(), 0);
3568 }
3569
3570 #[test]
3571 fn incsearch_preview_empty_or_invalid_is_noop() {
3572 let (src, mut idx) = setup(b"alpha\nbeta\n[unbalanced\n");
3573 let mut vp = Viewport::new(20, 4, "test".into());
3574 vp.set_top(1, 0);
3575 vp.incsearch_preview(&src, &mut idx, "", SearchDirection::Forward, (0, 0));
3577 assert_eq!(vp.top_line(), 1);
3578 vp.incsearch_preview(&src, &mut idx, "(", SearchDirection::Forward, (0, 0));
3580 assert_eq!(vp.top_line(), 0);
3581 }
3582
3583 #[test]
3584 fn filter_and_grep_combine_with_and() {
3585 use crate::grep::GrepPredicate;
3586 let fmt = crate::format::LogFormat::compile(
3587 "simple",
3588 r"^(?P<level>\w+) (?P<msg>.+)$",
3589 ).unwrap();
3590 let f = crate::filter::CompiledFilter::compile(
3591 &fmt,
3592 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
3593 CaseMode::Sensitive,
3594 ).unwrap();
3595 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3596
3597 let src = crate::source::MockSource::new();
3598 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();
3603 let mut idx = crate::line_index::LineIndex::new();
3604 idx.extend_to_end(&src);
3605
3606 let mut v = Viewport::new(80, 5, "test".into());
3607 v.set_filter(Some(f));
3608 v.set_grep(Some(g));
3609 v.extend_visible_lines(&idx, &src);
3610 assert_eq!(v.visible_lines(), &[0usize]);
3611 }
3612
3613 #[test]
3614 fn search_status_shows_pattern() {
3615 let (m, mut idx) = setup(b"x\n");
3616 let mut v = Viewport::new(20, 5, "f".into());
3617 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3618 let frame = v.frame(&m, &mut idx);
3619 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3620 }
3621
3622 #[test]
3623 fn repeat_search_after_first_match_advances() {
3624 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3625 let mut v = Viewport::new(40, 5, "f".into());
3626 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3627 assert!(v.search_repeat(&m, &mut idx, false));
3628 assert_eq!(v.top_line, 1, "first foo");
3629 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3630 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3631 assert_eq!(v.top_line, 3, "should advance to next foo");
3632 }
3633
3634 #[test]
3635 fn auto_scroll_paused_when_follow_off() {
3636 let m = MockSource::new();
3637 m.append(b"1\n2\n3\n4\n");
3638 let mut idx = LineIndex::new();
3639 let mut v = Viewport::new(10, 5, "f".into());
3640 idx.extend_to_end(&m);
3642 let frame_before = v.frame(&m, &mut idx);
3643 let top_first_cell = frame_before.body[0][0].clone();
3644 m.append(b"5\n6\n7\n8\n");
3645 simulate_growth_tick(&mut v, &m, &mut idx);
3646 let frame_after = v.frame(&m, &mut idx);
3647 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3648 }
3649
3650 #[test]
3653 fn search_jumps_to_next_matching_record() {
3654 let m = MockSource::new();
3655 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
3656 let mut idx = LineIndex::new();
3657 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3658 idx.extend_to_end(&m);
3659 let mut v = Viewport::new(40, 10, "f".into());
3660 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3661 let hit = v.search_repeat(&m, &mut idx, false);
3662 assert!(hit, "should find 'charlie' in record 2");
3663 assert_eq!(v.top_line(), 3); }
3665
3666 #[test]
3667 fn search_finds_cross_line_match_in_record_with_s_flag() {
3668 let m = MockSource::new();
3669 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
3670 let mut idx = LineIndex::new();
3671 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3672 idx.extend_to_end(&m);
3673 let mut v = Viewport::new(40, 10, "f".into());
3674 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3675 let hit = v.search_repeat(&m, &mut idx, false);
3676 assert!(hit, "should match across \\n inside record 0 with (?s)");
3677 assert_eq!(v.top_line(), 0);
3678 }
3679
3680 #[test]
3681 fn search_repeat_with_no_match_returns_false() {
3682 let m = MockSource::new();
3683 m.append(b"[1] alpha\n[2] bravo\n");
3684 let mut idx = LineIndex::new();
3685 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3686 idx.extend_to_end(&m);
3687 let mut v = Viewport::new(40, 10, "f".into());
3688 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3689 let hit = v.search_repeat(&m, &mut idx, false);
3690 assert!(!hit);
3691 }
3692
3693 #[test]
3696 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3697 let m = MockSource::new();
3700 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
3701 let mut idx = LineIndex::new();
3702 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3703 idx.extend_to_end(&m);
3704 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3705 let mut v = Viewport::new(40, 10, "f".into());
3706 v.set_grep(Some(grep));
3707 v.extend_visible_lines(&idx, &m);
3708 assert_eq!(v.visible_lines(), &[0usize, 1]);
3711 }
3712
3713 #[test]
3714 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3715 let m = MockSource::new();
3721 m.append(
3722 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
3723 );
3724 let mut idx = LineIndex::new();
3725 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3726 idx.extend_to_end(&m);
3727 let fmt = crate::format::LogFormat::compile(
3728 "rec",
3729 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3730 )
3731 .unwrap();
3732 let f = crate::filter::CompiledFilter::compile(
3733 &fmt,
3734 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3735 CaseMode::Sensitive,
3736 )
3737 .unwrap();
3738 let mut v = Viewport::new(40, 10, "f".into());
3739 v.set_filter(Some(f));
3740 v.extend_visible_lines(&idx, &m);
3741 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3743 }
3744
3745 #[test]
3746 fn grep_matches_across_record_newlines_in_records_mode() {
3747 let m = MockSource::new();
3749 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
3750 let mut idx = LineIndex::new();
3751 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3752 idx.extend_to_end(&m);
3753 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3754 let mut v = Viewport::new(40, 10, "f".into());
3755 v.set_grep(Some(grep));
3756 v.extend_visible_lines(&idx, &m);
3757 assert_eq!(v.visible_lines(), &[0usize, 1]);
3759 }
3760
3761 #[test]
3762 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3763 let m = MockSource::new();
3766 m.append(b"[1] head\n cont\n[2] other\n cont\n");
3767 let mut idx = LineIndex::new();
3768 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3769 idx.extend_to_end(&m);
3770 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3771 let mut v = Viewport::new(40, 10, "f".into());
3772 v.set_grep(Some(grep));
3773 v.set_dim_mode(true);
3774 v.extend_visible_lines(&idx, &m);
3775 assert_eq!(v.visible_lines(), &[] as &[usize]);
3777 assert!(!v.should_dim_line(0, &idx, &m));
3779 assert!(!v.should_dim_line(1, &idx, &m));
3780 assert!(v.should_dim_line(2, &idx, &m));
3782 assert!(v.should_dim_line(3, &idx, &m));
3783 }
3784
3785 #[test]
3786 fn status_unchanged_when_records_inactive() {
3787 let (m, mut idx) = setup(b"a\nb\nc\n");
3788 let mut v = Viewport::new(20, 5, "f".into());
3789 let frame = v.frame(&m, &mut idx);
3790 let status = &frame.status;
3791 assert!(status.contains("1-3/3"), "got: {status}");
3793 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3794 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3795 }
3796
3797 #[test]
3798 fn status_r_block_uses_real_lines_in_hide_mode() {
3799 let m = MockSource::new();
3808 let mut buf = Vec::new();
3811 for n in 0..10 {
3812 let kind = if n >= 8 { "B" } else { "A" };
3813 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3814 }
3815 m.append(&buf);
3816 m.finish();
3817
3818 let mut idx = LineIndex::new();
3819 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3820 idx.extend_to_end(&m);
3821
3822 let fmt = crate::format::LogFormat::compile(
3823 "rec",
3824 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3825 )
3826 .unwrap();
3827 let f = crate::filter::CompiledFilter::compile(
3828 &fmt,
3829 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3830 CaseMode::Sensitive,
3831 )
3832 .unwrap();
3833
3834 let mut v = Viewport::new(80, 5, "f".into());
3837 v.set_filter(Some(f));
3838 v.extend_visible_lines(&idx, &m);
3839
3840 v.goto_record(8, &m, &mut idx);
3842
3843 let frame = v.frame(&m, &mut idx);
3844 assert!(
3846 frame.status.contains("R9-10/10"),
3847 "expected R9-10/10 in status, got: {}",
3848 frame.status,
3849 );
3850 }
3851
3852 #[test]
3853 fn status_dual_readout_when_records_active() {
3854 let m = MockSource::new();
3855 m.append(b"[1] a\n cont\n[2] b\n");
3856 m.finish();
3857 let mut idx = LineIndex::new();
3858 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3859 idx.extend_to_end(&m);
3860 let mut v = Viewport::new(20, 5, "f".into());
3861 let frame = v.frame(&m, &mut idx);
3862 let status = &frame.status;
3863 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3864 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3865 }
3866
3867 #[test]
3868 fn format_status_uses_custom_template_when_set() {
3869 let m = MockSource::new();
3870 m.append(b"a\nb\nc\n");
3871 m.finish();
3872 let mut idx = LineIndex::new();
3873 idx.extend_to_end(&m);
3874 let mut v = Viewport::new(20, 5, "f".into());
3875 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3876 v.set_prompt(Some(prompt));
3877 let frame = v.frame(&m, &mut idx);
3878 assert_eq!(frame.status, "f 100%");
3879 }
3880
3881 #[test]
3882 fn status_shows_preprocess_failed_tag_when_set() {
3883 let m = MockSource::new();
3884 m.append(b"a\n");
3885 let mut idx = LineIndex::new();
3886 idx.extend_to_end(&m);
3887 let mut v = Viewport::new(40, 5, "f".into());
3888 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3889 let frame = v.frame(&m, &mut idx);
3890 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3891 "got: {}", frame.status);
3892 }
3893
3894 #[test]
3895 fn default_status_includes_help_hint() {
3896 let (m, mut idx) = setup(b"a\nb\nc\n");
3897 let mut v = Viewport::new(80, 5, "f".into());
3898 let frame = v.frame(&m, &mut idx);
3899 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3900 }
3901
3902 #[test]
3903 fn custom_prompt_does_not_get_help_hint() {
3904 let (m, mut idx) = setup(b"a\nb\nc\n");
3905 let mut v = Viewport::new(80, 5, "f".into());
3906 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3907 let frame = v.frame(&m, &mut idx);
3908 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3909 }
3910
3911 #[test]
3912 fn status_shows_file_index_when_multifile() {
3913 let m = MockSource::new();
3914 m.append(b"a\n");
3915 let mut idx = LineIndex::new();
3916 idx.extend_to_end(&m);
3917 let mut v = Viewport::new(60, 5, "f.log".into());
3918 v.set_file_index(0, 3);
3919 let frame = v.frame(&m, &mut idx);
3920 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3921 }
3922
3923 #[test]
3924 fn status_omits_file_index_when_single_file() {
3925 let m = MockSource::new();
3926 m.append(b"a\n");
3927 let mut idx = LineIndex::new();
3928 idx.extend_to_end(&m);
3929 let mut v = Viewport::new(60, 5, "f.log".into());
3930 v.set_file_index(0, 1);
3931 let frame = v.frame(&m, &mut idx);
3932 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3933 }
3934
3935 #[test]
3936 fn status_shows_tag_active_when_multimatch() {
3937 let m = MockSource::new();
3938 m.append(b"a\n");
3939 let mut idx = LineIndex::new();
3940 idx.extend_to_end(&m);
3941 let mut v = Viewport::new(80, 5, "f.log".into());
3942 v.set_tag_active(Some(("foo".into(), 2, 3)));
3943 let frame = v.frame(&m, &mut idx);
3944 assert!(
3945 frame.status.contains("[tag: foo (2/3)]"),
3946 "got: {}",
3947 frame.status
3948 );
3949 }
3950
3951 #[test]
3952 fn status_omits_tag_active_when_single_match() {
3953 let m = MockSource::new();
3954 m.append(b"a\n");
3955 let mut idx = LineIndex::new();
3956 idx.extend_to_end(&m);
3957 let mut v = Viewport::new(80, 5, "f.log".into());
3958 v.set_tag_active(Some(("foo".into(), 1, 1)));
3959 let frame = v.frame(&m, &mut idx);
3960 assert!(
3961 !frame.status.contains("[tag:"),
3962 "should not show indicator for single match: {}",
3963 frame.status
3964 );
3965 }
3966
3967 #[test]
3968 fn hscroll_noop_when_wrapping() {
3969 let mut v = Viewport::new(80, 24, "t".into());
3970 v.hscroll_right_step();
3972 assert_eq!(v.left_col(), 0);
3973 }
3974
3975 #[test]
3976 fn hscroll_active_in_chop_and_clamps_at_zero() {
3977 let mut v = Viewport::new(80, 24, "t".into());
3978 v.toggle_chop(); assert!(v.hscroll_active());
3980 v.hscroll_right_step();
3981 assert_eq!(v.left_col(), 8);
3982 v.hscroll_right_half();
3983 assert_eq!(v.left_col(), 8 + 40); v.hscroll_left_half();
3985 assert_eq!(v.left_col(), 8);
3986 v.hscroll_left_half();
3987 assert_eq!(v.left_col(), 0); }
3989
3990 #[test]
3991 fn hscroll_by_explicit_cols_moves_left_col() {
3992 let mut v = Viewport::new(80, 24, "t".into());
3994 v.toggle_chop(); v.hscroll_right_cols(12);
3996 assert_eq!(v.left_col(), 12);
3997 v.hscroll_right_cols(12);
3998 assert_eq!(v.left_col(), 24);
3999 v.hscroll_left_cols(12);
4000 assert_eq!(v.left_col(), 12);
4001 v.hscroll_left_cols(99);
4002 assert_eq!(v.left_col(), 0); }
4004
4005 #[test]
4006 fn hscroll_resets_to_zero_when_wrap_turned_on() {
4007 let mut v = Viewport::new(80, 24, "t".into());
4008 v.toggle_chop(); v.hscroll_right_step();
4010 assert_eq!(v.left_col(), 8);
4011 v.toggle_chop(); assert_eq!(v.left_col(), 0);
4013 }
4014
4015 #[test]
4016 fn reset_hscroll_zeroes_left_col() {
4017 let mut v = Viewport::new(80, 24, "t".into());
4019 v.toggle_chop();
4020 v.hscroll_right_step();
4021 assert_eq!(v.left_col(), 8);
4022 v.reset_hscroll();
4023 assert_eq!(v.left_col(), 0);
4024 }
4025
4026 #[test]
4029 fn reconstruct_picks_up_state_from_prior_lines() {
4030 let m = MockSource::new();
4031 m.append(b"\x1b[31mline 1\n");
4032 m.append(b"line 2 (still red, no reset)\n");
4033 m.append(b"line 3\n");
4034 let mut idx = LineIndex::new();
4035 idx.extend_to_end(&m);
4036 let state = reconstruct_render_state(&m, &idx, 2);
4037 assert_eq!(
4038 state.style.fg,
4039 Some(crate::ansi::Color::Ansi(1)),
4040 "red SGR from line 0 should persist to line 2"
4041 );
4042 }
4043
4044 #[test]
4045 fn reconstruct_respects_reset_between_lines() {
4046 let m = MockSource::new();
4047 m.append(b"\x1b[31mline 1\x1b[0m\n");
4048 m.append(b"line 2 (default)\n");
4049 let mut idx = LineIndex::new();
4050 idx.extend_to_end(&m);
4051 let state = reconstruct_render_state(&m, &idx, 1);
4052 assert_eq!(state.style.fg, None);
4053 }
4054
4055 #[test]
4056 fn reconstruct_caps_walkback_at_max_lines() {
4057 let m = MockSource::new();
4058 m.append(b"\x1b[31mvery early\n");
4059 for _ in 0..300 {
4060 m.append(b"line\n");
4061 }
4062 let mut idx = LineIndex::new();
4063 idx.extend_to_end(&m);
4064 let state = reconstruct_render_state(&m, &idx, 290);
4067 assert_eq!(state.style.fg, None);
4068 }
4069
4070 #[test]
4071 fn or_groups_narrow_within_required_line_mode() {
4072 let mut raw = crate::or::OrSpecRaw::new();
4073 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
4074 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
4075 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
4076 let mut v = Viewport::new(80, 24, "t".into());
4077 v.set_or_groups(og);
4078 assert!(v.or_active());
4079 assert!(v.line_passes(b"login failed"));
4080 assert!(v.line_passes(b"access denied"));
4081 assert!(!v.line_passes(b"login ok"));
4082 }
4083
4084 #[test]
4085 fn status_shows_or_indicator_when_active() {
4086 let mut raw = crate::or::OrSpecRaw::new();
4087 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
4088 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
4089 let (m, mut idx) = setup(b"x\ny\nx\n");
4090 idx.extend_to_end(&m);
4091 let mut v = Viewport::new(80, 5, "f".into());
4092 v.set_or_groups(og);
4093 v.extend_visible_lines(&idx, &m);
4094 let status = v.format_status(&idx, &m);
4095 assert!(status.contains("[or]"), "expected [or] in status: {status}");
4096 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
4097 }
4098
4099 #[test]
4100 fn status_shows_col_offset_when_scrolled() {
4101 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
4103 let (m, mut idx) = setup(content);
4104 let mut v = Viewport::new(10, 3, "t".into());
4105 v.toggle_chop(); v.hscroll_right_step(); let f = v.frame(&m, &mut idx);
4108 assert!(
4109 f.status.contains('\u{00bb}'),
4110 "expected » in status after hscroll_right_step, got: {}",
4111 f.status
4112 );
4113 }
4114
4115 #[test]
4116 fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
4117 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
4126 let (m, mut idx) = setup(content);
4127
4128 let mut v = Viewport::new(10, 3, "t".into());
4130 v.toggle_chop(); let frame0 = v.frame(&m, &mut idx);
4134 assert_eq!(
4135 frame0.body[0][0],
4136 Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4137 "at left_col=0 first cell should be 'A'"
4138 );
4139 assert!(
4141 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
4142 "no left marker expected at left_col=0"
4143 );
4144
4145 v.hscroll_right_step();
4147 assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
4148
4149 let frame1 = v.frame(&m, &mut idx);
4150 assert_eq!(
4152 frame1.body[0][0],
4153 Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
4154 "after scrolling right, first cell should be the '<' left marker"
4155 );
4156 assert_eq!(
4160 frame1.body[0][1],
4161 Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4162 "second cell should be 'J' (display column left_col+1 = 9)"
4163 );
4164 }
4165}