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_ansi_mode_cells(&mut self) {
758 if matches!(self.ansi_mode, crate::render::AnsiMode::Raw) {
759 self.ansi_mode = crate::render::AnsiMode::Interpret;
760 }
761 }
762
763 pub fn set_source_label(&mut self, label: String) {
764 self.source_label = label;
765 }
766
767 pub fn source_label_clone(&self) -> String {
768 self.source_label.clone()
769 }
770
771 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
776 let range = idx.line_range(line_n, src);
777 let raw = src.bytes(range);
778 if let Some(r) = self.display.as_ref() {
779 if let Some(rendered) = r.render_line(&raw) {
780 return std::borrow::Cow::Owned(rendered.into_bytes());
781 }
782 }
783 raw
784 }
785
786 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
790 let compiled = self.case_mode.apply_to_pattern(&raw);
791 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
792 self.search = Some(SearchState { raw, regex, direction });
793 Ok(())
794 }
795
796 pub fn clear_search(&mut self) { self.search = None; }
797
798 pub fn search_active(&self) -> bool { self.search.is_some() }
799
800 pub fn search_direction(&self) -> SearchDirection {
801 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
802 }
803
804 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
808 if idx.records_mode() {
809 self.search_repeat_records(src, idx, reverse)
810 } else {
811 self.search_repeat_lines(src, idx, reverse)
812 }
813 }
814
815 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
817 let Some(s) = self.search.as_ref() else { return false; };
818 let forward = matches!(
819 (s.direction, reverse),
820 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
821 );
822 idx.extend_to_end(src);
823 let pattern = s.regex.clone();
824 if self.hide_mode() {
825 self.extend_visible_lines(idx, src);
826 self.search_step_in_visible(&pattern, src, idx, forward)
827 } else {
828 self.search_step_in_logical(&pattern, src, idx, forward)
829 }
830 }
831
832 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
836 let Some(s) = self.search.as_ref() else { return false; };
837 let forward = matches!(
838 (s.direction, reverse),
839 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
840 );
841 let pattern = s.regex.clone();
842 idx.extend_to_end(src);
843
844 let total = idx.record_count();
845 if total == 0 { return false; }
846
847 let cur_record = idx.line_to_record(self.top_line);
848
849 let range: Box<dyn Iterator<Item = usize>> = if forward {
850 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
851 } else {
852 let earlier: Vec<usize> = (0..cur_record).rev().collect();
853 let later: Vec<usize> = (cur_record..total).rev().collect();
854 Box::new(earlier.into_iter().chain(later))
855 };
856
857 for r in range {
858 let bytes = idx.record_bytes_stripped(r, src);
859 let text = String::from_utf8_lossy(&bytes);
860 if pattern.is_match(&text) {
861 let line_range = idx.record_line_range(r);
862 self.top_line = line_range.start;
863 self.top_row = 0;
864 return true;
865 }
866 }
867 false
868 }
869
870 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
871 let display = self.line_display_bytes(src, idx, line_n);
876 let bytes = crate::ansi::strip_sgr(&display);
877 match std::str::from_utf8(&bytes) {
878 Ok(s) => pattern.is_match(s),
879 Err(_) => false,
880 }
881 }
882
883 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
884 let total = idx.line_count();
885 if total == 0 { return false; }
886 let start = self.top_line;
887 for offset in 1..=total {
890 let line_n = if forward {
891 (start + offset) % total
892 } else {
893 (start + total - offset) % total
894 };
895 if self.line_matches(pattern, src, idx, line_n) {
896 self.top_line = line_n;
897 self.top_row = 0;
898 return true;
899 }
900 }
901 false
902 }
903
904 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
905 let total = self.visible_lines.len();
906 if total == 0 { return false; }
907 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
909 for offset in 1..=total {
910 let visible_idx = if forward {
911 (cur + offset) % total
912 } else {
913 (cur + total - offset) % total
914 };
915 let line_n = self.visible_lines[visible_idx];
916 if self.line_matches(pattern, src, idx, line_n) {
917 self.top_line = line_n;
918 self.top_row = 0;
919 return true;
920 }
921 }
922 false
923 }
924
925 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
926 self.filter = filter;
927 self.visible_lines.clear();
928 self.visible_scanned = 0;
929 self.top_line = 0;
931 self.top_row = 0;
932 self.left_col = 0;
933 }
934
935 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
936 self.grep = grep;
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 set_or_groups(&mut self, or_groups: OrGroups) {
945 self.or_groups = or_groups;
946 self.visible_lines.clear();
947 self.visible_scanned = 0;
948 self.top_line = 0;
949 self.top_row = 0;
950 self.left_col = 0;
951 }
952
953 pub fn or_active(&self) -> bool {
954 self.or_groups.is_active()
955 }
956
957 pub fn grep_active(&self) -> bool { self.grep.is_some() }
958
959 pub fn set_dim_mode(&mut self, on: bool) {
960 self.dim_mode = on;
961 self.visible_lines.clear();
965 self.visible_scanned = 0;
966 }
967
968 pub fn filter_active(&self) -> bool { self.filter.is_some() }
969
970 pub fn dim_mode(&self) -> bool { self.dim_mode }
971
972 fn hide_mode(&self) -> bool {
973 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
974 && !self.dim_mode
975 }
976
977 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
982 if !self.hide_mode() {
983 return;
984 }
985 if idx.records_mode() {
986 self.extend_visible_lines_records(idx, src);
987 } else {
988 self.extend_visible_lines_per_line(idx, src);
989 }
990 }
991
992 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
994 let total = idx.line_count();
995 while self.visible_scanned < total {
996 let line_n = self.visible_scanned;
997 let bytes = idx.line_bytes_stripped(line_n, src);
998 if self.line_passes(&bytes) {
999 self.visible_lines.push(line_n);
1000 }
1001 self.visible_scanned += 1;
1002 }
1003 }
1004
1005 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
1012 self.visible_lines.clear();
1013 self.visible_scanned = 0; let total_records = idx.record_count();
1015 for r in 0..total_records {
1016 if self.record_passes(idx, src, r) {
1017 for line_n in idx.record_line_range(r) {
1018 self.visible_lines.push(line_n);
1019 }
1020 }
1021 }
1022 }
1023
1024 fn line_passes(&self, line: &[u8]) -> bool {
1030 let filter_ok = match self.filter.as_ref() {
1031 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
1032 None => true,
1033 };
1034 let grep_ok = match self.grep.as_ref() {
1035 Some(g) => g.matches(line),
1036 None => true,
1037 };
1038 filter_ok && grep_ok && self.or_groups.matches_line(line)
1039 }
1040
1041 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
1049 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
1050 let bytes = if need {
1051 Some(idx.record_bytes_stripped(r, src))
1052 } else {
1053 None
1054 };
1055 let filter_ok = match self.filter.as_ref() {
1056 Some(f) => matches!(
1057 f.evaluate_record(bytes.as_deref().unwrap()),
1058 FilterMatch::Matched,
1059 ),
1060 None => true,
1061 };
1062 let grep_ok = match self.grep.as_ref() {
1063 Some(g) => g.matches(bytes.as_deref().unwrap()),
1064 None => true,
1065 };
1066 let or_ok = if self.or_groups.is_active() {
1067 self.or_groups.matches_record(bytes.as_deref().unwrap())
1068 } else {
1069 true
1070 };
1071 filter_ok && grep_ok && or_ok
1072 }
1073
1074 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
1078 if !self.dim_mode {
1079 return false;
1080 }
1081 if idx.records_mode() {
1082 let r = idx.line_to_record(line_n);
1083 !self.record_passes(idx, src, r)
1084 } else {
1085 let bytes = idx.line_bytes_stripped(line_n, src);
1086 !self.line_passes(&bytes)
1087 }
1088 }
1089
1090 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
1098 let body_rows = self.body_rows() as usize;
1099 if self.hide_mode() && !self.visible_lines.is_empty() {
1100 let cur = self
1101 .visible_lines
1102 .iter()
1103 .position(|&l| l >= self.top_line)
1104 .unwrap_or(self.visible_lines.len().saturating_sub(1));
1105 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
1106 return self.visible_lines[last_pos];
1107 }
1108 let total = idx.line_count();
1109 if total == 0 {
1110 return self.top_line;
1111 }
1112 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
1113 }
1114
1115 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
1116
1117 pub fn follow_mode(&self) -> bool { self.follow_mode }
1118
1119 pub fn suspend_follow_if(&mut self, flag: bool) {
1124 if flag {
1125 self.follow_mode = false;
1126 }
1127 }
1128
1129 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
1130
1131 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
1132
1133 pub fn live_mode(&self) -> bool { self.live_mode }
1134
1135 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
1136
1137 pub fn set_prettify_label(&mut self, label: Option<String>) {
1140 self.prettify_label = label;
1141 }
1142
1143 pub fn set_format_label(&mut self, label: Option<String>) {
1146 self.format_label = label;
1147 }
1148
1149 pub fn invalidate_filter_cache(&mut self) {
1154 self.visible_lines.clear();
1155 self.visible_scanned = 0;
1156 }
1157
1158 pub fn clamp_top_line(&mut self, line_count: usize) {
1161 if line_count == 0 {
1162 self.top_line = 0;
1163 self.top_row = 0;
1164 } else if self.top_line >= line_count {
1165 self.top_line = line_count - 1;
1166 self.top_row = 0;
1167 }
1168 }
1169
1170 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
1174 #[cfg(feature = "image")]
1175 if self.image_mode {
1176 return self.is_at_bottom_image();
1177 }
1178 if self.hide_mode() {
1179 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
1183 } else {
1184 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
1188 }
1189 }
1190
1191 fn gutter_width(&self, idx: &LineIndex) -> u16 {
1193 if !self.show_line_numbers { return 0; }
1194 let n = idx.line_count().max(1);
1195 let digits = (n as f64).log10().floor() as u16 + 1;
1196 digits + 1
1197 }
1198
1199 fn render_opts(&self, gutter: u16) -> RenderOpts {
1200 let mut o = self.opts.clone();
1201 o.cols = self.cols.saturating_sub(self.status_col_width() + gutter);
1204 o.mode = self.ansi_mode;
1205 o.left_col = self.left_col; o
1207 }
1208
1209 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1210 #[cfg(feature = "image")]
1211 if self.image_mode {
1212 return self.frame_image();
1213 }
1214 if self.hex_mode {
1215 return self.frame_hex(src);
1216 }
1217 let body_rows = self.body_rows() as usize;
1218 idx.extend_to_line(self.top_line + body_rows + 1, src);
1219
1220 if self.left_col > 0 && self.hscroll_active() {
1223 let gutter_for_clamp = self.status_col_width() + self.gutter_width(idx);
1224 let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1225 let mut width_opts = self.opts.clone();
1228 width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1229 width_opts.mode = self.ansi_mode;
1230 width_opts.left_col = 0;
1231 let mut widest = 0usize;
1232 let total_lines_for_clamp = idx.line_count();
1233 if self.hide_mode() {
1234 let hide_pos = self.visible_lines.iter()
1235 .position(|&l| l >= self.top_line)
1236 .unwrap_or(self.visible_lines.len());
1237 let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1238 for vi in hide_pos..end_vi {
1239 let ln = self.visible_lines[vi];
1240 let bytes = self.line_display_bytes(src, idx, ln);
1241 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1242 }
1243 } else {
1244 let start = self.top_line.max(self.header_lines);
1245 let end = (start + body_rows).min(total_lines_for_clamp);
1246 for ln in start..end {
1247 let bytes = self.line_display_bytes(src, idx, ln);
1248 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1249 }
1250 }
1251 self.left_col = self.left_col.min(widest.saturating_sub(avail));
1252 }
1253
1254 let gutter = self.gutter_width(idx);
1255 let scol = self.status_col_width();
1256 let r_opts = self.render_opts(gutter);
1257
1258 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1262 reconstruct_render_state(src, idx, self.top_line)
1263 } else {
1264 crate::render::RenderState::default()
1265 };
1266 self.render_state = render_state.clone();
1268 self.render_state_for = self.top_line;
1269
1270 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1271 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1272 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1273 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1274 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1275 let hide = self.hide_mode();
1277 let total_lines = idx.line_count();
1278
1279 let header_rows = if !hide && !raw_passthrough {
1286 self.header_lines.min(body_rows).min(total_lines)
1287 } else {
1288 0
1289 };
1290 if header_rows > 0 {
1291 for hl in 0..header_rows {
1292 let raw = src.bytes(idx.line_range(hl, src));
1293 let display_bytes = if let Some(r) = self.display.as_ref() {
1294 match r.render_line(&raw) {
1295 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1296 None => raw.clone(),
1297 }
1298 } else {
1299 raw.clone()
1300 };
1301 let rows = render_line(&display_bytes, &r_opts, None);
1302 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1303 let mut v = Vec::with_capacity(self.cols as usize);
1304 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1305 v
1306 });
1307 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1308 if scol > 0 {
1309 let matched = self.search.as_ref()
1310 .is_some_and(|s| !find_row_highlights(&content_row, &s.regex).is_empty());
1311 let glyph = self.status_glyph(hl, matched);
1312 full.push(Self::status_cell(glyph));
1313 }
1314 if gutter > 0 {
1315 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1316 for c in label.chars() {
1317 full.push(Cell::Char {
1318 ch: c,
1319 width: 1,
1320 style: crate::ansi::Style::default(),
1321 hyperlink: None,
1322 });
1323 }
1324 }
1325 full.append(&mut content_row);
1326 body.push(full);
1327 row_styles.push(RowStyle::Normal);
1328 highlights.push(Vec::new());
1329 raw_rows.push(None);
1330 }
1331 }
1332
1333 let mut hide_pos = if hide {
1335 self.visible_lines
1336 .iter()
1337 .position(|&l| l >= self.top_line)
1338 .unwrap_or(self.visible_lines.len())
1339 } else {
1340 0
1341 };
1342 let mut line_n = if hide {
1343 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1344 } else {
1345 self.top_line.max(self.header_lines)
1348 };
1349 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1350
1351 while body.len() < body_rows {
1352 if line_n >= total_lines {
1353 let mut row = Vec::with_capacity(self.cols as usize);
1354 if scol > 0 {
1355 for _ in 0..scol { row.push(Cell::Empty); }
1356 }
1357 if gutter > 0 {
1358 for _ in 0..gutter { row.push(Cell::Empty); }
1359 }
1360 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1361 body.push(row);
1362 row_styles.push(RowStyle::Normal);
1363 highlights.push(Vec::new());
1364 raw_rows.push(None);
1365 line_n += 1;
1366 continue;
1367 }
1368 let raw = src.bytes(idx.line_range(line_n, src));
1371 if self.squeeze_blanks && line_is_blank(&raw) {
1376 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1377 let prev = src.bytes(idx.line_range(p, src));
1378 line_is_blank(&prev)
1379 });
1380 if prev_blank {
1381 line_n += 1;
1382 continue;
1383 }
1384 }
1385 let display_bytes = if let Some(r) = self.display.as_ref() {
1386 match r.render_line(&raw) {
1387 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1388 None => raw.clone(),
1389 }
1390 } else {
1391 raw.clone()
1392 };
1393 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1394 Some(&mut render_state)
1395 } else {
1396 None
1397 };
1398 let rows = render_line(&display_bytes, &r_opts, state_arg);
1399 let style = if self.filter.is_some() || self.grep.is_some() {
1400 if self.dim_mode {
1401 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1402 } else {
1403 RowStyle::Normal
1405 }
1406 } else {
1407 RowStyle::Normal
1408 };
1409
1410 let mut first_emitted_for_this_line = true;
1411 let mut status_first_row_idx: Option<usize> = None;
1416 let mut line_matched = false;
1417 for (i, mut content_row) in rows.into_iter().enumerate() {
1418 if i < skip { continue; }
1419 if body.len() >= body_rows { break; }
1420 if scol > 0 && !line_matched {
1427 if let Some(s) = self.search.as_ref() {
1428 if !find_row_highlights(&content_row, &s.regex).is_empty() {
1429 line_matched = true;
1430 }
1431 }
1432 }
1433 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1434 if scol > 0 {
1435 if status_first_row_idx.is_none() {
1436 status_first_row_idx = Some(body.len());
1437 }
1438 full.push(Self::status_cell(' '));
1441 }
1442 if gutter > 0 {
1443 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1444 for c in label.chars() {
1445 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1446 }
1447 }
1448 full.append(&mut content_row);
1449 if self.left_col > 0 && !self.opts.wrap {
1454 let marker_col = (scol + gutter) as usize;
1455 if let Some(cell) = full.get_mut(marker_col) {
1456 *cell = Cell::Char {
1457 ch: '<',
1458 width: 1,
1459 style: crate::ansi::Style { dim: true, ..Default::default() },
1460 hyperlink: None,
1461 };
1462 }
1463 }
1464 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1468 find_row_highlights(&full, &s.regex)
1469 } else {
1470 Vec::new()
1471 };
1472 body.push(full);
1473 row_styles.push(style);
1474 highlights.push(row_highlights);
1475 if raw_passthrough {
1476 if first_emitted_for_this_line {
1477 raw_rows.push(Some(raw.to_vec()));
1482 first_emitted_for_this_line = false;
1483 } else {
1484 raw_rows.push(Some(Vec::new()));
1485 }
1486 } else {
1487 raw_rows.push(None);
1488 }
1489 }
1490 if let Some(fi) = status_first_row_idx {
1494 let glyph = self.status_glyph(line_n, line_matched);
1495 if glyph != ' ' {
1496 if let Some(cell) = body[fi].first_mut() {
1497 *cell = Self::status_cell(glyph);
1498 }
1499 }
1500 }
1501 skip = 0;
1502 if hide {
1504 hide_pos += 1;
1505 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1506 } else {
1507 line_n += 1;
1508 }
1509 }
1510
1511 self.render_state_for = usize::MAX;
1514
1515 let status = self.format_status(idx, src);
1516 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1517 }
1518
1519 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1520 if let Some(p) = self.prompt.as_ref() {
1521 let ctx = self.build_prompt_context(idx, src);
1522 return p.render(&ctx);
1523 }
1524 let body_rows = self.body_rows() as usize;
1525 let total = idx.line_count();
1526 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1529 let visible_total = self.visible_lines.len();
1530 let cur = self
1532 .visible_lines
1533 .iter()
1534 .position(|&l| l >= self.top_line)
1535 .unwrap_or(visible_total);
1536 let top = cur + 1;
1537 let bottom = (cur + body_rows).min(visible_total.max(1));
1538 let total_str = if src.is_complete() {
1539 format!("{visible_total}/{total}")
1540 } else {
1541 format!("{visible_total}/{total}+")
1542 };
1543 (top, bottom, visible_total, total_str)
1544 } else {
1545 let top = self.top_line + 1;
1546 let bottom = (self.top_line + body_rows).min(total.max(1));
1547 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1548 (top, bottom, total, total_str)
1549 };
1550 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1551 let bottom_line = self.bottom_visible_line(idx);
1555 let (line_prefix, records_block) = if idx.records_mode() {
1556 let line_total = idx.line_count();
1557 let rec_total = idx.record_count();
1558 let rec_block = if line_total == 0 || rec_total == 0 {
1559 format!("R0-0/{}", rec_total)
1560 } else {
1561 let rec_top = idx.line_to_record(self.top_line) + 1;
1562 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1563 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1564 (rec_top, rec_top)
1568 } else {
1569 (rec_top, rec_bottom)
1570 };
1571 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1572 };
1573 ("L", Some(rec_block))
1574 } else {
1575 ("", None)
1576 };
1577 let middle = match records_block {
1578 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1579 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1580 };
1581 let label_with_index = match self.file_index {
1582 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1583 None => self.source_label.clone(),
1584 };
1585 let mut s = format!("{} {}", label_with_index, middle);
1586 if !self.hide_mode() && self.top_row > 0 {
1591 let line_rows = if total > 0 {
1592 let bytes = self.line_display_bytes(src, idx, self.top_line);
1593 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1594 } else { 1 };
1595 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1596 }
1597 if self.left_col > 0 {
1598 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1599 }
1600 if let Some(f) = self.filter.as_ref() {
1601 s.push_str(&format!(" [{}]", f.format_name));
1602 }
1603 if self.grep.is_some() {
1604 s.push_str(" [grep]");
1605 }
1606 if self.or_groups.is_active() {
1607 s.push_str(" [or]");
1608 }
1609 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1610 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1611 }
1612 if let Some(sr) = self.search.as_ref() {
1613 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1614 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1615 }
1616 if let Some(label) = self.prettify_label.as_ref() {
1617 s.push_str(&format!(" [pretty:{label}]"));
1618 }
1619 if self.live_mode { s.push_str(" (L)"); }
1620 if self.follow_mode {
1621 if let Some((msg, _)) = self.status_flash.as_ref() {
1622 s.push_str(" ");
1623 s.push_str(msg);
1624 } else if self.is_idle() {
1625 s.push_str(" (F idle)");
1626 } else {
1627 s.push_str(" (F)");
1628 }
1629 }
1630 if let Some(msg) = self.preprocess_failure.as_ref() {
1631 let first_line = msg.lines().next().unwrap_or("");
1632 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1633 }
1634 let tag_suffix = match &self.tag_active {
1635 Some((name, cur, total)) if *total > 1 => {
1636 format!(" [tag: {name} ({cur}/{total})]")
1637 }
1638 _ => String::new(),
1639 };
1640 s.push_str(&tag_suffix);
1641 let used = s.chars().count();
1644 let hint = ":help";
1645 if (self.cols as usize) > used + 1 + hint.chars().count() {
1646 let pad = self.cols as usize - used - hint.chars().count();
1647 s.push_str(&" ".repeat(pad));
1648 s.push_str(hint);
1649 } else {
1650 s.push(' ');
1651 s.push_str(hint);
1652 }
1653 s
1654 }
1655
1656 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1657 use crate::prompt::PromptContext;
1658
1659 let body_rows = self.body_rows() as usize;
1660 let total = idx.line_count();
1661 let top = self.top_line + 1;
1662 let bottom = (self.top_line + body_rows).min(total.max(1));
1663 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1664 let bottom_line = self.bottom_visible_line(idx);
1665
1666 let records_mode = idx.records_mode();
1667 let (rec_top, rec_bottom, rec_total) = if records_mode {
1668 let rt = idx.line_to_record(self.top_line) + 1;
1669 let rb_raw = idx.line_to_record(bottom_line) + 1;
1670 let rb = if rb_raw < rt { rt } else { rb_raw };
1671 (rt, rb, idx.record_count())
1672 } else {
1673 (0, 0, 0)
1674 };
1675
1676 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1677 let line_rows = if total > 0 {
1678 let bytes = self.line_display_bytes(src, idx, self.top_line);
1679 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1680 } else { 1 };
1681 format!("+{}/{}", self.top_row, line_rows)
1682 } else {
1683 String::new()
1684 };
1685
1686 let col_offset = if self.left_col > 0 { format!(" \u{00bb}{}", self.left_col) } else { String::new() };
1687
1688 let format_tag = self.format_label.as_ref()
1689 .map(|n| format!(" [{}]", n))
1690 .unwrap_or_default();
1691 let filter_tag = self.filter.as_ref()
1692 .map(|f| format!(" [{}]", f.format_name))
1693 .unwrap_or_default();
1694 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1695 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1696 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1697 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1698 } else {
1699 String::new()
1700 };
1701 let search_tag = self.search.as_ref()
1702 .map(|s| {
1703 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1704 format!(" [{}{}]", p, s.raw)
1705 })
1706 .unwrap_or_default();
1707 let pretty_tag = self.prettify_label.as_ref()
1708 .map(|l| format!(" [pretty:{l}]"))
1709 .unwrap_or_default();
1710 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1711 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1712 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1713 .map(|msg| {
1714 let first_line = msg.lines().next().unwrap_or("");
1715 format!(" [preprocess-failed: {}]", first_line)
1716 })
1717 .unwrap_or_default();
1718
1719 let file_index_tag = match self.file_index {
1720 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1721 None => String::new(),
1722 };
1723
1724 let tag_tag = match &self.tag_active {
1725 Some((name, cur, total)) if *total > 1 => {
1726 format!(" [tag: {name} ({cur}/{total})]")
1727 }
1728 _ => String::new(),
1729 };
1730
1731 PromptContext {
1732 label: self.source_label.clone(),
1733 top,
1734 bottom,
1735 total,
1736 pct: pct.min(100) as u8,
1737 rec_top,
1738 rec_bottom,
1739 rec_total,
1740 records_mode,
1741 wrap_offset,
1742 col_offset,
1743 format_tag,
1744 filter_tag,
1745 grep_tag,
1746 or_tag,
1747 hide_tag,
1748 search_tag,
1749 pretty_tag,
1750 live_tag,
1751 follow_tag,
1752 preprocess_failed_tag,
1753 file_index_tag,
1754 tag_tag,
1755 }
1756 }
1757
1758 fn frame_hex(&self, src: &dyn Source) -> Frame {
1759 use crate::hex::format_hex_row;
1760 use crate::render::{render_line, Cell, RenderOpts};
1761
1762 let body_rows = self.rows.saturating_sub(1) as usize;
1763 let total_bytes = src.len();
1764 let total_hex_rows = total_bytes.div_ceil(16);
1765
1766 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1767 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1768 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1769
1770 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 };
1771
1772 for row_idx in 0..body_rows {
1773 let hex_row = self.top_line + row_idx;
1774 if hex_row >= total_hex_rows {
1775 body.push(vec![Cell::Empty; self.cols as usize]);
1776 } else {
1777 let offset = hex_row * 16;
1778 let end = (offset + 16).min(total_bytes);
1779 let bytes_cow = src.bytes(offset..end);
1780 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1781 let rows = render_line(text.as_bytes(), &opts, None);
1782 body.push(rows.into_iter().next().unwrap_or_else(|| {
1783 vec![Cell::Empty; self.cols as usize]
1784 }));
1785 }
1786 row_styles.push(RowStyle::Normal);
1787 highlights.push(Vec::new());
1788 }
1789
1790 let status = self.format_status_hex(src);
1791 let raw_rows = vec![None; body.len()];
1792 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1793 }
1794
1795 fn format_status_hex(&self, src: &dyn Source) -> String {
1796 let total_bytes = src.len();
1797 let body_rows = self.rows.saturating_sub(1) as usize;
1798 let top_byte = self.top_line * 16;
1800 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1803 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1804 let label_with_index = match self.file_index {
1805 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1806 None => self.source_label.clone(),
1807 };
1808 let tag_suffix = match &self.tag_active {
1809 Some((name, cur, total)) if *total > 1 => {
1810 format!(" [tag: {name} ({cur}/{total})]")
1811 }
1812 _ => String::new(),
1813 };
1814 format!(
1815 "{} off {}-{}/{} {}% [hex]{}",
1816 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1817 )
1818 }
1819
1820 #[cfg(feature = "image")]
1821 fn frame_image(&mut self) -> Frame {
1822 use crate::render::Cell;
1823 if self.image_protocol != ImageProtocol::Ascii {
1824 return self.frame_image_protocol();
1825 }
1826 let body_rows = self.body_rows() as usize;
1827 let cols = self.cols as usize;
1828 let img = match self.current_image() {
1829 Some(i) => i,
1830 None => {
1831 let body = vec![vec![Cell::Empty; cols]; body_rows];
1832 return Frame {
1833 body,
1834 row_styles: vec![RowStyle::Normal; body_rows],
1835 highlights: vec![Vec::new(); body_rows],
1836 status: self.image_format.clone(),
1837 status_style: self.status_style,
1838 raw_rows: vec![None; body_rows],
1839 image_blob: None,
1840 };
1841 }
1842 };
1843 let color = !self.image_no_color;
1844 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1845 let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1846 let max_off = grid_w.saturating_sub(cols);
1847 if self.left_col > max_off { self.left_col = max_off; }
1848 let off = self.left_col;
1849 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1850 for r in 0..body_rows {
1851 let gi = self.top_line + r;
1852 if gi < grid.len() {
1853 let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1854 while row.len() < cols { row.push(Cell::Empty); }
1855 body.push(row);
1856 } else {
1857 body.push(vec![Cell::Empty; cols]);
1858 }
1859 }
1860 let status = self.format_status_image(grid.len());
1861 Frame {
1862 body,
1863 row_styles: vec![RowStyle::Normal; body_rows],
1864 highlights: vec![Vec::new(); body_rows],
1865 status,
1866 status_style: self.status_style,
1867 raw_rows: vec![None; body_rows],
1868 image_blob: None,
1869 }
1870 }
1871
1872 #[cfg(feature = "image")]
1873 fn format_status_image(&self, total_rows: usize) -> String {
1874 let body = self.body_rows() as usize;
1875 let top = self.top_line + 1;
1876 let bottom = (self.top_line + body).min(total_rows.max(1));
1877 let dims = self.current_image().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1878 let mut s = format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1879 if self.left_col > 0 {
1880 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1881 }
1882 s.push_str(&self.anim_badge());
1883 s
1884 }
1885
1886 #[cfg(feature = "image")]
1887 fn frame_image_protocol(&mut self) -> Frame {
1888 use crate::render::Cell;
1889 let body_rows = self.body_rows() as usize;
1890 let cols = self.cols as usize;
1891 let status_style = self.status_style;
1892 let blank = |status: String, blob: Option<Vec<u8>>| Frame {
1893 body: vec![vec![Cell::Empty; cols]; body_rows],
1894 row_styles: vec![RowStyle::Normal; body_rows],
1895 highlights: vec![Vec::new(); body_rows],
1896 status,
1897 status_style,
1898 raw_rows: vec![None; body_rows],
1899 image_blob: blob,
1900 };
1901 let (iw, ih) = match self.current_image() {
1902 Some(i) => i.dimensions(),
1903 None => return blank(self.image_format.clone(), None),
1904 };
1905 let ch = self.cell_px.1.max(1) as u32;
1906 let (scaled_w, scaled_h) = protocol_scaled_dims(iw, ih, self.cols, self.cell_px, self.image_width);
1907
1908 let need = self.image_scaled.as_ref().map(|(c, _)| *c != scaled_w as u16).unwrap_or(true);
1910 if need {
1911 let scaled = {
1912 let src = self.current_image().unwrap();
1913 image::imageops::resize(src, scaled_w, scaled_h, image::imageops::FilterType::Triangle)
1914 };
1915 self.image_scaled = Some((scaled_w as u16, scaled));
1916 }
1917
1918 let total_rows = protocol_occupied_rows(iw, ih, self.cols, self.cell_px, self.image_width);
1919 let max_top = total_rows.saturating_sub(body_rows);
1920 if self.top_line > max_top { self.top_line = max_top; }
1921 self.left_col = 0; let y0 = (self.top_line as u32 * ch).min(scaled_h);
1924 let band_h = ((body_rows as u32) * ch).min(scaled_h - y0).max(1);
1925 let scaled = &self.image_scaled.as_ref().unwrap().1;
1926 let band = image::imageops::crop_imm(scaled, 0, y0, scaled_w, band_h).to_image();
1927 let blob = match self.image_protocol {
1928 ImageProtocol::Kitty => crate::image_protocol::encode_kitty(&band),
1929 ImageProtocol::Sixel => crate::image_protocol::encode_sixel(&band),
1930 ImageProtocol::Ascii => unreachable!("frame_image_protocol only entered for non-Ascii"),
1931 };
1932 let status = self.format_status_image_protocol(total_rows);
1933 blank(status, Some(blob))
1934 }
1935
1936 #[cfg(feature = "image")]
1937 fn format_status_image_protocol(&self, total_rows: usize) -> String {
1938 let body = self.body_rows() as usize;
1939 let top = self.top_line + 1;
1940 let bottom = (self.top_line + body).min(total_rows.max(1));
1941 let proto = match self.image_protocol {
1942 ImageProtocol::Kitty => "kitty",
1943 ImageProtocol::Sixel => "sixel",
1944 ImageProtocol::Ascii => "ascii",
1945 };
1946 let dims = self.current_image().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1947 format!("{} {} {} [{}] rows {}-{}/{}{}", self.source_label, dims, self.image_format, proto, top, bottom, total_rows, self.anim_badge())
1948 }
1949
1950 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1955 if delta == 0 { return; }
1956 #[cfg(feature = "image")]
1957 if self.image_mode {
1958 self.scroll_lines(delta, src, idx);
1959 return;
1960 }
1961 if self.hide_mode() {
1962 self.extend_visible_lines(idx, src);
1966 let n = self.visible_lines.len();
1967 if n == 0 {
1968 self.top_line = 0;
1969 self.top_row = 0;
1970 return;
1971 }
1972 let vi = self
1973 .visible_lines
1974 .iter()
1975 .position(|&l| l >= self.top_line)
1976 .unwrap_or(n - 1);
1977 if delta > 0 {
1978 let target = (vi + delta as usize).min(n - 1);
1979 self.top_line = self.visible_lines[target];
1980 self.top_row = 0;
1981 } else {
1982 let back = (-delta) as usize;
1983 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1984 let extra_back = back.saturating_sub(consumed_for_snap);
1985 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1986 self.top_row = 0;
1987 }
1988 return;
1989 }
1990 if delta > 0 {
1991 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1992 let total = idx.line_count();
1993 if total == 0 { return; }
1994 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1995 self.top_line = target;
1996 self.top_row = 0;
1997 } else {
1998 let back = (-delta) as usize;
1999 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
2004 let extra_back = back.saturating_sub(consumed_for_snap);
2005 self.top_line = self.top_line.saturating_sub(extra_back);
2006 self.top_row = 0;
2007 }
2008 }
2009
2010 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
2011 if delta == 0 { return; }
2012 #[cfg(feature = "image")]
2013 if self.image_mode {
2014 let total = self.image_total_rows();
2015 let body = self.body_rows() as usize;
2016 let max_top = total.saturating_sub(body);
2017 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
2018 self.top_line = next as usize;
2019 self.top_row = 0;
2020 return;
2021 }
2022 if self.hide_mode() {
2023 self.extend_visible_lines(idx, src);
2027 let n = self.visible_lines.len();
2028 if n == 0 {
2029 self.top_line = 0;
2030 self.top_row = 0;
2031 return;
2032 }
2033 let mut vi = self
2034 .visible_lines
2035 .iter()
2036 .position(|&l| l >= self.top_line)
2037 .unwrap_or(n - 1);
2038 if self.visible_lines[vi] != self.top_line {
2041 self.top_row = 0;
2042 }
2043 self.top_line = self.visible_lines[vi];
2044 let r_opts = self.render_opts(self.gutter_width(idx));
2045 if delta > 0 {
2046 let mut remaining = delta as usize;
2047 while remaining > 0 {
2048 let line = self.visible_lines[vi];
2049 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2050 if self.top_row + 1 < rows {
2051 self.top_row += 1;
2052 } else if vi + 1 < n {
2053 self.top_row = 0;
2054 vi += 1;
2055 self.top_line = self.visible_lines[vi];
2056 } else {
2057 break;
2058 }
2059 remaining -= 1;
2060 }
2061 let anchor = self.hide_bottom_anchor(src, idx);
2062 if (self.top_line, self.top_row) > anchor {
2063 self.top_line = anchor.0;
2064 self.top_row = anchor.1;
2065 }
2066 } else {
2067 let mut remaining = (-delta) as usize;
2068 while remaining > 0 {
2069 if self.top_row > 0 {
2070 self.top_row -= 1;
2071 } else if vi > 0 {
2072 vi -= 1;
2073 self.top_line = self.visible_lines[vi];
2074 let line = self.visible_lines[vi];
2075 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2076 self.top_row = rows.saturating_sub(1);
2077 } else {
2078 break;
2079 }
2080 remaining -= 1;
2081 }
2082 }
2083 return;
2084 }
2085 if delta > 0 {
2086 let mut remaining = delta as usize;
2087 while remaining > 0 {
2088 idx.extend_to_line(self.top_line + 1, src);
2089 let total = idx.line_count();
2090 if total == 0 { break; }
2091 let bytes = self.line_display_bytes(src, idx, self.top_line);
2092 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2093 if self.top_row + 1 < line_rows {
2094 self.top_row += 1;
2095 } else if self.top_line + 1 < total {
2096 self.top_row = 0;
2097 self.top_line += 1;
2098 } else {
2099 break;
2100 }
2101 remaining -= 1;
2102 }
2103 if idx.scanned_through() >= src.len() {
2108 let anchor = self.bottom_anchor(src, idx);
2109 if (self.top_line, self.top_row) > anchor {
2110 self.top_line = anchor.0;
2111 self.top_row = anchor.1;
2112 }
2113 }
2114 } else {
2115 let mut remaining = (-delta) as usize;
2116 while remaining > 0 {
2117 if self.top_row > 0 {
2118 self.top_row -= 1;
2119 } else if self.top_line > 0 {
2120 self.top_line -= 1;
2121 let bytes = self.line_display_bytes(src, idx, self.top_line);
2122 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2123 self.top_row = line_rows.saturating_sub(1);
2124 } else {
2125 break;
2126 }
2127 remaining -= 1;
2128 }
2129 }
2130 }
2131
2132 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2133 let n = self.page_size
2134 .map(|p| p as i64)
2135 .unwrap_or_else(|| self.body_rows() as i64);
2136 self.scroll_lines(n, src, idx);
2137 }
2138
2139 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2140 let n = self.page_size
2141 .map(|p| p as i64)
2142 .unwrap_or_else(|| self.body_rows() as i64);
2143 self.scroll_lines(-n, src, idx);
2144 }
2145
2146 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2147 let n = (self.body_rows() / 2).max(1) as i64;
2148 self.scroll_lines(n, src, idx);
2149 }
2150
2151 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2152 let n = (self.body_rows() / 2).max(1) as i64;
2153 self.scroll_lines(-n, src, idx);
2154 }
2155
2156 pub fn goto_top(&mut self) {
2157 self.top_line = 0;
2158 self.top_row = 0;
2159 }
2160
2161 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2168 let body = self.body_rows() as usize;
2169 let total = idx.line_count();
2170 if total == 0 || body == 0 {
2171 return (0, 0);
2172 }
2173 let r_opts = self.render_opts(self.gutter_width(idx));
2174 let mut remaining = body;
2175 let mut line = total - 1;
2176 loop {
2177 let bytes = self.line_display_bytes(src, idx, line);
2178 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
2179 if line_rows >= remaining {
2180 return (line, line_rows - remaining);
2181 }
2182 remaining -= line_rows;
2183 if line == 0 {
2184 return (0, 0);
2185 }
2186 line -= 1;
2187 }
2188 }
2189
2190 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2195 let body = self.body_rows() as usize;
2196 let n = self.visible_lines.len();
2197 if n == 0 || body == 0 {
2198 return (0, 0);
2199 }
2200 let r_opts = self.render_opts(self.gutter_width(idx));
2201 let mut remaining = body;
2202 let mut vi = n - 1;
2203 loop {
2204 let line = self.visible_lines[vi];
2205 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2206 if rows >= remaining {
2207 return (line, rows - remaining);
2208 }
2209 remaining -= rows;
2210 if vi == 0 {
2211 return (self.visible_lines[0], 0);
2212 }
2213 vi -= 1;
2214 }
2215 }
2216
2217 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2218 #[cfg(feature = "image")]
2219 if self.image_mode {
2220 let body = self.body_rows() as usize;
2221 self.top_line = self.image_total_rows().saturating_sub(body);
2222 self.top_row = 0;
2223 return;
2224 }
2225 idx.extend_to_end(src);
2226 if self.hide_mode() {
2227 self.extend_visible_lines(idx, src);
2228 let (line, row) = self.hide_bottom_anchor(src, idx);
2229 self.top_line = line;
2230 self.top_row = row;
2231 } else {
2232 let (line, row) = self.bottom_anchor(src, idx);
2233 self.top_line = line;
2234 self.top_row = row;
2235 }
2236 }
2237
2238 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2240 idx.extend_to_line(n, src);
2241 let target = n.min(idx.line_count().saturating_sub(1));
2242 self.top_line = target;
2243 self.top_row = 0;
2244 }
2245
2246 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2248 while idx.record_count() <= n && idx.scanned_through() < src.len() {
2252 idx.extend_to_end(src);
2253 }
2254 if idx.record_count() == 0 {
2255 return;
2256 }
2257 let target = n.min(idx.record_count().saturating_sub(1));
2258 let line_range = idx.record_line_range(target);
2259 self.top_line = line_range.start;
2260 self.top_row = 0;
2261 }
2262
2263 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
2266 let p = p.min(100) as usize;
2267 let target_byte = src.len().saturating_mul(p) / 100;
2268 idx.extend_to_byte_for_query(src, target_byte);
2269 let line_n = idx.line_at_byte(target_byte)
2270 .or_else(|| {
2271 let lc = idx.line_count();
2273 if lc > 0 { Some(lc - 1) } else { None }
2274 })
2275 .unwrap_or(0);
2276 self.top_line = line_n;
2277 self.top_row = 0;
2278 }
2279
2280 pub fn top_line(&self) -> usize {
2282 self.top_line
2283 }
2284
2285 pub fn resize(&mut self, cols: u16, rows: u16) {
2286 self.cols = cols.max(1);
2287 self.rows = rows.max(2);
2288 self.opts.cols = self.cols;
2289 }
2290
2291 pub fn toggle_line_numbers(&mut self) {
2292 self.show_line_numbers = !self.show_line_numbers;
2293 }
2294
2295 pub fn toggle_chop(&mut self) {
2296 self.opts.wrap = !self.opts.wrap;
2297 if self.opts.wrap {
2298 self.left_col = 0;
2299 }
2300 }
2301
2302 const HSCROLL_STEP: usize = 8;
2303
2304 pub fn hscroll_active(&self) -> bool {
2308 #[cfg(feature = "image")]
2309 if self.current_image().is_some() {
2310 return true;
2311 }
2312 !self.opts.wrap
2313 && !self.hex_mode
2314 && self.ansi_mode != crate::render::AnsiMode::Raw
2315 }
2316
2317 fn hscroll_by(&mut self, delta: isize) {
2318 if !self.hscroll_active() {
2319 return;
2320 }
2321 self.left_col = (self.left_col as isize + delta).max(0) as usize;
2322 }
2324
2325 pub fn hscroll_left_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2326 pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2327 pub fn hscroll_left_step(&mut self) { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2328 pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2329
2330 pub fn hscroll_left_cols(&mut self, n: u16) { self.hscroll_by(-(n as isize)); }
2332 pub fn hscroll_right_cols(&mut self, n: u16) { self.hscroll_by(n as isize); }
2334
2335 pub fn left_col(&self) -> usize { self.left_col }
2336
2337 pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2340
2341 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2345}
2346
2347#[cfg(feature = "image")]
2352pub fn protocol_scaled_dims(img_w: u32, img_h: u32, cols: u16,
2353 cell_px: (u16, u16), width_cols: Option<usize>) -> (u32, u32) {
2354 let target_cols = width_cols.unwrap_or(cols as usize).max(1) as u32;
2355 let scaled_w = (target_cols * cell_px.0.max(1) as u32).max(1);
2356 let img_w = img_w.max(1);
2357 let scaled_h = (img_h as u64 * scaled_w as u64 / img_w as u64).max(1) as u32;
2358 (scaled_w, scaled_h)
2359}
2360
2361#[cfg(feature = "image")]
2364pub fn protocol_occupied_rows(img_w: u32, img_h: u32, cols: u16,
2365 cell_px: (u16, u16), width_cols: Option<usize>) -> usize {
2366 let (_, scaled_h) = protocol_scaled_dims(img_w, img_h, cols, cell_px, width_cols);
2367 (scaled_h as usize).div_ceil(cell_px.1.max(1) as usize).max(1)
2368}
2369
2370#[cfg(test)]
2371mod tests {
2372 use super::*;
2373 use crate::source::MockSource;
2374
2375 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2376 let m = MockSource::new();
2377 m.append(content);
2378 m.finish();
2379 let idx = LineIndex::new();
2380 (m, idx)
2381 }
2382
2383 fn first_cell_char(row: &[Cell]) -> char {
2386 match row.first() {
2387 Some(Cell::Char { ch, .. }) => *ch,
2388 other => panic!("expected Char in first cell, got {:?}", other),
2389 }
2390 }
2391
2392 #[test]
2393 fn status_column_shows_mark_then_search_glyphs() {
2394 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2397 let mut v = Viewport::new(20, 5, "f".into()); v.opts.wrap = false;
2399 v.set_status_column(true);
2400 let mut marks = std::collections::HashMap::new();
2401 marks.insert(1usize, 'a');
2402 v.set_status_marks(marks);
2403 v.set_search("cc".into(), SearchDirection::Forward).unwrap();
2404
2405 let frame = v.frame(&m, &mut idx);
2406 assert_eq!(first_cell_char(&frame.body[0]), ' ', "line 0: no mark, no match");
2407 assert_eq!(first_cell_char(&frame.body[1]), 'a', "line 1: mark letter");
2408 assert_eq!(first_cell_char(&frame.body[2]), '*', "line 2: search match");
2409 }
2410
2411 #[test]
2412 fn status_column_mark_beats_search_match() {
2413 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2416 let mut v = Viewport::new(20, 5, "f".into());
2417 v.opts.wrap = false;
2418 v.set_status_column(true);
2419 let mut marks = std::collections::HashMap::new();
2420 marks.insert(1usize, 'z');
2421 v.set_status_marks(marks);
2422 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2423
2424 let frame = v.frame(&m, &mut idx);
2425 assert_eq!(first_cell_char(&frame.body[1]), 'z', "mark beats search-match");
2426 }
2427
2428 #[test]
2429 fn status_column_matches_content_not_gutter_digits() {
2430 let (m, mut idx) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2437 let mut v = Viewport::new(40, 14, "f".into()); v.opts.wrap = false;
2439 v.show_line_numbers = true;
2440 v.set_status_column(true);
2441 v.set_search("5".into(), SearchDirection::Forward).unwrap();
2442
2443 let frame = v.frame(&m, &mut idx);
2444 for i in 0..12 {
2448 assert_eq!(
2449 first_cell_char(&frame.body[i]), ' ',
2450 "body row {i}: no content match for '5' but status column flagged it"
2451 );
2452 }
2453
2454 let (m2, mut idx2) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2456 let mut v2 = Viewport::new(40, 14, "f".into());
2457 v2.opts.wrap = false;
2458 v2.show_line_numbers = true;
2459 v2.set_status_column(true);
2460 v2.set_search("ee".into(), SearchDirection::Forward).unwrap();
2461 let frame2 = v2.frame(&m2, &mut idx2);
2462 assert_eq!(first_cell_char(&frame2.body[4]), '*', "line 5 content 'ee' matches search");
2463 }
2464
2465 #[test]
2466 fn status_column_off_leaves_first_cell_as_content() {
2467 let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2470 let mut v = Viewport::new(20, 5, "f".into());
2471 v.opts.wrap = false;
2472 let mut marks = std::collections::HashMap::new();
2474 marks.insert(1usize, 'a');
2475 v.set_status_marks(marks);
2476 v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2477
2478 let frame = v.frame(&m, &mut idx);
2479 assert_eq!(first_cell_char(&frame.body[0]), 'a', "line 0 content unchanged");
2480 assert_eq!(first_cell_char(&frame.body[1]), 'b', "line 1 content unchanged");
2481 assert_eq!(first_cell_char(&frame.body[2]), 'c', "line 2 content unchanged");
2482 }
2483
2484 #[test]
2485 fn frame_renders_body_height_rows() {
2486 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2487 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
2489 assert_eq!(frame.body.len(), 4);
2490 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2491 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2492 }
2493
2494 #[test]
2495 fn scroll_down_advances_top_line() {
2496 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2499 let mut v = Viewport::new(10, 5, "test".into());
2500 v.scroll_lines(2, &m, &mut idx);
2501 assert_eq!(v.top_line, 2);
2502 assert_eq!(v.top_row, 0);
2503 }
2504
2505 #[test]
2506 fn scroll_up_clamps_at_zero() {
2507 let (m, mut idx) = setup(b"a\nb\nc\n");
2508 let mut v = Viewport::new(10, 5, "test".into());
2509 v.scroll_lines(-5, &m, &mut idx);
2510 assert_eq!(v.top_line, 0);
2511 assert_eq!(v.top_row, 0);
2512 }
2513
2514 #[test]
2515 fn scroll_down_clamps_at_last_line() {
2516 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2521 let mut v = Viewport::new(10, 5, "test".into());
2522 v.scroll_lines(50, &m, &mut idx);
2523 assert_eq!((v.top_line, v.top_row), (4, 0));
2524 assert!(v.is_at_bottom(&m, &idx));
2525 }
2526
2527 #[test]
2528 fn scroll_logical_lines_skips_wrap_rows() {
2529 let mut content = vec![b'X'; 500];
2531 content.push(b'\n');
2532 content.extend_from_slice(b"second\n");
2533 content.extend_from_slice(b"third\n");
2534 let (m, mut idx) = setup(&content);
2535 let mut v = Viewport::new(10, 8, "f".into());
2536 v.scroll_logical_lines(1, &m, &mut idx);
2537 assert_eq!((v.top_line, v.top_row), (1, 0));
2538 v.scroll_logical_lines(1, &m, &mut idx);
2539 assert_eq!((v.top_line, v.top_row), (2, 0));
2540 }
2541
2542 #[test]
2543 fn scroll_logical_lines_back_snaps_to_line_start() {
2544 let mut content = vec![b'A'; 50];
2549 content.push(b'\n');
2550 content.extend_from_slice(&[b'B'; 50]);
2551 content.push(b'\n');
2552 content.extend_from_slice(&[b'C'; 50]);
2553 content.push(b'\n');
2554 let (m, mut idx) = setup(&content);
2555 let mut v = Viewport::new(10, 8, "f".into());
2556 v.scroll_lines(7, &m, &mut idx);
2557 assert_eq!(v.top_line, 1, "should be on line 1");
2558 assert!(v.top_row > 0, "should be inside line 1's wraps");
2559 v.scroll_logical_lines(-1, &m, &mut idx);
2560 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2561 v.scroll_logical_lines(-1, &m, &mut idx);
2562 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2563 }
2564
2565 #[test]
2566 fn scroll_down_walks_wraps_of_last_line() {
2567 let mut content = b"first\n".to_vec();
2571 content.extend_from_slice(&[b'X'; 60]);
2572 content.push(b'\n');
2573 let (m, mut idx) = setup(&content);
2574 let mut v = Viewport::new(10, 5, "f".into());
2575 v.scroll_lines(1, &m, &mut idx);
2576 assert_eq!((v.top_line, v.top_row), (1, 0));
2577 v.scroll_lines(1, &m, &mut idx);
2578 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2579 v.scroll_lines(1, &m, &mut idx);
2580 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2581 v.scroll_lines(5, &m, &mut idx);
2583 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2584 }
2585
2586 #[test]
2587 fn scroll_down_walks_wrap_rows_within_long_line() {
2588 let mut content = vec![b'X'; 30];
2592 content.push(b'\n');
2593 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2594 let (m, mut idx) = setup(&content);
2595 let mut v = Viewport::new(10, 5, "f".into());
2596 v.scroll_lines(1, &m, &mut idx);
2597 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2598 v.scroll_lines(1, &m, &mut idx);
2599 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2600 v.scroll_lines(1, &m, &mut idx);
2601 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2602 }
2603
2604 #[test]
2605 fn status_line_shows_range_and_pct() {
2606 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2607 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2609 assert!(frame.status.starts_with("f 1-4/10"));
2610 }
2611
2612 #[test]
2613 fn page_down_advances_by_body_rows() {
2614 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2615 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2617 assert_eq!(v.top_line, 4);
2618 }
2619
2620 #[test]
2621 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2622 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2623 let mut v = Viewport::new(10, 5, "f".into());
2624 v.page_down(&m, &mut idx);
2625 v.page_up(&m, &mut idx);
2626 assert_eq!(v.top_line, 0);
2627 assert_eq!(v.top_row, 0);
2628 }
2629
2630 #[test]
2631 fn half_page_down_advances_by_half_body() {
2632 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2635 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2637 assert_eq!(v.top_line, 3);
2638 }
2639
2640 #[test]
2641 fn goto_top_resets_position() {
2642 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2643 let mut v = Viewport::new(10, 5, "f".into());
2644 v.scroll_lines(2, &m, &mut idx);
2645 v.goto_top();
2646 assert_eq!(v.top_line, 0);
2647 assert_eq!(v.top_row, 0);
2648 }
2649
2650 #[test]
2651 fn goto_bottom_scrolls_to_last_page() {
2652 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2653 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2655 assert_eq!(v.top_line, 6);
2657 }
2658
2659 #[test]
2660 #[cfg(feature = "image")]
2661 fn protocol_image_occupied_rows_fit_width() {
2662 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), None), 50);
2664 assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), Some(25)), 25);
2666 }
2667
2668 #[cfg(feature = "image")]
2669 #[test]
2670 fn frame_image_protocol_sets_image_blob() {
2671 use image::{Rgba, RgbaImage};
2672 let mut vp = Viewport::new(40, 10, "cat.png".into());
2673 let mut idx = LineIndex::new();
2674 let m = MockSource::new();
2675 vp.set_image(RgbaImage::from_pixel(20, 40, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2676 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2677 let frame = vp.frame(&m, &mut idx);
2678 assert!(frame.image_blob.is_some(), "Kitty protocol frame carries an image blob");
2679 vp.set_image_protocol(crate::viewport::ImageProtocol::Ascii, None);
2681 let frame2 = vp.frame(&m, &mut idx);
2682 assert!(frame2.image_blob.is_none(), "ASCII protocol frame has no blob");
2683 }
2684
2685 #[cfg(feature = "image")]
2686 #[test]
2687 fn protocol_image_clamps_vertical_scroll() {
2688 use image::{Rgba, RgbaImage};
2689 let mut vp = Viewport::new(40, 10, "cat.png".into()); let mut idx = LineIndex::new();
2691 let m = MockSource::new();
2692 vp.set_image(RgbaImage::from_pixel(20, 2000, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2694 vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2695 for _ in 0..10_000 {
2697 vp.scroll_lines(1, &m, &mut idx);
2698 }
2699 let _ = vp.frame(&m, &mut idx);
2700 let total = crate::viewport::protocol_occupied_rows(20, 2000, 40, (8, 16), None);
2702 let body = vp.body_rows() as usize;
2703 assert_eq!(
2704 vp.top_line(),
2705 total.saturating_sub(body),
2706 "scroll reaches exactly the protocol image bottom"
2707 );
2708 }
2709
2710 #[cfg(feature = "image")]
2711 #[test]
2712 fn image_mode_frame_renders_and_scrolls() {
2713 use image::{Rgba, RgbaImage};
2714 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2715 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2717 assert!(v.image_mode());
2718 let total = v.image_total_rows();
2719 assert!(total > 5, "tall image should exceed the body");
2720 assert!(!v.is_at_bottom_image(), "starts at top");
2721 let mut idx = LineIndex::new();
2722 let m = MockSource::new();
2723 let frame = v.frame(&m, &mut idx);
2724 assert_eq!(frame.body.len(), 5);
2725 v.goto_bottom(&m, &mut idx);
2726 assert!(v.is_at_bottom_image());
2727 }
2728
2729 #[cfg(feature = "image")]
2730 #[test]
2731 fn frame_image_slices_at_left_col() {
2732 use crate::render::Cell;
2733 use image::{Rgba, RgbaImage};
2734
2735 let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2741 let mut v = Viewport::new(10, 4, "wide.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2743 assert!(v.hscroll_active(), "image mode should make hscroll active");
2744
2745 let mut idx = LineIndex::new();
2746 let m = MockSource::new();
2747
2748 assert_eq!(v.left_col(), 0);
2750 let frame0 = v.frame(&m, &mut idx);
2751 assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2752 assert_eq!(frame0.body[0].len(), 10);
2754 assert!(
2756 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2757 "no scroll marker expected on image frame at left_col=0"
2758 );
2759 let cell_at_col0 = frame0.body[0][0].clone();
2761 let cell_at_col8 = frame0.body[0][8].clone();
2762
2763 v.hscroll_right_step();
2765 assert_eq!(v.left_col(), 8);
2766 let frame1 = v.frame(&m, &mut idx);
2767 assert_eq!(frame1.body[0].len(), 10);
2768 assert!(
2770 !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2771 "no scroll marker expected on image frame after hscroll_right_step"
2772 );
2773 assert_eq!(
2775 frame1.body[0][0], cell_at_col8,
2776 "after hscroll_right_step the first visible cell should be grid col 8"
2777 );
2778 assert_ne!(
2782 frame1.body[0][0], cell_at_col0,
2783 "the scrolled first cell must differ from the unscrolled one"
2784 );
2785 }
2786
2787 #[cfg(feature = "image")]
2788 #[test]
2789 fn animation_renders_current_frame_and_advances() {
2790 use image::{Rgba, RgbaImage};
2791 use std::time::Duration;
2792 let m = MockSource::new();
2793 let mut idx = LineIndex::new();
2794 let mut vp = Viewport::new(40, 10, "x.gif".into());
2795 let frames = vec![
2796 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2797 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2798 ];
2799 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2800 crate::image_render::AsciiStyle::Ramp, None);
2801 let f0 = vp.frame(&m, &mut idx);
2802 let changed = vp.tick(Duration::from_millis(120));
2803 assert!(changed, "tick past the frame delay advances");
2804 let f1 = vp.frame(&m, &mut idx);
2805 assert_ne!(format!("{:?}", f0.body), format!("{:?}", f1.body), "frame content changed");
2806 assert!(vp.has_animation());
2807 assert!(vp.anim_deadline().is_some());
2808 }
2809
2810 #[cfg(feature = "image")]
2811 #[test]
2812 fn animation_status_badge_reflects_play_pause() {
2813 use image::{Rgba, RgbaImage};
2814 use std::time::Duration;
2815 let m = MockSource::new();
2816 let mut idx = LineIndex::new();
2817 let mut vp = Viewport::new(40, 10, "x.gif".into());
2818 let frames = vec![
2819 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2820 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2821 ];
2822 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2823 crate::image_render::AsciiStyle::Ramp, None);
2824 let playing = vp.frame(&m, &mut idx);
2825 assert!(playing.status.contains("[play 1/2]"), "status: {:?}", playing.status);
2826 vp.anim_toggle_pause();
2827 let paused = vp.frame(&m, &mut idx);
2828 assert!(paused.status.contains("[pause 1/2]"), "status: {:?}", paused.status);
2829 }
2830
2831 #[cfg(feature = "image")]
2832 #[test]
2833 fn animation_pause_stops_advance() {
2834 use image::{Rgba, RgbaImage};
2835 use std::time::Duration;
2836 let mut vp = Viewport::new(40, 10, "x.gif".into());
2837 let frames = vec![
2838 (RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 255])), Duration::from_millis(100)),
2839 (RgbaImage::from_pixel(4, 4, Rgba([255, 255, 255, 255])), Duration::from_millis(100)),
2840 ];
2841 vp.set_animation(crate::image_render::Animation { frames, loop_count: None }, "gif",
2842 crate::image_render::AsciiStyle::Ramp, None);
2843 vp.anim_toggle_pause();
2844 assert!(!vp.tick(Duration::from_millis(500)), "paused tick does not advance");
2845 assert_eq!(vp.anim_deadline(), None);
2846 }
2847
2848 #[test]
2849 fn goto_line_positions_top_line() {
2850 let m = MockSource::new();
2851 m.append(b"a\nb\nc\nd\ne\n");
2852 let mut idx = LineIndex::new();
2853 idx.extend_to_end(&m);
2854 let mut v = Viewport::new(20, 5, "f".into());
2855 v.goto_line(3, &m, &mut idx);
2856 assert_eq!(v.top_line(), 3);
2857 }
2858
2859 #[test]
2860 fn goto_line_clamps_to_last_line() {
2861 let m = MockSource::new();
2862 m.append(b"a\nb\n");
2863 let mut idx = LineIndex::new();
2864 idx.extend_to_end(&m);
2865 let mut v = Viewport::new(20, 5, "f".into());
2866 v.goto_line(999, &m, &mut idx);
2867 assert_eq!(v.top_line(), 1);
2868 }
2869
2870 #[test]
2871 fn goto_record_positions_at_record_start_line() {
2872 let m = MockSource::new();
2873 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
2874 let mut idx = LineIndex::new();
2875 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2876 idx.extend_to_end(&m);
2877 let mut v = Viewport::new(20, 5, "f".into());
2878 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2880 }
2881
2882 #[test]
2883 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2884 let m = MockSource::new();
2885 m.append(b"a\nb\nc\n");
2886 let mut idx = LineIndex::new();
2887 idx.extend_to_end(&m);
2888 let mut v = Viewport::new(20, 5, "f".into());
2889 v.goto_record(2, &m, &mut idx);
2890 assert_eq!(v.top_line(), 2);
2891 }
2892
2893 #[test]
2894 fn goto_percent_50_lands_in_middle() {
2895 let m = MockSource::new();
2896 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2898 idx.extend_to_end(&m);
2899 let mut v = Viewport::new(20, 5, "f".into());
2900 v.goto_percent(50, &m, &mut idx);
2901 assert_eq!(v.top_line(), 2); }
2903
2904 #[test]
2905 fn goto_percent_100_lands_at_last_line() {
2906 let m = MockSource::new();
2907 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2909 idx.extend_to_end(&m);
2910 let mut v = Viewport::new(20, 5, "f".into());
2911 v.goto_percent(100, &m, &mut idx);
2912 assert_eq!(v.top_line(), 2);
2913 }
2914
2915 #[test]
2916 fn goto_percent_0_lands_at_first_line() {
2917 let m = MockSource::new();
2918 m.append(b"a\nb\nc\n");
2919 let mut idx = LineIndex::new();
2920 idx.extend_to_end(&m);
2921 let mut v = Viewport::new(20, 5, "f".into());
2922 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2924 v.goto_percent(0, &m, &mut idx);
2925 assert_eq!(v.top_line(), 0);
2926 }
2927
2928 #[test]
2929 fn resize_updates_dimensions_and_render_opts() {
2930 let (m, mut idx) = setup(b"1\n2\n");
2931 let mut v = Viewport::new(10, 5, "f".into());
2932 v.resize(40, 12);
2933 assert_eq!(v.cols, 40);
2934 assert_eq!(v.rows, 12);
2935 assert_eq!(v.opts.cols, 40);
2936 let _ = v.frame(&m, &mut idx);
2937 }
2938
2939 #[test]
2940 fn toggle_line_numbers_changes_gutter() {
2941 let (m, mut idx) = setup(b"a\nb\nc\n");
2942 let mut v = Viewport::new(10, 5, "f".into());
2943 let frame_off = v.frame(&m, &mut idx);
2944 v.toggle_line_numbers();
2945 let frame_on = v.frame(&m, &mut idx);
2946 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2948 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2949 }
2950
2951 #[test]
2952 fn toggle_chop_changes_wrap_mode() {
2953 let (m, mut idx) = setup(b"abcdefghij\n");
2954 let mut v = Viewport::new(4, 5, "f".into());
2955 v.toggle_chop();
2956 let frame = v.frame(&m, &mut idx);
2957 assert_eq!(frame.body[0][..4],
2960 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2961 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2962 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2963 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2964 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2966 }
2967
2968 #[test]
2971 fn is_at_bottom_initially_only_when_source_fits() {
2972 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2975 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2976 }
2977
2978 #[test]
2979 fn is_at_bottom_false_when_top_and_more_lines_below() {
2980 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);
2983 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2984 }
2985
2986 #[test]
2987 fn is_at_bottom_true_after_goto_bottom() {
2988 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2989 let mut v = Viewport::new(10, 5, "f".into());
2990 v.goto_bottom(&m, &mut idx);
2991 assert!(v.is_at_bottom(&m, &idx));
2992 }
2993
2994 #[test]
2995 fn status_shows_follow_suffix_when_follow_mode_on() {
2996 let (m, mut idx) = setup(b"a\nb\n");
2997 let mut v = Viewport::new(20, 5, "f".into());
2998 let frame_off = v.frame(&m, &mut idx);
2999 assert!(!frame_off.status.contains("(F)"));
3000 v.set_follow_mode(true);
3001 let frame_on = v.frame(&m, &mut idx);
3002 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
3003 }
3004
3005 #[test]
3006 fn toggle_follow_flips_state() {
3007 let mut v = Viewport::new(10, 5, "f".into());
3008 assert!(!v.follow_mode());
3009 v.toggle_follow();
3010 assert!(v.follow_mode());
3011 v.toggle_follow();
3012 assert!(!v.follow_mode());
3013 }
3014
3015 #[test]
3016 fn idle_indicator_kicks_in_at_threshold() {
3017 let (m, mut idx) = setup(b"a\nb\n");
3018 let mut v = Viewport::new(20, 5, "f".into());
3019 v.set_follow_mode(true);
3020 for _ in 0..19 { v.tick_idle(); }
3022 let f1 = v.frame(&m, &mut idx);
3023 assert!(f1.status.contains("(F)"));
3024 assert!(!f1.status.contains("idle"));
3025 v.tick_idle();
3027 let f2 = v.frame(&m, &mut idx);
3028 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
3029 }
3030
3031 #[test]
3032 fn note_growth_resets_idle() {
3033 let (m, mut idx) = setup(b"a\nb\n");
3034 let mut v = Viewport::new(20, 5, "f".into());
3035 v.set_follow_mode(true);
3036 for _ in 0..25 { v.tick_idle(); }
3037 assert!(v.is_idle());
3038 v.note_growth();
3039 assert!(!v.is_idle());
3040 let f = v.frame(&m, &mut idx);
3041 assert!(!f.status.contains("idle"));
3042 }
3043
3044 #[test]
3045 fn qae_off_never_quits_even_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::Off);
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_quits_immediately_at_bottom() {
3055 let (m, mut idx) = setup(b"a\n");
3056 let mut v = Viewport::new(20, 5, "f".into());
3057 v.set_quit_at_eof(QuitAtEof::First);
3058 v.goto_bottom(&m, &mut idx);
3059 assert!(v.note_motion_for_eof(true, &m, &idx));
3060 }
3061
3062 #[test]
3063 fn qae_first_only_quits_at_eof_not_mid_file() {
3064 let mut content = Vec::new();
3065 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
3066 let (m, mut idx) = setup(&content);
3067 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
3069 v.set_quit_at_eof(QuitAtEof::First);
3070 assert!(!v.is_at_bottom(&m, &idx));
3072 assert!(!v.note_motion_for_eof(true, &m, &idx));
3073 }
3074
3075 #[test]
3076 fn qae_second_quits_on_second_hit() {
3077 let (m, mut idx) = setup(b"a\n");
3078 let mut v = Viewport::new(20, 5, "f".into());
3079 v.set_quit_at_eof(QuitAtEof::Second);
3080 v.goto_bottom(&m, &mut idx);
3081 assert!(!v.note_motion_for_eof(true, &m, &idx));
3083 assert!(v.note_motion_for_eof(true, &m, &idx));
3085 }
3086
3087 #[test]
3088 fn squeeze_collapses_consecutive_blanks() {
3089 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3091 let mut v = Viewport::new(10, 8, "f".into());
3092 v.set_squeeze_blanks(true);
3093 let f = v.frame(&m, &mut idx);
3094 let stringify = |row: &Vec<Cell>| -> String {
3096 row.iter().filter_map(|c| match c {
3097 Cell::Char { ch, .. } => Some(*ch),
3098 _ => None,
3099 }).collect::<String>().trim().to_string()
3100 };
3101 let rows: Vec<String> = f.body.iter().map(stringify).collect();
3102 assert_eq!(&rows[0], "a");
3104 assert_eq!(&rows[1], "");
3105 assert_eq!(&rows[2], "b");
3106 }
3107
3108 #[test]
3109 fn header_pins_top_rows_when_scrolling() {
3110 let mut content = Vec::new();
3112 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3113 let (m, mut idx) = setup(&content);
3114 let mut v = Viewport::new(20, 6, "f".into());
3115 v.set_header(2, 0);
3116 v.scroll_lines(5, &m, &mut idx);
3120 let f = v.frame(&m, &mut idx);
3121 let chs = |row: &Vec<Cell>| -> String {
3122 row.iter().filter_map(|c| match c {
3123 Cell::Char { ch, .. } => Some(*ch),
3124 _ => None,
3125 }).collect::<String>().trim().to_string()
3126 };
3127 assert_eq!(&chs(&f.body[0]), "line0");
3129 assert_eq!(&chs(&f.body[1]), "line1");
3130 assert_eq!(&chs(&f.body[2]), "line7");
3132 }
3133
3134 #[test]
3135 fn page_size_when_set_overrides_body_rows() {
3136 let mut content = Vec::new();
3137 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3138 let (m, mut idx) = setup(&content);
3139 let mut v = Viewport::new(20, 10, "f".into());
3140 v.set_page_size(Some(3));
3141 let before = v.top_line();
3142 v.page_down(&m, &mut idx);
3143 assert_eq!(v.top_line(), before + 3);
3144 v.page_up(&m, &mut idx);
3145 assert_eq!(v.top_line(), before);
3146 }
3147
3148 #[test]
3149 fn page_size_unset_uses_body_rows() {
3150 let mut content = Vec::new();
3151 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3152 let (m, mut idx) = setup(&content);
3153 let mut v = Viewport::new(20, 10, "f".into());
3154 v.page_down(&m, &mut idx);
3156 assert_eq!(v.top_line(), 9);
3157 }
3158
3159 #[test]
3160 fn header_zero_lines_renders_like_no_header() {
3161 let mut content = Vec::new();
3162 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3163 let (m, mut idx) = setup(&content);
3164 let mut v = Viewport::new(20, 6, "f".into());
3165 v.set_header(0, 0);
3166 let f = v.frame(&m, &mut idx);
3167 let chs = |row: &Vec<Cell>| -> String {
3168 row.iter().filter_map(|c| match c {
3169 Cell::Char { ch, .. } => Some(*ch),
3170 _ => None,
3171 }).collect::<String>().trim().to_string()
3172 };
3173 assert_eq!(&chs(&f.body[0]), "line0");
3174 assert_eq!(&chs(&f.body[1]), "line1");
3175 }
3176
3177 #[test]
3178 fn squeeze_off_preserves_blanks() {
3179 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3180 let mut v = Viewport::new(10, 8, "f".into());
3181 let f = v.frame(&m, &mut idx);
3183 let stringify = |row: &Vec<Cell>| -> String {
3184 row.iter().filter_map(|c| match c {
3185 Cell::Char { ch, .. } => Some(*ch),
3186 _ => None,
3187 }).collect::<String>().trim().to_string()
3188 };
3189 let rows: Vec<String> = f.body.iter().map(stringify).collect();
3190 assert_eq!(&rows[0], "a");
3192 assert_eq!(&rows[1], "");
3193 assert_eq!(&rows[2], "");
3194 assert_eq!(&rows[3], "");
3195 assert_eq!(&rows[4], "b");
3196 }
3197
3198 #[test]
3199 fn qae_second_resets_on_backward_motion() {
3200 let (m, mut idx) = setup(b"a\n");
3201 let mut v = Viewport::new(20, 5, "f".into());
3202 v.set_quit_at_eof(QuitAtEof::Second);
3203 v.goto_bottom(&m, &mut idx);
3204 assert!(!v.note_motion_for_eof(true, &m, &idx));
3205 v.note_motion_for_eof(false, &m, &idx);
3207 assert!(!v.note_motion_for_eof(true, &m, &idx));
3209 assert!(v.note_motion_for_eof(true, &m, &idx));
3211 }
3212
3213 #[test]
3214 fn flash_message_overrides_follow_suffix() {
3215 let (m, mut idx) = setup(b"a\nb\n");
3216 let mut v = Viewport::new(40, 5, "f".into());
3217 v.set_follow_mode(true);
3218 v.flash("(F reopened)", 3);
3219 let f = v.frame(&m, &mut idx);
3220 assert!(f.status.contains("(F reopened)"), "{}", f.status);
3221 assert!(!f.status.contains("(F idle)"));
3222 }
3223
3224 #[test]
3225 fn flash_countdown_clears() {
3226 let mut v = Viewport::new(10, 5, "f".into());
3227 v.flash("hello", 2);
3228 v.tick_flash();
3229 assert!(v.status_flash.is_some());
3230 v.tick_flash();
3231 assert!(v.status_flash.is_none());
3232 }
3233
3234 #[test]
3235 fn suspend_follow_if_off_is_noop() {
3236 let mut v = Viewport::new(10, 5, "f".into());
3237 v.set_follow_mode(true);
3238 v.suspend_follow_if(false);
3239 assert!(v.follow_mode());
3240 }
3241
3242 #[test]
3243 fn suspend_follow_if_on_flips_off() {
3244 let mut v = Viewport::new(10, 5, "f".into());
3245 v.set_follow_mode(true);
3246 v.suspend_follow_if(true);
3247 assert!(!v.follow_mode());
3248 }
3249
3250 #[test]
3251 fn case_mode_sensitive_returns_pattern_unchanged() {
3252 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
3253 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
3254 }
3255
3256 #[test]
3257 fn case_mode_insensitive_prepends_i_flag() {
3258 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
3259 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
3260 }
3261
3262 #[test]
3263 fn case_mode_smart_lowercase_is_insensitive() {
3264 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
3265 }
3266
3267 #[test]
3268 fn case_mode_smart_with_uppercase_is_sensitive() {
3269 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
3270 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
3271 }
3272
3273 #[test]
3274 fn set_case_mode_recompiles_active_search() {
3275 let (m, mut idx) = setup(b"hello WORLD\n");
3276 let mut v = Viewport::new(40, 5, "f".into());
3277 v.set_search("world".into(), SearchDirection::Forward).unwrap();
3278 assert!(!v.search_repeat(&m, &mut idx, false));
3280 v.set_case_mode(CaseMode::Insensitive);
3282 assert!(v.search_repeat(&m, &mut idx, false));
3283 }
3284
3285 #[test]
3286 fn status_shows_prettify_label_when_set() {
3287 let (m, mut idx) = setup(b"a\n");
3288 let mut v = Viewport::new(40, 5, "f".into());
3289 let frame_off = v.frame(&m, &mut idx);
3290 assert!(!frame_off.status.contains("[pretty"));
3291 v.set_prettify_label(Some("json".into()));
3292 let frame_on = v.frame(&m, &mut idx);
3293 assert!(frame_on.status.contains("[pretty:json]"),
3294 "expected [pretty:json] in status, got: {}", frame_on.status);
3295 v.set_prettify_label(Some("json:err".into()));
3296 let frame_err = v.frame(&m, &mut idx);
3297 assert!(frame_err.status.contains("[pretty:json:err]"),
3298 "expected [pretty:json:err] in status, got: {}", frame_err.status);
3299 }
3300
3301 #[test]
3302 fn status_shows_l_suffix_when_live_mode_on() {
3303 let (m, mut idx) = setup(b"a\nb\n");
3304 let mut v = Viewport::new(20, 5, "f".into());
3305 let frame_off = v.frame(&m, &mut idx);
3306 assert!(!frame_off.status.contains("(L)"));
3307 v.set_live_mode(true);
3308 let frame_on = v.frame(&m, &mut idx);
3309 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
3310 }
3311
3312 #[test]
3313 fn clamp_top_line_pulls_back_when_total_shrinks() {
3314 let mut v = Viewport::new(20, 5, "f".into());
3315 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
3324 let (m, mut idx) = setup(b"only\n");
3326 let _ = v.frame(&m, &mut idx);
3327 }
3328
3329 fn simulate_growth_tick(
3332 v: &mut Viewport,
3333 src: &MockSource,
3334 idx: &mut LineIndex,
3335 ) {
3336 if !v.follow_mode() { return; }
3337 let was_at_bottom = v.is_at_bottom(src, idx);
3338 let lines_before = idx.line_count();
3339 idx.notice_new_bytes(src);
3340 if idx.line_count() != lines_before && was_at_bottom {
3341 v.goto_bottom(src, idx);
3342 }
3343 }
3344
3345 #[test]
3346 fn auto_scroll_engages_when_at_bottom() {
3347 let m = MockSource::new();
3348 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
3350 let mut v = Viewport::new(10, 5, "f".into());
3351 v.set_follow_mode(true);
3352 idx.extend_to_end(&m);
3353 assert!(v.is_at_bottom(&m, &idx));
3354 let top_before = {
3355 let f = v.frame(&m, &mut idx);
3356 f.status.clone() };
3358 let _ = top_before;
3359 m.append(b"5\n6\n7\n8\n");
3361 simulate_growth_tick(&mut v, &m, &mut idx);
3362 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
3364 let frame = v.frame(&m, &mut idx);
3365 let last_row = &frame.body[frame.body.len() - 1];
3368 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
3369 }
3370
3371 #[test]
3372 fn auto_scroll_suppressed_when_scrolled_up() {
3373 let m = MockSource::new();
3374 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
3376 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
3378 idx.extend_to_end(&m);
3379 v.goto_bottom(&m, &mut idx);
3380 v.scroll_lines(-2, &m, &mut idx);
3382 assert!(!v.is_at_bottom(&m, &idx));
3383 let frame_before = v.frame(&m, &mut idx);
3384 let top_first_cell_before = frame_before.body[0][0].clone();
3385 m.append(b"9\n10\n");
3387 simulate_growth_tick(&mut v, &m, &mut idx);
3388 let frame_after = v.frame(&m, &mut idx);
3390 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
3391 }
3392
3393 #[test]
3396 fn set_search_compiles_regex() {
3397 let mut v = Viewport::new(10, 5, "f".into());
3398 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
3399 assert!(v.search_active());
3400 }
3401
3402 #[test]
3403 fn set_search_rejects_bad_regex() {
3404 let mut v = Viewport::new(10, 5, "f".into());
3405 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
3406 assert!(!err.is_empty());
3407 assert!(!v.search_active(), "no search should be set on error");
3408 }
3409
3410 #[test]
3411 fn search_step_forward_finds_match_after_top() {
3412 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3413 let mut v = Viewport::new(20, 5, "f".into());
3414 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3415 let found = v.search_repeat(&m, &mut idx, false);
3416 assert!(found);
3417 assert_eq!(v.top_line, 2);
3419 }
3420
3421 #[test]
3422 fn search_step_backward_finds_match_before_top() {
3423 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3424 let mut v = Viewport::new(20, 5, "f".into());
3425 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
3427 let found = v.search_repeat(&m, &mut idx, false);
3428 assert!(found);
3429 assert_eq!(v.top_line, 0);
3430 }
3431
3432 #[test]
3433 fn search_wraps_at_end() {
3434 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3435 let mut v = Viewport::new(20, 5, "f".into());
3436 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
3438 let found = v.search_repeat(&m, &mut idx, false);
3439 assert!(found, "search should wrap forward past EOF");
3440 assert_eq!(v.top_line, 0);
3441 }
3442
3443 #[test]
3444 fn search_no_match_returns_false_and_does_not_move() {
3445 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3446 let mut v = Viewport::new(20, 5, "f".into());
3447 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
3448 let found = v.search_repeat(&m, &mut idx, false);
3449 assert!(!found);
3450 assert_eq!(v.top_line, 0);
3451 }
3452
3453 #[test]
3454 fn frame_records_highlight_ranges_for_matches() {
3455 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
3456 let mut v = Viewport::new(20, 5, "f".into());
3457 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3458 let frame = v.frame(&m, &mut idx);
3459 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3461 assert!(frame.highlights[0].is_empty());
3462 assert!(frame.highlights[1].is_empty());
3463 assert_eq!(frame.highlights[2], vec![0..5]);
3464 assert!(frame.highlights[3].is_empty());
3465 }
3466
3467 #[test]
3468 fn frame_highlights_substring_inside_a_row() {
3469 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
3470 let mut v = Viewport::new(40, 5, "f".into());
3471 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3472 let frame = v.frame(&m, &mut idx);
3473 assert_eq!(frame.highlights[0], vec![18..22]);
3475 assert!(frame.highlights[1].is_empty());
3476 }
3477
3478 #[test]
3479 fn search_highlight_with_filter_dim_keeps_row_dim() {
3480 let (m, mut idx) = setup(b"alpha\nbeta\n");
3483 let mut v = Viewport::new(20, 5, "f".into());
3484 let fmt = crate::format::LogFormat::compile(
3485 "simple",
3486 r"^(?P<line>.+)$",
3487 )
3488 .unwrap();
3489 let f = crate::filter::CompiledFilter::compile(
3490 &fmt,
3491 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
3492 CaseMode::Sensitive,
3493 )
3494 .unwrap();
3495 v.set_filter(Some(f));
3496 v.set_dim_mode(true);
3497 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3498 let frame = v.frame(&m, &mut idx);
3499 assert_eq!(frame.row_styles[0], RowStyle::Normal);
3500 assert_eq!(frame.row_styles[1], RowStyle::Dim);
3501 assert_eq!(frame.highlights[1], vec![0..4]);
3502 }
3503
3504 #[test]
3505 fn grep_only_hides_non_matching_lines() {
3506 use crate::grep::GrepPredicate;
3507 let src = crate::source::MockSource::new();
3508 src.append(b"keep this error\n");
3509 src.append(b"drop this one\n");
3510 src.append(b"another error line\n");
3511 src.finish();
3512 let mut idx = crate::line_index::LineIndex::new();
3513 idx.extend_to_end(&src);
3514
3515 let mut v = Viewport::new(40, 5, "test".into());
3516 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
3517 v.extend_visible_lines(&idx, &src);
3518
3519 let frame = v.frame(&src, &mut idx);
3521 let body_text: Vec<String> = frame.body.iter()
3522 .map(|row| row.iter().filter_map(|c| match c {
3523 crate::render::Cell::Char { ch, .. } => Some(*ch),
3524 _ => None,
3525 }).collect())
3526 .collect();
3527 assert!(body_text[0].contains("keep this error"));
3528 assert!(body_text[1].contains("another error line"));
3529 assert!(frame.status.contains("[grep]"));
3530 }
3531
3532 #[test]
3533 fn incsearch_preview_anchors_from_origin_not_previous_match() {
3534 let src = crate::source::MockSource::new();
3542 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();
3553 let mut idx = crate::line_index::LineIndex::new();
3554
3555 let origin = (2usize, 0usize);
3556 let mut vp = Viewport::new(20, 4, "test".into()); vp.set_top(origin.0, origin.1);
3558 assert_eq!(vp.top_line(), 2);
3559
3560 vp.incsearch_preview(&src, &mut idx, "target", SearchDirection::Forward, origin);
3562 assert_eq!(vp.top_line(), 8, "should land on the far-below match");
3563 assert_eq!(vp.top_row(), 0);
3564
3565 vp.incsearch_preview(&src, &mut idx, "mark", SearchDirection::Forward, origin);
3571 assert_eq!(
3572 vp.top_line(), 4,
3573 "preview must reset to origin before scanning, landing on the match \
3574 after origin rather than continuing forward from the previous match"
3575 );
3576 assert_eq!(vp.top_row(), 0);
3577 }
3578
3579 #[test]
3580 fn incsearch_preview_empty_or_invalid_is_noop() {
3581 let (src, mut idx) = setup(b"alpha\nbeta\n[unbalanced\n");
3582 let mut vp = Viewport::new(20, 4, "test".into());
3583 vp.set_top(1, 0);
3584 vp.incsearch_preview(&src, &mut idx, "", SearchDirection::Forward, (0, 0));
3586 assert_eq!(vp.top_line(), 1);
3587 vp.incsearch_preview(&src, &mut idx, "(", SearchDirection::Forward, (0, 0));
3589 assert_eq!(vp.top_line(), 0);
3590 }
3591
3592 #[test]
3593 fn filter_and_grep_combine_with_and() {
3594 use crate::grep::GrepPredicate;
3595 let fmt = crate::format::LogFormat::compile(
3596 "simple",
3597 r"^(?P<level>\w+) (?P<msg>.+)$",
3598 ).unwrap();
3599 let f = crate::filter::CompiledFilter::compile(
3600 &fmt,
3601 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
3602 CaseMode::Sensitive,
3603 ).unwrap();
3604 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3605
3606 let src = crate::source::MockSource::new();
3607 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();
3612 let mut idx = crate::line_index::LineIndex::new();
3613 idx.extend_to_end(&src);
3614
3615 let mut v = Viewport::new(80, 5, "test".into());
3616 v.set_filter(Some(f));
3617 v.set_grep(Some(g));
3618 v.extend_visible_lines(&idx, &src);
3619 assert_eq!(v.visible_lines(), &[0usize]);
3620 }
3621
3622 #[test]
3623 fn search_status_shows_pattern() {
3624 let (m, mut idx) = setup(b"x\n");
3625 let mut v = Viewport::new(20, 5, "f".into());
3626 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3627 let frame = v.frame(&m, &mut idx);
3628 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3629 }
3630
3631 #[test]
3632 fn repeat_search_after_first_match_advances() {
3633 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3634 let mut v = Viewport::new(40, 5, "f".into());
3635 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3636 assert!(v.search_repeat(&m, &mut idx, false));
3637 assert_eq!(v.top_line, 1, "first foo");
3638 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3639 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3640 assert_eq!(v.top_line, 3, "should advance to next foo");
3641 }
3642
3643 #[test]
3644 fn auto_scroll_paused_when_follow_off() {
3645 let m = MockSource::new();
3646 m.append(b"1\n2\n3\n4\n");
3647 let mut idx = LineIndex::new();
3648 let mut v = Viewport::new(10, 5, "f".into());
3649 idx.extend_to_end(&m);
3651 let frame_before = v.frame(&m, &mut idx);
3652 let top_first_cell = frame_before.body[0][0].clone();
3653 m.append(b"5\n6\n7\n8\n");
3654 simulate_growth_tick(&mut v, &m, &mut idx);
3655 let frame_after = v.frame(&m, &mut idx);
3656 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3657 }
3658
3659 #[test]
3662 fn search_jumps_to_next_matching_record() {
3663 let m = MockSource::new();
3664 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
3665 let mut idx = LineIndex::new();
3666 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3667 idx.extend_to_end(&m);
3668 let mut v = Viewport::new(40, 10, "f".into());
3669 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3670 let hit = v.search_repeat(&m, &mut idx, false);
3671 assert!(hit, "should find 'charlie' in record 2");
3672 assert_eq!(v.top_line(), 3); }
3674
3675 #[test]
3676 fn search_finds_cross_line_match_in_record_with_s_flag() {
3677 let m = MockSource::new();
3678 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
3679 let mut idx = LineIndex::new();
3680 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3681 idx.extend_to_end(&m);
3682 let mut v = Viewport::new(40, 10, "f".into());
3683 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3684 let hit = v.search_repeat(&m, &mut idx, false);
3685 assert!(hit, "should match across \\n inside record 0 with (?s)");
3686 assert_eq!(v.top_line(), 0);
3687 }
3688
3689 #[test]
3690 fn search_repeat_with_no_match_returns_false() {
3691 let m = MockSource::new();
3692 m.append(b"[1] alpha\n[2] bravo\n");
3693 let mut idx = LineIndex::new();
3694 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3695 idx.extend_to_end(&m);
3696 let mut v = Viewport::new(40, 10, "f".into());
3697 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3698 let hit = v.search_repeat(&m, &mut idx, false);
3699 assert!(!hit);
3700 }
3701
3702 #[test]
3705 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3706 let m = MockSource::new();
3709 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
3710 let mut idx = LineIndex::new();
3711 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3712 idx.extend_to_end(&m);
3713 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3714 let mut v = Viewport::new(40, 10, "f".into());
3715 v.set_grep(Some(grep));
3716 v.extend_visible_lines(&idx, &m);
3717 assert_eq!(v.visible_lines(), &[0usize, 1]);
3720 }
3721
3722 #[test]
3723 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3724 let m = MockSource::new();
3730 m.append(
3731 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
3732 );
3733 let mut idx = LineIndex::new();
3734 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3735 idx.extend_to_end(&m);
3736 let fmt = crate::format::LogFormat::compile(
3737 "rec",
3738 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3739 )
3740 .unwrap();
3741 let f = crate::filter::CompiledFilter::compile(
3742 &fmt,
3743 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3744 CaseMode::Sensitive,
3745 )
3746 .unwrap();
3747 let mut v = Viewport::new(40, 10, "f".into());
3748 v.set_filter(Some(f));
3749 v.extend_visible_lines(&idx, &m);
3750 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3752 }
3753
3754 #[test]
3755 fn grep_matches_across_record_newlines_in_records_mode() {
3756 let m = MockSource::new();
3758 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
3759 let mut idx = LineIndex::new();
3760 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3761 idx.extend_to_end(&m);
3762 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3763 let mut v = Viewport::new(40, 10, "f".into());
3764 v.set_grep(Some(grep));
3765 v.extend_visible_lines(&idx, &m);
3766 assert_eq!(v.visible_lines(), &[0usize, 1]);
3768 }
3769
3770 #[test]
3771 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3772 let m = MockSource::new();
3775 m.append(b"[1] head\n cont\n[2] other\n cont\n");
3776 let mut idx = LineIndex::new();
3777 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3778 idx.extend_to_end(&m);
3779 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3780 let mut v = Viewport::new(40, 10, "f".into());
3781 v.set_grep(Some(grep));
3782 v.set_dim_mode(true);
3783 v.extend_visible_lines(&idx, &m);
3784 assert_eq!(v.visible_lines(), &[] as &[usize]);
3786 assert!(!v.should_dim_line(0, &idx, &m));
3788 assert!(!v.should_dim_line(1, &idx, &m));
3789 assert!(v.should_dim_line(2, &idx, &m));
3791 assert!(v.should_dim_line(3, &idx, &m));
3792 }
3793
3794 #[test]
3795 fn status_unchanged_when_records_inactive() {
3796 let (m, mut idx) = setup(b"a\nb\nc\n");
3797 let mut v = Viewport::new(20, 5, "f".into());
3798 let frame = v.frame(&m, &mut idx);
3799 let status = &frame.status;
3800 assert!(status.contains("1-3/3"), "got: {status}");
3802 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3803 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3804 }
3805
3806 #[test]
3807 fn status_r_block_uses_real_lines_in_hide_mode() {
3808 let m = MockSource::new();
3817 let mut buf = Vec::new();
3820 for n in 0..10 {
3821 let kind = if n >= 8 { "B" } else { "A" };
3822 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3823 }
3824 m.append(&buf);
3825 m.finish();
3826
3827 let mut idx = LineIndex::new();
3828 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3829 idx.extend_to_end(&m);
3830
3831 let fmt = crate::format::LogFormat::compile(
3832 "rec",
3833 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3834 )
3835 .unwrap();
3836 let f = crate::filter::CompiledFilter::compile(
3837 &fmt,
3838 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3839 CaseMode::Sensitive,
3840 )
3841 .unwrap();
3842
3843 let mut v = Viewport::new(80, 5, "f".into());
3846 v.set_filter(Some(f));
3847 v.extend_visible_lines(&idx, &m);
3848
3849 v.goto_record(8, &m, &mut idx);
3851
3852 let frame = v.frame(&m, &mut idx);
3853 assert!(
3855 frame.status.contains("R9-10/10"),
3856 "expected R9-10/10 in status, got: {}",
3857 frame.status,
3858 );
3859 }
3860
3861 #[test]
3862 fn status_dual_readout_when_records_active() {
3863 let m = MockSource::new();
3864 m.append(b"[1] a\n cont\n[2] b\n");
3865 m.finish();
3866 let mut idx = LineIndex::new();
3867 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3868 idx.extend_to_end(&m);
3869 let mut v = Viewport::new(20, 5, "f".into());
3870 let frame = v.frame(&m, &mut idx);
3871 let status = &frame.status;
3872 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3873 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3874 }
3875
3876 #[test]
3877 fn format_status_uses_custom_template_when_set() {
3878 let m = MockSource::new();
3879 m.append(b"a\nb\nc\n");
3880 m.finish();
3881 let mut idx = LineIndex::new();
3882 idx.extend_to_end(&m);
3883 let mut v = Viewport::new(20, 5, "f".into());
3884 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3885 v.set_prompt(Some(prompt));
3886 let frame = v.frame(&m, &mut idx);
3887 assert_eq!(frame.status, "f 100%");
3888 }
3889
3890 #[test]
3891 fn status_shows_preprocess_failed_tag_when_set() {
3892 let m = MockSource::new();
3893 m.append(b"a\n");
3894 let mut idx = LineIndex::new();
3895 idx.extend_to_end(&m);
3896 let mut v = Viewport::new(40, 5, "f".into());
3897 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3898 let frame = v.frame(&m, &mut idx);
3899 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3900 "got: {}", frame.status);
3901 }
3902
3903 #[test]
3904 fn default_status_includes_help_hint() {
3905 let (m, mut idx) = setup(b"a\nb\nc\n");
3906 let mut v = Viewport::new(80, 5, "f".into());
3907 let frame = v.frame(&m, &mut idx);
3908 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3909 }
3910
3911 #[test]
3912 fn custom_prompt_does_not_get_help_hint() {
3913 let (m, mut idx) = setup(b"a\nb\nc\n");
3914 let mut v = Viewport::new(80, 5, "f".into());
3915 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3916 let frame = v.frame(&m, &mut idx);
3917 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3918 }
3919
3920 #[test]
3921 fn status_shows_file_index_when_multifile() {
3922 let m = MockSource::new();
3923 m.append(b"a\n");
3924 let mut idx = LineIndex::new();
3925 idx.extend_to_end(&m);
3926 let mut v = Viewport::new(60, 5, "f.log".into());
3927 v.set_file_index(0, 3);
3928 let frame = v.frame(&m, &mut idx);
3929 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3930 }
3931
3932 #[test]
3933 fn status_omits_file_index_when_single_file() {
3934 let m = MockSource::new();
3935 m.append(b"a\n");
3936 let mut idx = LineIndex::new();
3937 idx.extend_to_end(&m);
3938 let mut v = Viewport::new(60, 5, "f.log".into());
3939 v.set_file_index(0, 1);
3940 let frame = v.frame(&m, &mut idx);
3941 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3942 }
3943
3944 #[test]
3945 fn status_shows_tag_active_when_multimatch() {
3946 let m = MockSource::new();
3947 m.append(b"a\n");
3948 let mut idx = LineIndex::new();
3949 idx.extend_to_end(&m);
3950 let mut v = Viewport::new(80, 5, "f.log".into());
3951 v.set_tag_active(Some(("foo".into(), 2, 3)));
3952 let frame = v.frame(&m, &mut idx);
3953 assert!(
3954 frame.status.contains("[tag: foo (2/3)]"),
3955 "got: {}",
3956 frame.status
3957 );
3958 }
3959
3960 #[test]
3961 fn status_omits_tag_active_when_single_match() {
3962 let m = MockSource::new();
3963 m.append(b"a\n");
3964 let mut idx = LineIndex::new();
3965 idx.extend_to_end(&m);
3966 let mut v = Viewport::new(80, 5, "f.log".into());
3967 v.set_tag_active(Some(("foo".into(), 1, 1)));
3968 let frame = v.frame(&m, &mut idx);
3969 assert!(
3970 !frame.status.contains("[tag:"),
3971 "should not show indicator for single match: {}",
3972 frame.status
3973 );
3974 }
3975
3976 #[test]
3977 fn hscroll_noop_when_wrapping() {
3978 let mut v = Viewport::new(80, 24, "t".into());
3979 v.hscroll_right_step();
3981 assert_eq!(v.left_col(), 0);
3982 }
3983
3984 #[test]
3985 fn hscroll_active_in_chop_and_clamps_at_zero() {
3986 let mut v = Viewport::new(80, 24, "t".into());
3987 v.toggle_chop(); assert!(v.hscroll_active());
3989 v.hscroll_right_step();
3990 assert_eq!(v.left_col(), 8);
3991 v.hscroll_right_half();
3992 assert_eq!(v.left_col(), 8 + 40); v.hscroll_left_half();
3994 assert_eq!(v.left_col(), 8);
3995 v.hscroll_left_half();
3996 assert_eq!(v.left_col(), 0); }
3998
3999 #[test]
4000 fn hscroll_by_explicit_cols_moves_left_col() {
4001 let mut v = Viewport::new(80, 24, "t".into());
4003 v.toggle_chop(); v.hscroll_right_cols(12);
4005 assert_eq!(v.left_col(), 12);
4006 v.hscroll_right_cols(12);
4007 assert_eq!(v.left_col(), 24);
4008 v.hscroll_left_cols(12);
4009 assert_eq!(v.left_col(), 12);
4010 v.hscroll_left_cols(99);
4011 assert_eq!(v.left_col(), 0); }
4013
4014 #[test]
4015 fn hscroll_resets_to_zero_when_wrap_turned_on() {
4016 let mut v = Viewport::new(80, 24, "t".into());
4017 v.toggle_chop(); v.hscroll_right_step();
4019 assert_eq!(v.left_col(), 8);
4020 v.toggle_chop(); assert_eq!(v.left_col(), 0);
4022 }
4023
4024 #[test]
4025 fn reset_hscroll_zeroes_left_col() {
4026 let mut v = Viewport::new(80, 24, "t".into());
4028 v.toggle_chop();
4029 v.hscroll_right_step();
4030 assert_eq!(v.left_col(), 8);
4031 v.reset_hscroll();
4032 assert_eq!(v.left_col(), 0);
4033 }
4034
4035 #[test]
4038 fn reconstruct_picks_up_state_from_prior_lines() {
4039 let m = MockSource::new();
4040 m.append(b"\x1b[31mline 1\n");
4041 m.append(b"line 2 (still red, no reset)\n");
4042 m.append(b"line 3\n");
4043 let mut idx = LineIndex::new();
4044 idx.extend_to_end(&m);
4045 let state = reconstruct_render_state(&m, &idx, 2);
4046 assert_eq!(
4047 state.style.fg,
4048 Some(crate::ansi::Color::Ansi(1)),
4049 "red SGR from line 0 should persist to line 2"
4050 );
4051 }
4052
4053 #[test]
4054 fn reconstruct_respects_reset_between_lines() {
4055 let m = MockSource::new();
4056 m.append(b"\x1b[31mline 1\x1b[0m\n");
4057 m.append(b"line 2 (default)\n");
4058 let mut idx = LineIndex::new();
4059 idx.extend_to_end(&m);
4060 let state = reconstruct_render_state(&m, &idx, 1);
4061 assert_eq!(state.style.fg, None);
4062 }
4063
4064 #[test]
4065 fn reconstruct_caps_walkback_at_max_lines() {
4066 let m = MockSource::new();
4067 m.append(b"\x1b[31mvery early\n");
4068 for _ in 0..300 {
4069 m.append(b"line\n");
4070 }
4071 let mut idx = LineIndex::new();
4072 idx.extend_to_end(&m);
4073 let state = reconstruct_render_state(&m, &idx, 290);
4076 assert_eq!(state.style.fg, None);
4077 }
4078
4079 #[test]
4080 fn or_groups_narrow_within_required_line_mode() {
4081 let mut raw = crate::or::OrSpecRaw::new();
4082 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
4083 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
4084 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
4085 let mut v = Viewport::new(80, 24, "t".into());
4086 v.set_or_groups(og);
4087 assert!(v.or_active());
4088 assert!(v.line_passes(b"login failed"));
4089 assert!(v.line_passes(b"access denied"));
4090 assert!(!v.line_passes(b"login ok"));
4091 }
4092
4093 #[test]
4094 fn status_shows_or_indicator_when_active() {
4095 let mut raw = crate::or::OrSpecRaw::new();
4096 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
4097 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
4098 let (m, mut idx) = setup(b"x\ny\nx\n");
4099 idx.extend_to_end(&m);
4100 let mut v = Viewport::new(80, 5, "f".into());
4101 v.set_or_groups(og);
4102 v.extend_visible_lines(&idx, &m);
4103 let status = v.format_status(&idx, &m);
4104 assert!(status.contains("[or]"), "expected [or] in status: {status}");
4105 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
4106 }
4107
4108 #[test]
4109 fn status_shows_col_offset_when_scrolled() {
4110 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
4112 let (m, mut idx) = setup(content);
4113 let mut v = Viewport::new(10, 3, "t".into());
4114 v.toggle_chop(); v.hscroll_right_step(); let f = v.frame(&m, &mut idx);
4117 assert!(
4118 f.status.contains('\u{00bb}'),
4119 "expected » in status after hscroll_right_step, got: {}",
4120 f.status
4121 );
4122 }
4123
4124 #[test]
4125 fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
4126 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
4135 let (m, mut idx) = setup(content);
4136
4137 let mut v = Viewport::new(10, 3, "t".into());
4139 v.toggle_chop(); let frame0 = v.frame(&m, &mut idx);
4143 assert_eq!(
4144 frame0.body[0][0],
4145 Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4146 "at left_col=0 first cell should be 'A'"
4147 );
4148 assert!(
4150 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
4151 "no left marker expected at left_col=0"
4152 );
4153
4154 v.hscroll_right_step();
4156 assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
4157
4158 let frame1 = v.frame(&m, &mut idx);
4159 assert_eq!(
4161 frame1.body[0][0],
4162 Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
4163 "after scrolling right, first cell should be the '<' left marker"
4164 );
4165 assert_eq!(
4169 frame1.body[0][1],
4170 Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4171 "second cell should be 'J' (display column left_col+1 = 9)"
4172 );
4173 }
4174}