1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::or::OrGroups;
8use crate::line_index::LineIndex;
9use crate::render::{count_rows, render_line, Cell, RenderOpts};
10use crate::source::Source;
11
12const MAX_RECONSTRUCT_LINES: usize = 256;
16
17fn reconstruct_render_state(
24 src: &dyn Source,
25 idx: &crate::line_index::LineIndex,
26 target_line: usize,
27) -> crate::render::RenderState {
28 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
29 let mut state = crate::render::RenderState::default();
30 for line_no in start..target_line {
31 let range = idx.line_range(line_no, src);
32 let raw = src.bytes(range);
33 for &b in raw.as_ref() {
34 let _ = crate::ansi::step(
35 &mut state.parse,
36 &mut state.style,
37 &mut state.hyperlink,
38 b,
39 );
40 }
41 }
42 state
43}
44
45fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
51 let mut text = String::new();
52 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
53 for (col, cell) in row.iter().enumerate() {
54 match cell {
55 Cell::Char { ch, .. } => {
56 starts.push(col);
57 text.push(*ch);
58 }
59 Cell::Empty => {
60 starts.push(col);
61 text.push(' ');
62 }
63 Cell::Continuation => {}
64 }
65 }
66 starts.push(row.len());
67 (text, starts)
68}
69
70fn line_is_blank(bytes: &[u8]) -> bool {
75 bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
76}
77
78fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
82 if row.is_empty() {
83 return Vec::new();
84 }
85 let last_content_col = row
86 .iter()
87 .enumerate()
88 .rev()
89 .find_map(|(c, cell)| match cell {
90 Cell::Char { width, .. } => Some(c + *width as usize),
91 Cell::Continuation => Some(c + 1),
92 Cell::Empty => None,
93 })
94 .unwrap_or(0);
95 if last_content_col == 0 {
96 return Vec::new();
97 }
98 let (text, starts) = row_text_and_starts(row);
99 let mut out = Vec::new();
100 for m in regex.find_iter(&text) {
101 if m.start() == m.end() {
102 continue;
103 }
104 let char_start = text[..m.start()].chars().count();
105 let char_end = text[..m.end()].chars().count();
106 if char_start >= starts.len() - 1 || char_end <= char_start {
107 continue;
108 }
109 let col_start = starts[char_start];
110 let col_end = starts[char_end].min(last_content_col);
111 if col_end > col_start {
112 out.push(col_start..col_end);
113 }
114 }
115 out
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RowStyle {
120 Normal,
121 Dim,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SearchDirection {
128 Forward,
129 Backward,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum CaseMode {
138 #[default]
139 Sensitive,
140 Smart,
141 Insensitive,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum QuitAtEof {
150 #[default]
151 Off,
152 Second,
153 First,
154}
155
156impl CaseMode {
157 pub fn apply_to_pattern(self, pattern: &str) -> String {
160 match self {
161 CaseMode::Sensitive => pattern.to_string(),
162 CaseMode::Insensitive => format!("(?i){pattern}"),
163 CaseMode::Smart => {
164 if pattern.chars().any(|c| c.is_uppercase()) {
165 pattern.to_string()
166 } else {
167 format!("(?i){pattern}")
168 }
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone)]
175pub struct SearchState {
176 pub raw: String,
177 pub regex: Regex,
178 pub direction: SearchDirection,
179}
180
181#[derive(Debug, Clone)]
182pub struct Frame {
183 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
190 pub status: String,
191 pub status_style: crate::ansi::Style,
193 pub raw_rows: Vec<Option<Vec<u8>>>,
201}
202
203pub struct Viewport {
204 top_line: usize,
205 top_row: usize,
206 left_col: usize,
210 cols: u16,
211 rows: u16,
212 pub opts: RenderOpts,
213 pub show_line_numbers: bool,
214 pub source_label: String,
215 follow_mode: bool,
216 live_mode: bool,
217 prettify_label: Option<String>,
218 format_label: Option<String>,
219 filter: Option<CompiledFilter>,
220 grep: Option<GrepPredicate>,
221 or_groups: OrGroups,
222 dim_mode: bool,
223 visible_lines: Vec<usize>,
226 visible_scanned: usize,
229 search: Option<SearchState>,
230 display: Option<crate::format::DisplayRenderer>,
234 hex_mode: bool,
235 #[cfg(feature = "image")]
236 image: Option<image::RgbaImage>,
237 image_mode: bool,
238 image_no_color: bool,
239 #[cfg_attr(not(feature = "image"), allow(dead_code))]
240 image_format: String,
241 #[cfg(feature = "image")]
242 image_style: crate::image_render::AsciiStyle,
243 #[cfg_attr(not(feature = "image"), allow(dead_code))]
244 image_width: Option<usize>,
245 hex_group_size: usize,
248 prompt: Option<crate::prompt::ParsedPrompt>,
251 preprocess_failure: Option<String>,
254 file_index: Option<(usize, usize)>,
256 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
260 status_style: crate::ansi::Style,
264 status_flash: Option<(String, u32)>,
269 ticks_since_growth: u32,
274 case_mode: CaseMode,
278 hilite_search: bool,
282 quit_at_eof: QuitAtEof,
284 eof_hits: u8,
287 squeeze_blanks: bool,
291 header_lines: usize,
296 header_cols: usize,
297 page_size: Option<u16>,
301 render_state: crate::render::RenderState,
305 render_state_for: usize,
308}
309
310impl Viewport {
311 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
312 let opts = RenderOpts { cols, ..RenderOpts::default() };
313 Self {
314 top_line: 0,
315 top_row: 0,
316 left_col: 0,
317 cols,
318 rows,
319 opts,
320 show_line_numbers: false,
321 source_label,
322 follow_mode: false,
323 live_mode: false,
324 prettify_label: None,
325 format_label: None,
326 filter: None,
327 grep: None,
328 or_groups: OrGroups::default(),
329 dim_mode: false,
330 visible_lines: Vec::new(),
331 visible_scanned: 0,
332 search: None,
333 display: None,
334 hex_mode: false,
335 #[cfg(feature = "image")]
336 image: None,
337 image_mode: false,
338 image_no_color: false,
339 image_format: String::new(),
340 #[cfg(feature = "image")]
341 image_style: crate::image_render::AsciiStyle::Ramp,
342 image_width: None,
343 hex_group_size: 2,
344 prompt: None,
345 preprocess_failure: None,
346 file_index: None,
347 tag_active: None,
348 ansi_mode: crate::render::AnsiMode::Strict,
349 status_style: crate::ansi::Style { reverse: true, ..Default::default() },
350 status_flash: None,
351 ticks_since_growth: 0,
352 case_mode: CaseMode::default(),
353 hilite_search: true,
354 quit_at_eof: QuitAtEof::default(),
355 eof_hits: 0,
356 squeeze_blanks: false,
357 header_lines: 0,
358 header_cols: 0,
359 page_size: None,
360 render_state: crate::render::RenderState::default(),
361 render_state_for: usize::MAX,
362 }
363 }
364
365 pub fn case_mode(&self) -> CaseMode { self.case_mode }
366
367 pub fn hilite_search(&self) -> bool { self.hilite_search }
368
369 pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
370
371 pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
372 self.quit_at_eof = mode;
373 self.eof_hits = 0;
374 }
375
376 pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
377 pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
378
379 pub fn set_header(&mut self, lines: usize, cols: usize) {
380 self.header_lines = lines;
381 self.header_cols = cols;
382 if self.top_line < self.header_lines {
385 self.top_line = self.header_lines;
386 }
387 }
388 pub fn header_lines(&self) -> usize { self.header_lines }
389 pub fn header_cols(&self) -> usize { self.header_cols }
390
391 pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
392 pub fn page_size(&self) -> Option<u16> { self.page_size }
393
394 pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
399 match self.quit_at_eof {
400 QuitAtEof::Off => false,
401 QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
402 QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
403 self.eof_hits = self.eof_hits.saturating_add(1);
404 self.eof_hits >= 2
405 }
406 _ => {
407 if !forward { self.eof_hits = 0; }
408 false
409 }
410 }
411 }
412
413 pub fn set_case_mode(&mut self, mode: CaseMode) {
417 self.case_mode = mode;
418 if let Some(s) = self.search.clone() {
419 let _ = self.set_search(s.raw, s.direction);
420 }
421 }
422
423 pub fn set_status_style(&mut self, style: crate::ansi::Style) {
424 self.status_style = style;
425 }
426
427 pub fn status_style(&self) -> crate::ansi::Style {
428 self.status_style
429 }
430
431 pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
435 self.status_flash = Some((msg.into(), ticks));
436 }
437
438 pub fn tick_flash(&mut self) {
441 if let Some((_, n)) = &mut self.status_flash {
442 *n = n.saturating_sub(1);
443 if *n == 0 {
444 self.status_flash = None;
445 }
446 }
447 }
448
449 pub fn note_growth(&mut self) {
451 self.ticks_since_growth = 0;
452 }
453
454 pub fn tick_idle(&mut self) {
457 self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
458 }
459
460 pub fn is_idle(&self) -> bool {
463 self.ticks_since_growth >= 20
464 }
465
466 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
467 self.display = renderer;
468 }
469
470 pub fn set_hex_mode(&mut self, on: bool) {
471 self.hex_mode = on;
472 }
473
474 pub fn hex_mode(&self) -> bool {
476 self.hex_mode
477 }
478
479 #[cfg(feature = "image")]
480 pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
481 self.image = Some(img);
482 self.image_format = format.to_string();
483 self.image_style = style;
484 self.image_width = width;
485 self.image_mode = true;
486 self.top_line = 0;
487 self.top_row = 0;
488 }
489
490 pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
491
492 pub fn image_mode(&self) -> bool { self.image_mode }
493
494 #[cfg(feature = "image")]
495 fn image_cols(&self) -> u16 {
496 self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
497 }
498
499 #[cfg(feature = "image")]
500 pub fn image_total_rows(&self) -> usize {
501 match &self.image {
502 Some(img) => {
503 let (w, h) = img.dimensions();
504 crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
505 }
506 None => 0,
507 }
508 }
509
510 #[cfg(feature = "image")]
511 pub fn is_at_bottom_image(&self) -> bool {
512 let body = self.body_rows() as usize;
513 self.top_line + body >= self.image_total_rows()
514 }
515
516 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
519 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
520 self.hex_group_size = bytes_per_group;
521 }
522 }
523
524 pub fn hex_group_size(&self) -> usize {
526 self.hex_group_size
527 }
528
529 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
530 self.prompt = prompt;
531 }
532
533 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
534 self.preprocess_failure = msg;
535 }
536
537 pub fn set_file_index(&mut self, current: usize, total: usize) {
538 self.file_index = if total > 1 {
539 Some((current, total))
540 } else {
541 None
542 };
543 }
544
545 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
546 self.tag_active = info;
547 }
548
549 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
550 self.ansi_mode = mode;
551 }
552
553 pub fn ansi_mode(&self) -> crate::render::AnsiMode {
554 self.ansi_mode
555 }
556
557 pub fn set_source_label(&mut self, label: String) {
558 self.source_label = label;
559 }
560
561 pub fn source_label_clone(&self) -> String {
562 self.source_label.clone()
563 }
564
565 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
570 let range = idx.line_range(line_n, src);
571 let raw = src.bytes(range);
572 if let Some(r) = self.display.as_ref() {
573 if let Some(rendered) = r.render_line(&raw) {
574 return std::borrow::Cow::Owned(rendered.into_bytes());
575 }
576 }
577 raw
578 }
579
580 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
584 let compiled = self.case_mode.apply_to_pattern(&raw);
585 let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
586 self.search = Some(SearchState { raw, regex, direction });
587 Ok(())
588 }
589
590 pub fn clear_search(&mut self) { self.search = None; }
591
592 pub fn search_active(&self) -> bool { self.search.is_some() }
593
594 pub fn search_direction(&self) -> SearchDirection {
595 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
596 }
597
598 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
602 if idx.records_mode() {
603 self.search_repeat_records(src, idx, reverse)
604 } else {
605 self.search_repeat_lines(src, idx, reverse)
606 }
607 }
608
609 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
611 let Some(s) = self.search.as_ref() else { return false; };
612 let forward = matches!(
613 (s.direction, reverse),
614 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
615 );
616 idx.extend_to_end(src);
617 let pattern = s.regex.clone();
618 if self.hide_mode() {
619 self.extend_visible_lines(idx, src);
620 self.search_step_in_visible(&pattern, src, idx, forward)
621 } else {
622 self.search_step_in_logical(&pattern, src, idx, forward)
623 }
624 }
625
626 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
630 let Some(s) = self.search.as_ref() else { return false; };
631 let forward = matches!(
632 (s.direction, reverse),
633 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
634 );
635 let pattern = s.regex.clone();
636 idx.extend_to_end(src);
637
638 let total = idx.record_count();
639 if total == 0 { return false; }
640
641 let cur_record = idx.line_to_record(self.top_line);
642
643 let range: Box<dyn Iterator<Item = usize>> = if forward {
644 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
645 } else {
646 let earlier: Vec<usize> = (0..cur_record).rev().collect();
647 let later: Vec<usize> = (cur_record..total).rev().collect();
648 Box::new(earlier.into_iter().chain(later))
649 };
650
651 for r in range {
652 let bytes = idx.record_bytes_stripped(r, src);
653 let text = String::from_utf8_lossy(&bytes);
654 if pattern.is_match(&text) {
655 let line_range = idx.record_line_range(r);
656 self.top_line = line_range.start;
657 self.top_row = 0;
658 return true;
659 }
660 }
661 false
662 }
663
664 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
665 let display = self.line_display_bytes(src, idx, line_n);
670 let bytes = crate::ansi::strip_sgr(&display);
671 match std::str::from_utf8(&bytes) {
672 Ok(s) => pattern.is_match(s),
673 Err(_) => false,
674 }
675 }
676
677 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
678 let total = idx.line_count();
679 if total == 0 { return false; }
680 let start = self.top_line;
681 for offset in 1..=total {
684 let line_n = if forward {
685 (start + offset) % total
686 } else {
687 (start + total - offset) % total
688 };
689 if self.line_matches(pattern, src, idx, line_n) {
690 self.top_line = line_n;
691 self.top_row = 0;
692 return true;
693 }
694 }
695 false
696 }
697
698 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
699 let total = self.visible_lines.len();
700 if total == 0 { return false; }
701 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
703 for offset in 1..=total {
704 let visible_idx = if forward {
705 (cur + offset) % total
706 } else {
707 (cur + total - offset) % total
708 };
709 let line_n = self.visible_lines[visible_idx];
710 if self.line_matches(pattern, src, idx, line_n) {
711 self.top_line = line_n;
712 self.top_row = 0;
713 return true;
714 }
715 }
716 false
717 }
718
719 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
720 self.filter = filter;
721 self.visible_lines.clear();
722 self.visible_scanned = 0;
723 self.top_line = 0;
725 self.top_row = 0;
726 self.left_col = 0;
727 }
728
729 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
730 self.grep = grep;
731 self.visible_lines.clear();
732 self.visible_scanned = 0;
733 self.top_line = 0;
734 self.top_row = 0;
735 self.left_col = 0;
736 }
737
738 pub fn set_or_groups(&mut self, or_groups: OrGroups) {
739 self.or_groups = or_groups;
740 self.visible_lines.clear();
741 self.visible_scanned = 0;
742 self.top_line = 0;
743 self.top_row = 0;
744 self.left_col = 0;
745 }
746
747 pub fn or_active(&self) -> bool {
748 self.or_groups.is_active()
749 }
750
751 pub fn grep_active(&self) -> bool { self.grep.is_some() }
752
753 pub fn set_dim_mode(&mut self, on: bool) {
754 self.dim_mode = on;
755 self.visible_lines.clear();
759 self.visible_scanned = 0;
760 }
761
762 pub fn filter_active(&self) -> bool { self.filter.is_some() }
763
764 pub fn dim_mode(&self) -> bool { self.dim_mode }
765
766 fn hide_mode(&self) -> bool {
767 (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
768 && !self.dim_mode
769 }
770
771 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
776 if !self.hide_mode() {
777 return;
778 }
779 if idx.records_mode() {
780 self.extend_visible_lines_records(idx, src);
781 } else {
782 self.extend_visible_lines_per_line(idx, src);
783 }
784 }
785
786 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
788 let total = idx.line_count();
789 while self.visible_scanned < total {
790 let line_n = self.visible_scanned;
791 let bytes = idx.line_bytes_stripped(line_n, src);
792 if self.line_passes(&bytes) {
793 self.visible_lines.push(line_n);
794 }
795 self.visible_scanned += 1;
796 }
797 }
798
799 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
806 self.visible_lines.clear();
807 self.visible_scanned = 0; let total_records = idx.record_count();
809 for r in 0..total_records {
810 if self.record_passes(idx, src, r) {
811 for line_n in idx.record_line_range(r) {
812 self.visible_lines.push(line_n);
813 }
814 }
815 }
816 }
817
818 fn line_passes(&self, line: &[u8]) -> bool {
824 let filter_ok = match self.filter.as_ref() {
825 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
826 None => true,
827 };
828 let grep_ok = match self.grep.as_ref() {
829 Some(g) => g.matches(line),
830 None => true,
831 };
832 filter_ok && grep_ok && self.or_groups.matches_line(line)
833 }
834
835 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
843 let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
844 let bytes = if need {
845 Some(idx.record_bytes_stripped(r, src))
846 } else {
847 None
848 };
849 let filter_ok = match self.filter.as_ref() {
850 Some(f) => matches!(
851 f.evaluate_record(bytes.as_deref().unwrap()),
852 FilterMatch::Matched,
853 ),
854 None => true,
855 };
856 let grep_ok = match self.grep.as_ref() {
857 Some(g) => g.matches(bytes.as_deref().unwrap()),
858 None => true,
859 };
860 let or_ok = if self.or_groups.is_active() {
861 self.or_groups.matches_record(bytes.as_deref().unwrap())
862 } else {
863 true
864 };
865 filter_ok && grep_ok && or_ok
866 }
867
868 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
872 if !self.dim_mode {
873 return false;
874 }
875 if idx.records_mode() {
876 let r = idx.line_to_record(line_n);
877 !self.record_passes(idx, src, r)
878 } else {
879 let bytes = idx.line_bytes_stripped(line_n, src);
880 !self.line_passes(&bytes)
881 }
882 }
883
884 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
892 let body_rows = self.body_rows() as usize;
893 if self.hide_mode() && !self.visible_lines.is_empty() {
894 let cur = self
895 .visible_lines
896 .iter()
897 .position(|&l| l >= self.top_line)
898 .unwrap_or(self.visible_lines.len().saturating_sub(1));
899 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
900 return self.visible_lines[last_pos];
901 }
902 let total = idx.line_count();
903 if total == 0 {
904 return self.top_line;
905 }
906 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
907 }
908
909 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
910
911 pub fn follow_mode(&self) -> bool { self.follow_mode }
912
913 pub fn suspend_follow_if(&mut self, flag: bool) {
918 if flag {
919 self.follow_mode = false;
920 }
921 }
922
923 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
924
925 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
926
927 pub fn live_mode(&self) -> bool { self.live_mode }
928
929 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
930
931 pub fn set_prettify_label(&mut self, label: Option<String>) {
934 self.prettify_label = label;
935 }
936
937 pub fn set_format_label(&mut self, label: Option<String>) {
940 self.format_label = label;
941 }
942
943 pub fn invalidate_filter_cache(&mut self) {
948 self.visible_lines.clear();
949 self.visible_scanned = 0;
950 }
951
952 pub fn clamp_top_line(&mut self, line_count: usize) {
955 if line_count == 0 {
956 self.top_line = 0;
957 self.top_row = 0;
958 } else if self.top_line >= line_count {
959 self.top_line = line_count - 1;
960 self.top_row = 0;
961 }
962 }
963
964 pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
968 #[cfg(feature = "image")]
969 if self.image_mode {
970 return self.is_at_bottom_image();
971 }
972 if self.hide_mode() {
973 (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
977 } else {
978 (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
982 }
983 }
984
985 fn gutter_width(&self, idx: &LineIndex) -> u16 {
987 if !self.show_line_numbers { return 0; }
988 let n = idx.line_count().max(1);
989 let digits = (n as f64).log10().floor() as u16 + 1;
990 digits + 1
991 }
992
993 fn render_opts(&self, gutter: u16) -> RenderOpts {
994 let mut o = self.opts.clone();
995 o.cols = self.cols.saturating_sub(gutter);
996 o.mode = self.ansi_mode;
997 o.left_col = self.left_col; o
999 }
1000
1001 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1002 #[cfg(feature = "image")]
1003 if self.image_mode {
1004 return self.frame_image();
1005 }
1006 if self.hex_mode {
1007 return self.frame_hex(src);
1008 }
1009 let body_rows = self.body_rows() as usize;
1010 idx.extend_to_line(self.top_line + body_rows + 1, src);
1011
1012 if self.left_col > 0 && self.hscroll_active() {
1015 let gutter_for_clamp = self.gutter_width(idx);
1016 let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1017 let mut width_opts = self.opts.clone();
1020 width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1021 width_opts.mode = self.ansi_mode;
1022 width_opts.left_col = 0;
1023 let mut widest = 0usize;
1024 let total_lines_for_clamp = idx.line_count();
1025 if self.hide_mode() {
1026 let hide_pos = self.visible_lines.iter()
1027 .position(|&l| l >= self.top_line)
1028 .unwrap_or(self.visible_lines.len());
1029 let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1030 for vi in hide_pos..end_vi {
1031 let ln = self.visible_lines[vi];
1032 let bytes = self.line_display_bytes(src, idx, ln);
1033 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1034 }
1035 } else {
1036 let start = self.top_line.max(self.header_lines);
1037 let end = (start + body_rows).min(total_lines_for_clamp);
1038 for ln in start..end {
1039 let bytes = self.line_display_bytes(src, idx, ln);
1040 widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1041 }
1042 }
1043 self.left_col = self.left_col.min(widest.saturating_sub(avail));
1044 }
1045
1046 let gutter = self.gutter_width(idx);
1047 let r_opts = self.render_opts(gutter);
1048
1049 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1053 reconstruct_render_state(src, idx, self.top_line)
1054 } else {
1055 crate::render::RenderState::default()
1056 };
1057 self.render_state = render_state.clone();
1059 self.render_state_for = self.top_line;
1060
1061 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1062 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1063 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1064 let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1065 let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1066 let hide = self.hide_mode();
1068 let total_lines = idx.line_count();
1069
1070 let header_rows = if !hide && !raw_passthrough {
1077 self.header_lines.min(body_rows).min(total_lines)
1078 } else {
1079 0
1080 };
1081 if header_rows > 0 {
1082 for hl in 0..header_rows {
1083 let raw = src.bytes(idx.line_range(hl, src));
1084 let display_bytes = if let Some(r) = self.display.as_ref() {
1085 match r.render_line(&raw) {
1086 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1087 None => raw.clone(),
1088 }
1089 } else {
1090 raw.clone()
1091 };
1092 let rows = render_line(&display_bytes, &r_opts, None);
1093 let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1094 let mut v = Vec::with_capacity(self.cols as usize);
1095 while v.len() < self.cols as usize { v.push(Cell::Empty); }
1096 v
1097 });
1098 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1099 if gutter > 0 {
1100 let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1101 for c in label.chars() {
1102 full.push(Cell::Char {
1103 ch: c,
1104 width: 1,
1105 style: crate::ansi::Style::default(),
1106 hyperlink: None,
1107 });
1108 }
1109 }
1110 full.append(&mut content_row);
1111 body.push(full);
1112 row_styles.push(RowStyle::Normal);
1113 highlights.push(Vec::new());
1114 raw_rows.push(None);
1115 }
1116 }
1117
1118 let mut hide_pos = if hide {
1120 self.visible_lines
1121 .iter()
1122 .position(|&l| l >= self.top_line)
1123 .unwrap_or(self.visible_lines.len())
1124 } else {
1125 0
1126 };
1127 let mut line_n = if hide {
1128 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1129 } else {
1130 self.top_line.max(self.header_lines)
1133 };
1134 let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1135
1136 while body.len() < body_rows {
1137 if line_n >= total_lines {
1138 let mut row = Vec::with_capacity(self.cols as usize);
1139 if gutter > 0 {
1140 for _ in 0..gutter { row.push(Cell::Empty); }
1141 }
1142 while row.len() < self.cols as usize { row.push(Cell::Empty); }
1143 body.push(row);
1144 row_styles.push(RowStyle::Normal);
1145 highlights.push(Vec::new());
1146 raw_rows.push(None);
1147 line_n += 1;
1148 continue;
1149 }
1150 let raw = src.bytes(idx.line_range(line_n, src));
1153 if self.squeeze_blanks && line_is_blank(&raw) {
1158 let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1159 let prev = src.bytes(idx.line_range(p, src));
1160 line_is_blank(&prev)
1161 });
1162 if prev_blank {
1163 line_n += 1;
1164 continue;
1165 }
1166 }
1167 let display_bytes = if let Some(r) = self.display.as_ref() {
1168 match r.render_line(&raw) {
1169 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1170 None => raw.clone(),
1171 }
1172 } else {
1173 raw.clone()
1174 };
1175 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1176 Some(&mut render_state)
1177 } else {
1178 None
1179 };
1180 let rows = render_line(&display_bytes, &r_opts, state_arg);
1181 let style = if self.filter.is_some() || self.grep.is_some() {
1182 if self.dim_mode {
1183 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1184 } else {
1185 RowStyle::Normal
1187 }
1188 } else {
1189 RowStyle::Normal
1190 };
1191
1192 let mut first_emitted_for_this_line = true;
1193 for (i, mut content_row) in rows.into_iter().enumerate() {
1194 if i < skip { continue; }
1195 if body.len() >= body_rows { break; }
1196 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1197 if gutter > 0 {
1198 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1199 for c in label.chars() {
1200 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1201 }
1202 }
1203 full.append(&mut content_row);
1204 if self.left_col > 0 && !self.opts.wrap {
1209 let marker_col = gutter as usize;
1210 if let Some(cell) = full.get_mut(marker_col) {
1211 *cell = Cell::Char {
1212 ch: '<',
1213 width: 1,
1214 style: crate::ansi::Style { dim: true, ..Default::default() },
1215 hyperlink: None,
1216 };
1217 }
1218 }
1219 let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1223 find_row_highlights(&full, &s.regex)
1224 } else {
1225 Vec::new()
1226 };
1227 body.push(full);
1228 row_styles.push(style);
1229 highlights.push(row_highlights);
1230 if raw_passthrough {
1231 if first_emitted_for_this_line {
1232 raw_rows.push(Some(raw.to_vec()));
1237 first_emitted_for_this_line = false;
1238 } else {
1239 raw_rows.push(Some(Vec::new()));
1240 }
1241 } else {
1242 raw_rows.push(None);
1243 }
1244 }
1245 skip = 0;
1246 if hide {
1248 hide_pos += 1;
1249 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1250 } else {
1251 line_n += 1;
1252 }
1253 }
1254
1255 self.render_state_for = usize::MAX;
1258
1259 let status = self.format_status(idx, src);
1260 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1261 }
1262
1263 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1264 if let Some(p) = self.prompt.as_ref() {
1265 let ctx = self.build_prompt_context(idx, src);
1266 return p.render(&ctx);
1267 }
1268 let body_rows = self.body_rows() as usize;
1269 let total = idx.line_count();
1270 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1273 let visible_total = self.visible_lines.len();
1274 let cur = self
1276 .visible_lines
1277 .iter()
1278 .position(|&l| l >= self.top_line)
1279 .unwrap_or(visible_total);
1280 let top = cur + 1;
1281 let bottom = (cur + body_rows).min(visible_total.max(1));
1282 let total_str = if src.is_complete() {
1283 format!("{visible_total}/{total}")
1284 } else {
1285 format!("{visible_total}/{total}+")
1286 };
1287 (top, bottom, visible_total, total_str)
1288 } else {
1289 let top = self.top_line + 1;
1290 let bottom = (self.top_line + body_rows).min(total.max(1));
1291 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1292 (top, bottom, total, total_str)
1293 };
1294 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1295 let bottom_line = self.bottom_visible_line(idx);
1299 let (line_prefix, records_block) = if idx.records_mode() {
1300 let line_total = idx.line_count();
1301 let rec_total = idx.record_count();
1302 let rec_block = if line_total == 0 || rec_total == 0 {
1303 format!("R0-0/{}", rec_total)
1304 } else {
1305 let rec_top = idx.line_to_record(self.top_line) + 1;
1306 let rec_bottom = idx.line_to_record(bottom_line) + 1;
1307 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1308 (rec_top, rec_top)
1312 } else {
1313 (rec_top, rec_bottom)
1314 };
1315 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1316 };
1317 ("L", Some(rec_block))
1318 } else {
1319 ("", None)
1320 };
1321 let middle = match records_block {
1322 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
1323 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
1324 };
1325 let label_with_index = match self.file_index {
1326 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1327 None => self.source_label.clone(),
1328 };
1329 let mut s = format!("{} {}", label_with_index, middle);
1330 if !self.hide_mode() && self.top_row > 0 {
1335 let line_rows = if total > 0 {
1336 let bytes = self.line_display_bytes(src, idx, self.top_line);
1337 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1338 } else { 1 };
1339 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
1340 }
1341 if self.left_col > 0 {
1342 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1343 }
1344 if let Some(f) = self.filter.as_ref() {
1345 s.push_str(&format!(" [{}]", f.format_name));
1346 }
1347 if self.grep.is_some() {
1348 s.push_str(" [grep]");
1349 }
1350 if self.or_groups.is_active() {
1351 s.push_str(" [or]");
1352 }
1353 if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1354 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
1355 }
1356 if let Some(sr) = self.search.as_ref() {
1357 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1358 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
1359 }
1360 if let Some(label) = self.prettify_label.as_ref() {
1361 s.push_str(&format!(" [pretty:{label}]"));
1362 }
1363 if self.live_mode { s.push_str(" (L)"); }
1364 if self.follow_mode {
1365 if let Some((msg, _)) = self.status_flash.as_ref() {
1366 s.push_str(" ");
1367 s.push_str(msg);
1368 } else if self.is_idle() {
1369 s.push_str(" (F idle)");
1370 } else {
1371 s.push_str(" (F)");
1372 }
1373 }
1374 if let Some(msg) = self.preprocess_failure.as_ref() {
1375 let first_line = msg.lines().next().unwrap_or("");
1376 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
1377 }
1378 let tag_suffix = match &self.tag_active {
1379 Some((name, cur, total)) if *total > 1 => {
1380 format!(" [tag: {name} ({cur}/{total})]")
1381 }
1382 _ => String::new(),
1383 };
1384 s.push_str(&tag_suffix);
1385 let used = s.chars().count();
1388 let hint = ":help";
1389 if (self.cols as usize) > used + 1 + hint.chars().count() {
1390 let pad = self.cols as usize - used - hint.chars().count();
1391 s.push_str(&" ".repeat(pad));
1392 s.push_str(hint);
1393 } else {
1394 s.push(' ');
1395 s.push_str(hint);
1396 }
1397 s
1398 }
1399
1400 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1401 use crate::prompt::PromptContext;
1402
1403 let body_rows = self.body_rows() as usize;
1404 let total = idx.line_count();
1405 let top = self.top_line + 1;
1406 let bottom = (self.top_line + body_rows).min(total.max(1));
1407 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1408 let bottom_line = self.bottom_visible_line(idx);
1409
1410 let records_mode = idx.records_mode();
1411 let (rec_top, rec_bottom, rec_total) = if records_mode {
1412 let rt = idx.line_to_record(self.top_line) + 1;
1413 let rb_raw = idx.line_to_record(bottom_line) + 1;
1414 let rb = if rb_raw < rt { rt } else { rb_raw };
1415 (rt, rb, idx.record_count())
1416 } else {
1417 (0, 0, 0)
1418 };
1419
1420 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1421 let line_rows = if total > 0 {
1422 let bytes = self.line_display_bytes(src, idx, self.top_line);
1423 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1424 } else { 1 };
1425 format!("+{}/{}", self.top_row, line_rows)
1426 } else {
1427 String::new()
1428 };
1429
1430 let col_offset = if self.left_col > 0 { format!(" \u{00bb}{}", self.left_col) } else { String::new() };
1431
1432 let format_tag = self.format_label.as_ref()
1433 .map(|n| format!(" [{}]", n))
1434 .unwrap_or_default();
1435 let filter_tag = self.filter.as_ref()
1436 .map(|f| format!(" [{}]", f.format_name))
1437 .unwrap_or_default();
1438 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
1439 let or_tag = if self.or_groups.is_active() { " [or]".to_string() } else { String::new() };
1440 let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1441 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
1442 } else {
1443 String::new()
1444 };
1445 let search_tag = self.search.as_ref()
1446 .map(|s| {
1447 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1448 format!(" [{}{}]", p, s.raw)
1449 })
1450 .unwrap_or_default();
1451 let pretty_tag = self.prettify_label.as_ref()
1452 .map(|l| format!(" [pretty:{l}]"))
1453 .unwrap_or_default();
1454 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
1455 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
1456 let preprocess_failed_tag = self.preprocess_failure.as_ref()
1457 .map(|msg| {
1458 let first_line = msg.lines().next().unwrap_or("");
1459 format!(" [preprocess-failed: {}]", first_line)
1460 })
1461 .unwrap_or_default();
1462
1463 let file_index_tag = match self.file_index {
1464 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
1465 None => String::new(),
1466 };
1467
1468 let tag_tag = match &self.tag_active {
1469 Some((name, cur, total)) if *total > 1 => {
1470 format!(" [tag: {name} ({cur}/{total})]")
1471 }
1472 _ => String::new(),
1473 };
1474
1475 PromptContext {
1476 label: self.source_label.clone(),
1477 top,
1478 bottom,
1479 total,
1480 pct: pct.min(100) as u8,
1481 rec_top,
1482 rec_bottom,
1483 rec_total,
1484 records_mode,
1485 wrap_offset,
1486 col_offset,
1487 format_tag,
1488 filter_tag,
1489 grep_tag,
1490 or_tag,
1491 hide_tag,
1492 search_tag,
1493 pretty_tag,
1494 live_tag,
1495 follow_tag,
1496 preprocess_failed_tag,
1497 file_index_tag,
1498 tag_tag,
1499 }
1500 }
1501
1502 fn frame_hex(&self, src: &dyn Source) -> Frame {
1503 use crate::hex::format_hex_row;
1504 use crate::render::{render_line, Cell, RenderOpts};
1505
1506 let body_rows = self.rows.saturating_sub(1) as usize;
1507 let total_bytes = src.len();
1508 let total_hex_rows = total_bytes.div_ceil(16);
1509
1510 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1511 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1512 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1513
1514 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 };
1515
1516 for row_idx in 0..body_rows {
1517 let hex_row = self.top_line + row_idx;
1518 if hex_row >= total_hex_rows {
1519 body.push(vec![Cell::Empty; self.cols as usize]);
1520 } else {
1521 let offset = hex_row * 16;
1522 let end = (offset + 16).min(total_bytes);
1523 let bytes_cow = src.bytes(offset..end);
1524 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1525 let rows = render_line(text.as_bytes(), &opts, None);
1526 body.push(rows.into_iter().next().unwrap_or_else(|| {
1527 vec![Cell::Empty; self.cols as usize]
1528 }));
1529 }
1530 row_styles.push(RowStyle::Normal);
1531 highlights.push(Vec::new());
1532 }
1533
1534 let status = self.format_status_hex(src);
1535 let raw_rows = vec![None; body.len()];
1536 Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1537 }
1538
1539 fn format_status_hex(&self, src: &dyn Source) -> String {
1540 let total_bytes = src.len();
1541 let body_rows = self.rows.saturating_sub(1) as usize;
1542 let top_byte = self.top_line * 16;
1544 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1547 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1548 let label_with_index = match self.file_index {
1549 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1550 None => self.source_label.clone(),
1551 };
1552 let tag_suffix = match &self.tag_active {
1553 Some((name, cur, total)) if *total > 1 => {
1554 format!(" [tag: {name} ({cur}/{total})]")
1555 }
1556 _ => String::new(),
1557 };
1558 format!(
1559 "{} off {}-{}/{} {}% [hex]{}",
1560 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1561 )
1562 }
1563
1564 #[cfg(feature = "image")]
1565 fn frame_image(&mut self) -> Frame {
1566 use crate::render::Cell;
1567 let body_rows = self.body_rows() as usize;
1568 let cols = self.cols as usize;
1569 let img = match &self.image {
1570 Some(i) => i,
1571 None => {
1572 let body = vec![vec![Cell::Empty; cols]; body_rows];
1573 return Frame {
1574 body,
1575 row_styles: vec![RowStyle::Normal; body_rows],
1576 highlights: vec![Vec::new(); body_rows],
1577 status: self.image_format.clone(),
1578 status_style: self.status_style,
1579 raw_rows: vec![None; body_rows],
1580 };
1581 }
1582 };
1583 let color = !self.image_no_color;
1584 let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1585 let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1586 let max_off = grid_w.saturating_sub(cols);
1587 if self.left_col > max_off { self.left_col = max_off; }
1588 let off = self.left_col;
1589 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1590 for r in 0..body_rows {
1591 let gi = self.top_line + r;
1592 if gi < grid.len() {
1593 let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1594 while row.len() < cols { row.push(Cell::Empty); }
1595 body.push(row);
1596 } else {
1597 body.push(vec![Cell::Empty; cols]);
1598 }
1599 }
1600 let status = self.format_status_image(grid.len());
1601 Frame {
1602 body,
1603 row_styles: vec![RowStyle::Normal; body_rows],
1604 highlights: vec![Vec::new(); body_rows],
1605 status,
1606 status_style: self.status_style,
1607 raw_rows: vec![None; body_rows],
1608 }
1609 }
1610
1611 #[cfg(feature = "image")]
1612 fn format_status_image(&self, total_rows: usize) -> String {
1613 let body = self.body_rows() as usize;
1614 let top = self.top_line + 1;
1615 let bottom = (self.top_line + body).min(total_rows.max(1));
1616 let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1617 let mut s = format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1618 if self.left_col > 0 {
1619 s.push_str(&format!(" \u{00bb}{}", self.left_col));
1620 }
1621 s
1622 }
1623
1624 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1629 if delta == 0 { return; }
1630 #[cfg(feature = "image")]
1631 if self.image_mode {
1632 self.scroll_lines(delta, src, idx);
1633 return;
1634 }
1635 if self.hide_mode() {
1636 self.extend_visible_lines(idx, src);
1640 let n = self.visible_lines.len();
1641 if n == 0 {
1642 self.top_line = 0;
1643 self.top_row = 0;
1644 return;
1645 }
1646 let vi = self
1647 .visible_lines
1648 .iter()
1649 .position(|&l| l >= self.top_line)
1650 .unwrap_or(n - 1);
1651 if delta > 0 {
1652 let target = (vi + delta as usize).min(n - 1);
1653 self.top_line = self.visible_lines[target];
1654 self.top_row = 0;
1655 } else {
1656 let back = (-delta) as usize;
1657 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1658 let extra_back = back.saturating_sub(consumed_for_snap);
1659 self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1660 self.top_row = 0;
1661 }
1662 return;
1663 }
1664 if delta > 0 {
1665 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1666 let total = idx.line_count();
1667 if total == 0 { return; }
1668 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1669 self.top_line = target;
1670 self.top_row = 0;
1671 } else {
1672 let back = (-delta) as usize;
1673 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1678 let extra_back = back.saturating_sub(consumed_for_snap);
1679 self.top_line = self.top_line.saturating_sub(extra_back);
1680 self.top_row = 0;
1681 }
1682 }
1683
1684 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1685 if delta == 0 { return; }
1686 #[cfg(feature = "image")]
1687 if self.image_mode {
1688 let total = self.image_total_rows();
1689 let body = self.body_rows() as usize;
1690 let max_top = total.saturating_sub(body);
1691 let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
1692 self.top_line = next as usize;
1693 self.top_row = 0;
1694 return;
1695 }
1696 if self.hide_mode() {
1697 self.extend_visible_lines(idx, src);
1701 let n = self.visible_lines.len();
1702 if n == 0 {
1703 self.top_line = 0;
1704 self.top_row = 0;
1705 return;
1706 }
1707 let mut vi = self
1708 .visible_lines
1709 .iter()
1710 .position(|&l| l >= self.top_line)
1711 .unwrap_or(n - 1);
1712 if self.visible_lines[vi] != self.top_line {
1715 self.top_row = 0;
1716 }
1717 self.top_line = self.visible_lines[vi];
1718 let r_opts = self.render_opts(self.gutter_width(idx));
1719 if delta > 0 {
1720 let mut remaining = delta as usize;
1721 while remaining > 0 {
1722 let line = self.visible_lines[vi];
1723 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1724 if self.top_row + 1 < rows {
1725 self.top_row += 1;
1726 } else if vi + 1 < n {
1727 self.top_row = 0;
1728 vi += 1;
1729 self.top_line = self.visible_lines[vi];
1730 } else {
1731 break;
1732 }
1733 remaining -= 1;
1734 }
1735 let anchor = self.hide_bottom_anchor(src, idx);
1736 if (self.top_line, self.top_row) > anchor {
1737 self.top_line = anchor.0;
1738 self.top_row = anchor.1;
1739 }
1740 } else {
1741 let mut remaining = (-delta) as usize;
1742 while remaining > 0 {
1743 if self.top_row > 0 {
1744 self.top_row -= 1;
1745 } else if vi > 0 {
1746 vi -= 1;
1747 self.top_line = self.visible_lines[vi];
1748 let line = self.visible_lines[vi];
1749 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1750 self.top_row = rows.saturating_sub(1);
1751 } else {
1752 break;
1753 }
1754 remaining -= 1;
1755 }
1756 }
1757 return;
1758 }
1759 if delta > 0 {
1760 let mut remaining = delta as usize;
1761 while remaining > 0 {
1762 idx.extend_to_line(self.top_line + 1, src);
1763 let total = idx.line_count();
1764 if total == 0 { break; }
1765 let bytes = self.line_display_bytes(src, idx, self.top_line);
1766 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1767 if self.top_row + 1 < line_rows {
1768 self.top_row += 1;
1769 } else if self.top_line + 1 < total {
1770 self.top_row = 0;
1771 self.top_line += 1;
1772 } else {
1773 break;
1774 }
1775 remaining -= 1;
1776 }
1777 if idx.scanned_through() >= src.len() {
1782 let anchor = self.bottom_anchor(src, idx);
1783 if (self.top_line, self.top_row) > anchor {
1784 self.top_line = anchor.0;
1785 self.top_row = anchor.1;
1786 }
1787 }
1788 } else {
1789 let mut remaining = (-delta) as usize;
1790 while remaining > 0 {
1791 if self.top_row > 0 {
1792 self.top_row -= 1;
1793 } else if self.top_line > 0 {
1794 self.top_line -= 1;
1795 let bytes = self.line_display_bytes(src, idx, self.top_line);
1796 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1797 self.top_row = line_rows.saturating_sub(1);
1798 } else {
1799 break;
1800 }
1801 remaining -= 1;
1802 }
1803 }
1804 }
1805
1806 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1807 let n = self.page_size
1808 .map(|p| p as i64)
1809 .unwrap_or_else(|| self.body_rows() as i64);
1810 self.scroll_lines(n, src, idx);
1811 }
1812
1813 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1814 let n = self.page_size
1815 .map(|p| p as i64)
1816 .unwrap_or_else(|| self.body_rows() as i64);
1817 self.scroll_lines(-n, src, idx);
1818 }
1819
1820 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1821 let n = (self.body_rows() / 2).max(1) as i64;
1822 self.scroll_lines(n, src, idx);
1823 }
1824
1825 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1826 let n = (self.body_rows() / 2).max(1) as i64;
1827 self.scroll_lines(-n, src, idx);
1828 }
1829
1830 pub fn goto_top(&mut self) {
1831 self.top_line = 0;
1832 self.top_row = 0;
1833 }
1834
1835 fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1842 let body = self.body_rows() as usize;
1843 let total = idx.line_count();
1844 if total == 0 || body == 0 {
1845 return (0, 0);
1846 }
1847 let r_opts = self.render_opts(self.gutter_width(idx));
1848 let mut remaining = body;
1849 let mut line = total - 1;
1850 loop {
1851 let bytes = self.line_display_bytes(src, idx, line);
1852 let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1853 if line_rows >= remaining {
1854 return (line, line_rows - remaining);
1855 }
1856 remaining -= line_rows;
1857 if line == 0 {
1858 return (0, 0);
1859 }
1860 line -= 1;
1861 }
1862 }
1863
1864 fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1869 let body = self.body_rows() as usize;
1870 let n = self.visible_lines.len();
1871 if n == 0 || body == 0 {
1872 return (0, 0);
1873 }
1874 let r_opts = self.render_opts(self.gutter_width(idx));
1875 let mut remaining = body;
1876 let mut vi = n - 1;
1877 loop {
1878 let line = self.visible_lines[vi];
1879 let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1880 if rows >= remaining {
1881 return (line, rows - remaining);
1882 }
1883 remaining -= rows;
1884 if vi == 0 {
1885 return (self.visible_lines[0], 0);
1886 }
1887 vi -= 1;
1888 }
1889 }
1890
1891 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1892 #[cfg(feature = "image")]
1893 if self.image_mode {
1894 let body = self.body_rows() as usize;
1895 self.top_line = self.image_total_rows().saturating_sub(body);
1896 self.top_row = 0;
1897 return;
1898 }
1899 idx.extend_to_end(src);
1900 if self.hide_mode() {
1901 self.extend_visible_lines(idx, src);
1902 let (line, row) = self.hide_bottom_anchor(src, idx);
1903 self.top_line = line;
1904 self.top_row = row;
1905 } else {
1906 let (line, row) = self.bottom_anchor(src, idx);
1907 self.top_line = line;
1908 self.top_row = row;
1909 }
1910 }
1911
1912 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1914 idx.extend_to_line(n, src);
1915 let target = n.min(idx.line_count().saturating_sub(1));
1916 self.top_line = target;
1917 self.top_row = 0;
1918 }
1919
1920 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1922 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1926 idx.extend_to_end(src);
1927 }
1928 if idx.record_count() == 0 {
1929 return;
1930 }
1931 let target = n.min(idx.record_count().saturating_sub(1));
1932 let line_range = idx.record_line_range(target);
1933 self.top_line = line_range.start;
1934 self.top_row = 0;
1935 }
1936
1937 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1940 let p = p.min(100) as usize;
1941 let target_byte = src.len().saturating_mul(p) / 100;
1942 idx.extend_to_byte_for_query(src, target_byte);
1943 let line_n = idx.line_at_byte(target_byte)
1944 .or_else(|| {
1945 let lc = idx.line_count();
1947 if lc > 0 { Some(lc - 1) } else { None }
1948 })
1949 .unwrap_or(0);
1950 self.top_line = line_n;
1951 self.top_row = 0;
1952 }
1953
1954 pub fn top_line(&self) -> usize {
1956 self.top_line
1957 }
1958
1959 pub fn resize(&mut self, cols: u16, rows: u16) {
1960 self.cols = cols.max(1);
1961 self.rows = rows.max(2);
1962 self.opts.cols = self.cols;
1963 }
1964
1965 pub fn toggle_line_numbers(&mut self) {
1966 self.show_line_numbers = !self.show_line_numbers;
1967 }
1968
1969 pub fn toggle_chop(&mut self) {
1970 self.opts.wrap = !self.opts.wrap;
1971 if self.opts.wrap {
1972 self.left_col = 0;
1973 }
1974 }
1975
1976 const HSCROLL_STEP: usize = 8;
1977
1978 pub fn hscroll_active(&self) -> bool {
1982 #[cfg(feature = "image")]
1983 if self.image.is_some() {
1984 return true;
1985 }
1986 !self.opts.wrap
1987 && !self.hex_mode
1988 && self.ansi_mode != crate::render::AnsiMode::Raw
1989 }
1990
1991 fn hscroll_by(&mut self, delta: isize) {
1992 if !self.hscroll_active() {
1993 return;
1994 }
1995 self.left_col = (self.left_col as isize + delta).max(0) as usize;
1996 }
1998
1999 pub fn hscroll_left_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2000 pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2001 pub fn hscroll_left_step(&mut self) { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2002 pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2003
2004 pub fn left_col(&self) -> usize { self.left_col }
2005
2006 pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2009
2010 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::*;
2019 use crate::source::MockSource;
2020
2021 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2022 let m = MockSource::new();
2023 m.append(content);
2024 m.finish();
2025 let idx = LineIndex::new();
2026 (m, idx)
2027 }
2028
2029 #[test]
2030 fn frame_renders_body_height_rows() {
2031 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2032 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
2034 assert_eq!(frame.body.len(), 4);
2035 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2036 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2037 }
2038
2039 #[test]
2040 fn scroll_down_advances_top_line() {
2041 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2044 let mut v = Viewport::new(10, 5, "test".into());
2045 v.scroll_lines(2, &m, &mut idx);
2046 assert_eq!(v.top_line, 2);
2047 assert_eq!(v.top_row, 0);
2048 }
2049
2050 #[test]
2051 fn scroll_up_clamps_at_zero() {
2052 let (m, mut idx) = setup(b"a\nb\nc\n");
2053 let mut v = Viewport::new(10, 5, "test".into());
2054 v.scroll_lines(-5, &m, &mut idx);
2055 assert_eq!(v.top_line, 0);
2056 assert_eq!(v.top_row, 0);
2057 }
2058
2059 #[test]
2060 fn scroll_down_clamps_at_last_line() {
2061 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2066 let mut v = Viewport::new(10, 5, "test".into());
2067 v.scroll_lines(50, &m, &mut idx);
2068 assert_eq!((v.top_line, v.top_row), (4, 0));
2069 assert!(v.is_at_bottom(&m, &idx));
2070 }
2071
2072 #[test]
2073 fn scroll_logical_lines_skips_wrap_rows() {
2074 let mut content = vec![b'X'; 500];
2076 content.push(b'\n');
2077 content.extend_from_slice(b"second\n");
2078 content.extend_from_slice(b"third\n");
2079 let (m, mut idx) = setup(&content);
2080 let mut v = Viewport::new(10, 8, "f".into());
2081 v.scroll_logical_lines(1, &m, &mut idx);
2082 assert_eq!((v.top_line, v.top_row), (1, 0));
2083 v.scroll_logical_lines(1, &m, &mut idx);
2084 assert_eq!((v.top_line, v.top_row), (2, 0));
2085 }
2086
2087 #[test]
2088 fn scroll_logical_lines_back_snaps_to_line_start() {
2089 let mut content = vec![b'A'; 50];
2094 content.push(b'\n');
2095 content.extend_from_slice(&[b'B'; 50]);
2096 content.push(b'\n');
2097 content.extend_from_slice(&[b'C'; 50]);
2098 content.push(b'\n');
2099 let (m, mut idx) = setup(&content);
2100 let mut v = Viewport::new(10, 8, "f".into());
2101 v.scroll_lines(7, &m, &mut idx);
2102 assert_eq!(v.top_line, 1, "should be on line 1");
2103 assert!(v.top_row > 0, "should be inside line 1's wraps");
2104 v.scroll_logical_lines(-1, &m, &mut idx);
2105 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2106 v.scroll_logical_lines(-1, &m, &mut idx);
2107 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2108 }
2109
2110 #[test]
2111 fn scroll_down_walks_wraps_of_last_line() {
2112 let mut content = b"first\n".to_vec();
2116 content.extend_from_slice(&[b'X'; 60]);
2117 content.push(b'\n');
2118 let (m, mut idx) = setup(&content);
2119 let mut v = Viewport::new(10, 5, "f".into());
2120 v.scroll_lines(1, &m, &mut idx);
2121 assert_eq!((v.top_line, v.top_row), (1, 0));
2122 v.scroll_lines(1, &m, &mut idx);
2123 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2124 v.scroll_lines(1, &m, &mut idx);
2125 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2126 v.scroll_lines(5, &m, &mut idx);
2128 assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2129 }
2130
2131 #[test]
2132 fn scroll_down_walks_wrap_rows_within_long_line() {
2133 let mut content = vec![b'X'; 30];
2137 content.push(b'\n');
2138 content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2139 let (m, mut idx) = setup(&content);
2140 let mut v = Viewport::new(10, 5, "f".into());
2141 v.scroll_lines(1, &m, &mut idx);
2142 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2143 v.scroll_lines(1, &m, &mut idx);
2144 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2145 v.scroll_lines(1, &m, &mut idx);
2146 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2147 }
2148
2149 #[test]
2150 fn status_line_shows_range_and_pct() {
2151 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2152 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
2154 assert!(frame.status.starts_with("f 1-4/10"));
2155 }
2156
2157 #[test]
2158 fn page_down_advances_by_body_rows() {
2159 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2160 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
2162 assert_eq!(v.top_line, 4);
2163 }
2164
2165 #[test]
2166 fn page_up_then_page_down_returns_to_start_when_no_resize() {
2167 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2168 let mut v = Viewport::new(10, 5, "f".into());
2169 v.page_down(&m, &mut idx);
2170 v.page_up(&m, &mut idx);
2171 assert_eq!(v.top_line, 0);
2172 assert_eq!(v.top_row, 0);
2173 }
2174
2175 #[test]
2176 fn half_page_down_advances_by_half_body() {
2177 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2180 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
2182 assert_eq!(v.top_line, 3);
2183 }
2184
2185 #[test]
2186 fn goto_top_resets_position() {
2187 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2188 let mut v = Viewport::new(10, 5, "f".into());
2189 v.scroll_lines(2, &m, &mut idx);
2190 v.goto_top();
2191 assert_eq!(v.top_line, 0);
2192 assert_eq!(v.top_row, 0);
2193 }
2194
2195 #[test]
2196 fn goto_bottom_scrolls_to_last_page() {
2197 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2198 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
2200 assert_eq!(v.top_line, 6);
2202 }
2203
2204 #[cfg(feature = "image")]
2205 #[test]
2206 fn image_mode_frame_renders_and_scrolls() {
2207 use image::{Rgba, RgbaImage};
2208 let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2209 let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2211 assert!(v.image_mode());
2212 let total = v.image_total_rows();
2213 assert!(total > 5, "tall image should exceed the body");
2214 assert!(!v.is_at_bottom_image(), "starts at top");
2215 let mut idx = LineIndex::new();
2216 let m = MockSource::new();
2217 let frame = v.frame(&m, &mut idx);
2218 assert_eq!(frame.body.len(), 5);
2219 v.goto_bottom(&m, &mut idx);
2220 assert!(v.is_at_bottom_image());
2221 }
2222
2223 #[cfg(feature = "image")]
2224 #[test]
2225 fn frame_image_slices_at_left_col() {
2226 use crate::render::Cell;
2227 use image::{Rgba, RgbaImage};
2228
2229 let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2235 let mut v = Viewport::new(10, 4, "wide.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2237 assert!(v.hscroll_active(), "image mode should make hscroll active");
2238
2239 let mut idx = LineIndex::new();
2240 let m = MockSource::new();
2241
2242 assert_eq!(v.left_col(), 0);
2244 let frame0 = v.frame(&m, &mut idx);
2245 assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2246 assert_eq!(frame0.body[0].len(), 10);
2248 assert!(
2250 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2251 "no scroll marker expected on image frame at left_col=0"
2252 );
2253 let cell_at_col0 = frame0.body[0][0].clone();
2255 let cell_at_col8 = frame0.body[0][8].clone();
2256
2257 v.hscroll_right_step();
2259 assert_eq!(v.left_col(), 8);
2260 let frame1 = v.frame(&m, &mut idx);
2261 assert_eq!(frame1.body[0].len(), 10);
2262 assert!(
2264 !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2265 "no scroll marker expected on image frame after hscroll_right_step"
2266 );
2267 assert_eq!(
2269 frame1.body[0][0], cell_at_col8,
2270 "after hscroll_right_step the first visible cell should be grid col 8"
2271 );
2272 assert_ne!(
2276 frame1.body[0][0], cell_at_col0,
2277 "the scrolled first cell must differ from the unscrolled one"
2278 );
2279 }
2280
2281 #[test]
2282 fn goto_line_positions_top_line() {
2283 let m = MockSource::new();
2284 m.append(b"a\nb\nc\nd\ne\n");
2285 let mut idx = LineIndex::new();
2286 idx.extend_to_end(&m);
2287 let mut v = Viewport::new(20, 5, "f".into());
2288 v.goto_line(3, &m, &mut idx);
2289 assert_eq!(v.top_line(), 3);
2290 }
2291
2292 #[test]
2293 fn goto_line_clamps_to_last_line() {
2294 let m = MockSource::new();
2295 m.append(b"a\nb\n");
2296 let mut idx = LineIndex::new();
2297 idx.extend_to_end(&m);
2298 let mut v = Viewport::new(20, 5, "f".into());
2299 v.goto_line(999, &m, &mut idx);
2300 assert_eq!(v.top_line(), 1);
2301 }
2302
2303 #[test]
2304 fn goto_record_positions_at_record_start_line() {
2305 let m = MockSource::new();
2306 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
2307 let mut idx = LineIndex::new();
2308 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2309 idx.extend_to_end(&m);
2310 let mut v = Viewport::new(20, 5, "f".into());
2311 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
2313 }
2314
2315 #[test]
2316 fn goto_record_in_line_per_record_mode_equals_goto_line() {
2317 let m = MockSource::new();
2318 m.append(b"a\nb\nc\n");
2319 let mut idx = LineIndex::new();
2320 idx.extend_to_end(&m);
2321 let mut v = Viewport::new(20, 5, "f".into());
2322 v.goto_record(2, &m, &mut idx);
2323 assert_eq!(v.top_line(), 2);
2324 }
2325
2326 #[test]
2327 fn goto_percent_50_lands_in_middle() {
2328 let m = MockSource::new();
2329 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
2331 idx.extend_to_end(&m);
2332 let mut v = Viewport::new(20, 5, "f".into());
2333 v.goto_percent(50, &m, &mut idx);
2334 assert_eq!(v.top_line(), 2); }
2336
2337 #[test]
2338 fn goto_percent_100_lands_at_last_line() {
2339 let m = MockSource::new();
2340 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
2342 idx.extend_to_end(&m);
2343 let mut v = Viewport::new(20, 5, "f".into());
2344 v.goto_percent(100, &m, &mut idx);
2345 assert_eq!(v.top_line(), 2);
2346 }
2347
2348 #[test]
2349 fn goto_percent_0_lands_at_first_line() {
2350 let m = MockSource::new();
2351 m.append(b"a\nb\nc\n");
2352 let mut idx = LineIndex::new();
2353 idx.extend_to_end(&m);
2354 let mut v = Viewport::new(20, 5, "f".into());
2355 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
2357 v.goto_percent(0, &m, &mut idx);
2358 assert_eq!(v.top_line(), 0);
2359 }
2360
2361 #[test]
2362 fn resize_updates_dimensions_and_render_opts() {
2363 let (m, mut idx) = setup(b"1\n2\n");
2364 let mut v = Viewport::new(10, 5, "f".into());
2365 v.resize(40, 12);
2366 assert_eq!(v.cols, 40);
2367 assert_eq!(v.rows, 12);
2368 assert_eq!(v.opts.cols, 40);
2369 let _ = v.frame(&m, &mut idx);
2370 }
2371
2372 #[test]
2373 fn toggle_line_numbers_changes_gutter() {
2374 let (m, mut idx) = setup(b"a\nb\nc\n");
2375 let mut v = Viewport::new(10, 5, "f".into());
2376 let frame_off = v.frame(&m, &mut idx);
2377 v.toggle_line_numbers();
2378 let frame_on = v.frame(&m, &mut idx);
2379 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2381 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2382 }
2383
2384 #[test]
2385 fn toggle_chop_changes_wrap_mode() {
2386 let (m, mut idx) = setup(b"abcdefghij\n");
2387 let mut v = Viewport::new(4, 5, "f".into());
2388 v.toggle_chop();
2389 let frame = v.frame(&m, &mut idx);
2390 assert_eq!(frame.body[0][..4],
2393 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2394 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2395 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2396 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2397 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2399 }
2400
2401 #[test]
2404 fn is_at_bottom_initially_only_when_source_fits() {
2405 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
2408 assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2409 }
2410
2411 #[test]
2412 fn is_at_bottom_false_when_top_and_more_lines_below() {
2413 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);
2416 assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2417 }
2418
2419 #[test]
2420 fn is_at_bottom_true_after_goto_bottom() {
2421 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2422 let mut v = Viewport::new(10, 5, "f".into());
2423 v.goto_bottom(&m, &mut idx);
2424 assert!(v.is_at_bottom(&m, &idx));
2425 }
2426
2427 #[test]
2428 fn status_shows_follow_suffix_when_follow_mode_on() {
2429 let (m, mut idx) = setup(b"a\nb\n");
2430 let mut v = Viewport::new(20, 5, "f".into());
2431 let frame_off = v.frame(&m, &mut idx);
2432 assert!(!frame_off.status.contains("(F)"));
2433 v.set_follow_mode(true);
2434 let frame_on = v.frame(&m, &mut idx);
2435 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2436 }
2437
2438 #[test]
2439 fn toggle_follow_flips_state() {
2440 let mut v = Viewport::new(10, 5, "f".into());
2441 assert!(!v.follow_mode());
2442 v.toggle_follow();
2443 assert!(v.follow_mode());
2444 v.toggle_follow();
2445 assert!(!v.follow_mode());
2446 }
2447
2448 #[test]
2449 fn idle_indicator_kicks_in_at_threshold() {
2450 let (m, mut idx) = setup(b"a\nb\n");
2451 let mut v = Viewport::new(20, 5, "f".into());
2452 v.set_follow_mode(true);
2453 for _ in 0..19 { v.tick_idle(); }
2455 let f1 = v.frame(&m, &mut idx);
2456 assert!(f1.status.contains("(F)"));
2457 assert!(!f1.status.contains("idle"));
2458 v.tick_idle();
2460 let f2 = v.frame(&m, &mut idx);
2461 assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2462 }
2463
2464 #[test]
2465 fn note_growth_resets_idle() {
2466 let (m, mut idx) = setup(b"a\nb\n");
2467 let mut v = Viewport::new(20, 5, "f".into());
2468 v.set_follow_mode(true);
2469 for _ in 0..25 { v.tick_idle(); }
2470 assert!(v.is_idle());
2471 v.note_growth();
2472 assert!(!v.is_idle());
2473 let f = v.frame(&m, &mut idx);
2474 assert!(!f.status.contains("idle"));
2475 }
2476
2477 #[test]
2478 fn qae_off_never_quits_even_at_bottom() {
2479 let (m, mut idx) = setup(b"a\n");
2480 let mut v = Viewport::new(20, 5, "f".into());
2481 v.set_quit_at_eof(QuitAtEof::Off);
2482 v.goto_bottom(&m, &mut idx);
2483 assert!(!v.note_motion_for_eof(true, &m, &idx));
2484 }
2485
2486 #[test]
2487 fn qae_first_quits_immediately_at_bottom() {
2488 let (m, mut idx) = setup(b"a\n");
2489 let mut v = Viewport::new(20, 5, "f".into());
2490 v.set_quit_at_eof(QuitAtEof::First);
2491 v.goto_bottom(&m, &mut idx);
2492 assert!(v.note_motion_for_eof(true, &m, &idx));
2493 }
2494
2495 #[test]
2496 fn qae_first_only_quits_at_eof_not_mid_file() {
2497 let mut content = Vec::new();
2498 for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2499 let (m, mut idx) = setup(&content);
2500 idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
2502 v.set_quit_at_eof(QuitAtEof::First);
2503 assert!(!v.is_at_bottom(&m, &idx));
2505 assert!(!v.note_motion_for_eof(true, &m, &idx));
2506 }
2507
2508 #[test]
2509 fn qae_second_quits_on_second_hit() {
2510 let (m, mut idx) = setup(b"a\n");
2511 let mut v = Viewport::new(20, 5, "f".into());
2512 v.set_quit_at_eof(QuitAtEof::Second);
2513 v.goto_bottom(&m, &mut idx);
2514 assert!(!v.note_motion_for_eof(true, &m, &idx));
2516 assert!(v.note_motion_for_eof(true, &m, &idx));
2518 }
2519
2520 #[test]
2521 fn squeeze_collapses_consecutive_blanks() {
2522 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2524 let mut v = Viewport::new(10, 8, "f".into());
2525 v.set_squeeze_blanks(true);
2526 let f = v.frame(&m, &mut idx);
2527 let stringify = |row: &Vec<Cell>| -> String {
2529 row.iter().filter_map(|c| match c {
2530 Cell::Char { ch, .. } => Some(*ch),
2531 _ => None,
2532 }).collect::<String>().trim().to_string()
2533 };
2534 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2535 assert_eq!(&rows[0], "a");
2537 assert_eq!(&rows[1], "");
2538 assert_eq!(&rows[2], "b");
2539 }
2540
2541 #[test]
2542 fn header_pins_top_rows_when_scrolling() {
2543 let mut content = Vec::new();
2545 for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2546 let (m, mut idx) = setup(&content);
2547 let mut v = Viewport::new(20, 6, "f".into());
2548 v.set_header(2, 0);
2549 v.scroll_lines(5, &m, &mut idx);
2553 let f = v.frame(&m, &mut idx);
2554 let chs = |row: &Vec<Cell>| -> String {
2555 row.iter().filter_map(|c| match c {
2556 Cell::Char { ch, .. } => Some(*ch),
2557 _ => None,
2558 }).collect::<String>().trim().to_string()
2559 };
2560 assert_eq!(&chs(&f.body[0]), "line0");
2562 assert_eq!(&chs(&f.body[1]), "line1");
2563 assert_eq!(&chs(&f.body[2]), "line7");
2565 }
2566
2567 #[test]
2568 fn page_size_when_set_overrides_body_rows() {
2569 let mut content = Vec::new();
2570 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2571 let (m, mut idx) = setup(&content);
2572 let mut v = Viewport::new(20, 10, "f".into());
2573 v.set_page_size(Some(3));
2574 let before = v.top_line();
2575 v.page_down(&m, &mut idx);
2576 assert_eq!(v.top_line(), before + 3);
2577 v.page_up(&m, &mut idx);
2578 assert_eq!(v.top_line(), before);
2579 }
2580
2581 #[test]
2582 fn page_size_unset_uses_body_rows() {
2583 let mut content = Vec::new();
2584 for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2585 let (m, mut idx) = setup(&content);
2586 let mut v = Viewport::new(20, 10, "f".into());
2587 v.page_down(&m, &mut idx);
2589 assert_eq!(v.top_line(), 9);
2590 }
2591
2592 #[test]
2593 fn header_zero_lines_renders_like_no_header() {
2594 let mut content = Vec::new();
2595 for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2596 let (m, mut idx) = setup(&content);
2597 let mut v = Viewport::new(20, 6, "f".into());
2598 v.set_header(0, 0);
2599 let f = v.frame(&m, &mut idx);
2600 let chs = |row: &Vec<Cell>| -> String {
2601 row.iter().filter_map(|c| match c {
2602 Cell::Char { ch, .. } => Some(*ch),
2603 _ => None,
2604 }).collect::<String>().trim().to_string()
2605 };
2606 assert_eq!(&chs(&f.body[0]), "line0");
2607 assert_eq!(&chs(&f.body[1]), "line1");
2608 }
2609
2610 #[test]
2611 fn squeeze_off_preserves_blanks() {
2612 let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2613 let mut v = Viewport::new(10, 8, "f".into());
2614 let f = v.frame(&m, &mut idx);
2616 let stringify = |row: &Vec<Cell>| -> String {
2617 row.iter().filter_map(|c| match c {
2618 Cell::Char { ch, .. } => Some(*ch),
2619 _ => None,
2620 }).collect::<String>().trim().to_string()
2621 };
2622 let rows: Vec<String> = f.body.iter().map(stringify).collect();
2623 assert_eq!(&rows[0], "a");
2625 assert_eq!(&rows[1], "");
2626 assert_eq!(&rows[2], "");
2627 assert_eq!(&rows[3], "");
2628 assert_eq!(&rows[4], "b");
2629 }
2630
2631 #[test]
2632 fn qae_second_resets_on_backward_motion() {
2633 let (m, mut idx) = setup(b"a\n");
2634 let mut v = Viewport::new(20, 5, "f".into());
2635 v.set_quit_at_eof(QuitAtEof::Second);
2636 v.goto_bottom(&m, &mut idx);
2637 assert!(!v.note_motion_for_eof(true, &m, &idx));
2638 v.note_motion_for_eof(false, &m, &idx);
2640 assert!(!v.note_motion_for_eof(true, &m, &idx));
2642 assert!(v.note_motion_for_eof(true, &m, &idx));
2644 }
2645
2646 #[test]
2647 fn flash_message_overrides_follow_suffix() {
2648 let (m, mut idx) = setup(b"a\nb\n");
2649 let mut v = Viewport::new(40, 5, "f".into());
2650 v.set_follow_mode(true);
2651 v.flash("(F reopened)", 3);
2652 let f = v.frame(&m, &mut idx);
2653 assert!(f.status.contains("(F reopened)"), "{}", f.status);
2654 assert!(!f.status.contains("(F idle)"));
2655 }
2656
2657 #[test]
2658 fn flash_countdown_clears() {
2659 let mut v = Viewport::new(10, 5, "f".into());
2660 v.flash("hello", 2);
2661 v.tick_flash();
2662 assert!(v.status_flash.is_some());
2663 v.tick_flash();
2664 assert!(v.status_flash.is_none());
2665 }
2666
2667 #[test]
2668 fn suspend_follow_if_off_is_noop() {
2669 let mut v = Viewport::new(10, 5, "f".into());
2670 v.set_follow_mode(true);
2671 v.suspend_follow_if(false);
2672 assert!(v.follow_mode());
2673 }
2674
2675 #[test]
2676 fn suspend_follow_if_on_flips_off() {
2677 let mut v = Viewport::new(10, 5, "f".into());
2678 v.set_follow_mode(true);
2679 v.suspend_follow_if(true);
2680 assert!(!v.follow_mode());
2681 }
2682
2683 #[test]
2684 fn case_mode_sensitive_returns_pattern_unchanged() {
2685 assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2686 assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2687 }
2688
2689 #[test]
2690 fn case_mode_insensitive_prepends_i_flag() {
2691 assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2692 assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2693 }
2694
2695 #[test]
2696 fn case_mode_smart_lowercase_is_insensitive() {
2697 assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2698 }
2699
2700 #[test]
2701 fn case_mode_smart_with_uppercase_is_sensitive() {
2702 assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2703 assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2704 }
2705
2706 #[test]
2707 fn set_case_mode_recompiles_active_search() {
2708 let (m, mut idx) = setup(b"hello WORLD\n");
2709 let mut v = Viewport::new(40, 5, "f".into());
2710 v.set_search("world".into(), SearchDirection::Forward).unwrap();
2711 assert!(!v.search_repeat(&m, &mut idx, false));
2713 v.set_case_mode(CaseMode::Insensitive);
2715 assert!(v.search_repeat(&m, &mut idx, false));
2716 }
2717
2718 #[test]
2719 fn status_shows_prettify_label_when_set() {
2720 let (m, mut idx) = setup(b"a\n");
2721 let mut v = Viewport::new(40, 5, "f".into());
2722 let frame_off = v.frame(&m, &mut idx);
2723 assert!(!frame_off.status.contains("[pretty"));
2724 v.set_prettify_label(Some("json".into()));
2725 let frame_on = v.frame(&m, &mut idx);
2726 assert!(frame_on.status.contains("[pretty:json]"),
2727 "expected [pretty:json] in status, got: {}", frame_on.status);
2728 v.set_prettify_label(Some("json:err".into()));
2729 let frame_err = v.frame(&m, &mut idx);
2730 assert!(frame_err.status.contains("[pretty:json:err]"),
2731 "expected [pretty:json:err] in status, got: {}", frame_err.status);
2732 }
2733
2734 #[test]
2735 fn status_shows_l_suffix_when_live_mode_on() {
2736 let (m, mut idx) = setup(b"a\nb\n");
2737 let mut v = Viewport::new(20, 5, "f".into());
2738 let frame_off = v.frame(&m, &mut idx);
2739 assert!(!frame_off.status.contains("(L)"));
2740 v.set_live_mode(true);
2741 let frame_on = v.frame(&m, &mut idx);
2742 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2743 }
2744
2745 #[test]
2746 fn clamp_top_line_pulls_back_when_total_shrinks() {
2747 let mut v = Viewport::new(20, 5, "f".into());
2748 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
2757 let (m, mut idx) = setup(b"only\n");
2759 let _ = v.frame(&m, &mut idx);
2760 }
2761
2762 fn simulate_growth_tick(
2765 v: &mut Viewport,
2766 src: &MockSource,
2767 idx: &mut LineIndex,
2768 ) {
2769 if !v.follow_mode() { return; }
2770 let was_at_bottom = v.is_at_bottom(src, idx);
2771 let lines_before = idx.line_count();
2772 idx.notice_new_bytes(src);
2773 if idx.line_count() != lines_before && was_at_bottom {
2774 v.goto_bottom(src, idx);
2775 }
2776 }
2777
2778 #[test]
2779 fn auto_scroll_engages_when_at_bottom() {
2780 let m = MockSource::new();
2781 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
2783 let mut v = Viewport::new(10, 5, "f".into());
2784 v.set_follow_mode(true);
2785 idx.extend_to_end(&m);
2786 assert!(v.is_at_bottom(&m, &idx));
2787 let top_before = {
2788 let f = v.frame(&m, &mut idx);
2789 f.status.clone() };
2791 let _ = top_before;
2792 m.append(b"5\n6\n7\n8\n");
2794 simulate_growth_tick(&mut v, &m, &mut idx);
2795 assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
2797 let frame = v.frame(&m, &mut idx);
2798 let last_row = &frame.body[frame.body.len() - 1];
2801 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2802 }
2803
2804 #[test]
2805 fn auto_scroll_suppressed_when_scrolled_up() {
2806 let m = MockSource::new();
2807 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
2809 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
2811 idx.extend_to_end(&m);
2812 v.goto_bottom(&m, &mut idx);
2813 v.scroll_lines(-2, &m, &mut idx);
2815 assert!(!v.is_at_bottom(&m, &idx));
2816 let frame_before = v.frame(&m, &mut idx);
2817 let top_first_cell_before = frame_before.body[0][0].clone();
2818 m.append(b"9\n10\n");
2820 simulate_growth_tick(&mut v, &m, &mut idx);
2821 let frame_after = v.frame(&m, &mut idx);
2823 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2824 }
2825
2826 #[test]
2829 fn set_search_compiles_regex() {
2830 let mut v = Viewport::new(10, 5, "f".into());
2831 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2832 assert!(v.search_active());
2833 }
2834
2835 #[test]
2836 fn set_search_rejects_bad_regex() {
2837 let mut v = Viewport::new(10, 5, "f".into());
2838 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2839 assert!(!err.is_empty());
2840 assert!(!v.search_active(), "no search should be set on error");
2841 }
2842
2843 #[test]
2844 fn search_step_forward_finds_match_after_top() {
2845 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2846 let mut v = Viewport::new(20, 5, "f".into());
2847 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2848 let found = v.search_repeat(&m, &mut idx, false);
2849 assert!(found);
2850 assert_eq!(v.top_line, 2);
2852 }
2853
2854 #[test]
2855 fn search_step_backward_finds_match_before_top() {
2856 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2857 let mut v = Viewport::new(20, 5, "f".into());
2858 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2860 let found = v.search_repeat(&m, &mut idx, false);
2861 assert!(found);
2862 assert_eq!(v.top_line, 0);
2863 }
2864
2865 #[test]
2866 fn search_wraps_at_end() {
2867 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2868 let mut v = Viewport::new(20, 5, "f".into());
2869 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2871 let found = v.search_repeat(&m, &mut idx, false);
2872 assert!(found, "search should wrap forward past EOF");
2873 assert_eq!(v.top_line, 0);
2874 }
2875
2876 #[test]
2877 fn search_no_match_returns_false_and_does_not_move() {
2878 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2879 let mut v = Viewport::new(20, 5, "f".into());
2880 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2881 let found = v.search_repeat(&m, &mut idx, false);
2882 assert!(!found);
2883 assert_eq!(v.top_line, 0);
2884 }
2885
2886 #[test]
2887 fn frame_records_highlight_ranges_for_matches() {
2888 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2889 let mut v = Viewport::new(20, 5, "f".into());
2890 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2891 let frame = v.frame(&m, &mut idx);
2892 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2894 assert!(frame.highlights[0].is_empty());
2895 assert!(frame.highlights[1].is_empty());
2896 assert_eq!(frame.highlights[2], vec![0..5]);
2897 assert!(frame.highlights[3].is_empty());
2898 }
2899
2900 #[test]
2901 fn frame_highlights_substring_inside_a_row() {
2902 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2903 let mut v = Viewport::new(40, 5, "f".into());
2904 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2905 let frame = v.frame(&m, &mut idx);
2906 assert_eq!(frame.highlights[0], vec![18..22]);
2908 assert!(frame.highlights[1].is_empty());
2909 }
2910
2911 #[test]
2912 fn search_highlight_with_filter_dim_keeps_row_dim() {
2913 let (m, mut idx) = setup(b"alpha\nbeta\n");
2916 let mut v = Viewport::new(20, 5, "f".into());
2917 let fmt = crate::format::LogFormat::compile(
2918 "simple",
2919 r"^(?P<line>.+)$",
2920 )
2921 .unwrap();
2922 let f = crate::filter::CompiledFilter::compile(
2923 &fmt,
2924 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2925 CaseMode::Sensitive,
2926 )
2927 .unwrap();
2928 v.set_filter(Some(f));
2929 v.set_dim_mode(true);
2930 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2931 let frame = v.frame(&m, &mut idx);
2932 assert_eq!(frame.row_styles[0], RowStyle::Normal);
2933 assert_eq!(frame.row_styles[1], RowStyle::Dim);
2934 assert_eq!(frame.highlights[1], vec![0..4]);
2935 }
2936
2937 #[test]
2938 fn grep_only_hides_non_matching_lines() {
2939 use crate::grep::GrepPredicate;
2940 let src = crate::source::MockSource::new();
2941 src.append(b"keep this error\n");
2942 src.append(b"drop this one\n");
2943 src.append(b"another error line\n");
2944 src.finish();
2945 let mut idx = crate::line_index::LineIndex::new();
2946 idx.extend_to_end(&src);
2947
2948 let mut v = Viewport::new(40, 5, "test".into());
2949 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2950 v.extend_visible_lines(&idx, &src);
2951
2952 let frame = v.frame(&src, &mut idx);
2954 let body_text: Vec<String> = frame.body.iter()
2955 .map(|row| row.iter().filter_map(|c| match c {
2956 crate::render::Cell::Char { ch, .. } => Some(*ch),
2957 _ => None,
2958 }).collect())
2959 .collect();
2960 assert!(body_text[0].contains("keep this error"));
2961 assert!(body_text[1].contains("another error line"));
2962 assert!(frame.status.contains("[grep]"));
2963 }
2964
2965 #[test]
2966 fn filter_and_grep_combine_with_and() {
2967 use crate::grep::GrepPredicate;
2968 let fmt = crate::format::LogFormat::compile(
2969 "simple",
2970 r"^(?P<level>\w+) (?P<msg>.+)$",
2971 ).unwrap();
2972 let f = crate::filter::CompiledFilter::compile(
2973 &fmt,
2974 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2975 CaseMode::Sensitive,
2976 ).unwrap();
2977 let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2978
2979 let src = crate::source::MockSource::new();
2980 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();
2985 let mut idx = crate::line_index::LineIndex::new();
2986 idx.extend_to_end(&src);
2987
2988 let mut v = Viewport::new(80, 5, "test".into());
2989 v.set_filter(Some(f));
2990 v.set_grep(Some(g));
2991 v.extend_visible_lines(&idx, &src);
2992 assert_eq!(v.visible_lines(), &[0usize]);
2993 }
2994
2995 #[test]
2996 fn search_status_shows_pattern() {
2997 let (m, mut idx) = setup(b"x\n");
2998 let mut v = Viewport::new(20, 5, "f".into());
2999 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3000 let frame = v.frame(&m, &mut idx);
3001 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3002 }
3003
3004 #[test]
3005 fn repeat_search_after_first_match_advances() {
3006 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3007 let mut v = Viewport::new(40, 5, "f".into());
3008 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3009 assert!(v.search_repeat(&m, &mut idx, false));
3010 assert_eq!(v.top_line, 1, "first foo");
3011 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3012 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3013 assert_eq!(v.top_line, 3, "should advance to next foo");
3014 }
3015
3016 #[test]
3017 fn auto_scroll_paused_when_follow_off() {
3018 let m = MockSource::new();
3019 m.append(b"1\n2\n3\n4\n");
3020 let mut idx = LineIndex::new();
3021 let mut v = Viewport::new(10, 5, "f".into());
3022 idx.extend_to_end(&m);
3024 let frame_before = v.frame(&m, &mut idx);
3025 let top_first_cell = frame_before.body[0][0].clone();
3026 m.append(b"5\n6\n7\n8\n");
3027 simulate_growth_tick(&mut v, &m, &mut idx);
3028 let frame_after = v.frame(&m, &mut idx);
3029 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3030 }
3031
3032 #[test]
3035 fn search_jumps_to_next_matching_record() {
3036 let m = MockSource::new();
3037 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
3038 let mut idx = LineIndex::new();
3039 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3040 idx.extend_to_end(&m);
3041 let mut v = Viewport::new(40, 10, "f".into());
3042 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3043 let hit = v.search_repeat(&m, &mut idx, false);
3044 assert!(hit, "should find 'charlie' in record 2");
3045 assert_eq!(v.top_line(), 3); }
3047
3048 #[test]
3049 fn search_finds_cross_line_match_in_record_with_s_flag() {
3050 let m = MockSource::new();
3051 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
3052 let mut idx = LineIndex::new();
3053 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3054 idx.extend_to_end(&m);
3055 let mut v = Viewport::new(40, 10, "f".into());
3056 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3057 let hit = v.search_repeat(&m, &mut idx, false);
3058 assert!(hit, "should match across \\n inside record 0 with (?s)");
3059 assert_eq!(v.top_line(), 0);
3060 }
3061
3062 #[test]
3063 fn search_repeat_with_no_match_returns_false() {
3064 let m = MockSource::new();
3065 m.append(b"[1] alpha\n[2] bravo\n");
3066 let mut idx = LineIndex::new();
3067 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3068 idx.extend_to_end(&m);
3069 let mut v = Viewport::new(40, 10, "f".into());
3070 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3071 let hit = v.search_repeat(&m, &mut idx, false);
3072 assert!(!hit);
3073 }
3074
3075 #[test]
3078 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3079 let m = MockSource::new();
3082 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
3083 let mut idx = LineIndex::new();
3084 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3085 idx.extend_to_end(&m);
3086 let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3087 let mut v = Viewport::new(40, 10, "f".into());
3088 v.set_grep(Some(grep));
3089 v.extend_visible_lines(&idx, &m);
3090 assert_eq!(v.visible_lines(), &[0usize, 1]);
3093 }
3094
3095 #[test]
3096 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3097 let m = MockSource::new();
3103 m.append(
3104 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
3105 );
3106 let mut idx = LineIndex::new();
3107 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3108 idx.extend_to_end(&m);
3109 let fmt = crate::format::LogFormat::compile(
3110 "rec",
3111 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3112 )
3113 .unwrap();
3114 let f = crate::filter::CompiledFilter::compile(
3115 &fmt,
3116 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3117 CaseMode::Sensitive,
3118 )
3119 .unwrap();
3120 let mut v = Viewport::new(40, 10, "f".into());
3121 v.set_filter(Some(f));
3122 v.extend_visible_lines(&idx, &m);
3123 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3125 }
3126
3127 #[test]
3128 fn grep_matches_across_record_newlines_in_records_mode() {
3129 let m = MockSource::new();
3131 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
3132 let mut idx = LineIndex::new();
3133 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3134 idx.extend_to_end(&m);
3135 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3136 let mut v = Viewport::new(40, 10, "f".into());
3137 v.set_grep(Some(grep));
3138 v.extend_visible_lines(&idx, &m);
3139 assert_eq!(v.visible_lines(), &[0usize, 1]);
3141 }
3142
3143 #[test]
3144 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3145 let m = MockSource::new();
3148 m.append(b"[1] head\n cont\n[2] other\n cont\n");
3149 let mut idx = LineIndex::new();
3150 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3151 idx.extend_to_end(&m);
3152 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3153 let mut v = Viewport::new(40, 10, "f".into());
3154 v.set_grep(Some(grep));
3155 v.set_dim_mode(true);
3156 v.extend_visible_lines(&idx, &m);
3157 assert_eq!(v.visible_lines(), &[] as &[usize]);
3159 assert!(!v.should_dim_line(0, &idx, &m));
3161 assert!(!v.should_dim_line(1, &idx, &m));
3162 assert!(v.should_dim_line(2, &idx, &m));
3164 assert!(v.should_dim_line(3, &idx, &m));
3165 }
3166
3167 #[test]
3168 fn status_unchanged_when_records_inactive() {
3169 let (m, mut idx) = setup(b"a\nb\nc\n");
3170 let mut v = Viewport::new(20, 5, "f".into());
3171 let frame = v.frame(&m, &mut idx);
3172 let status = &frame.status;
3173 assert!(status.contains("1-3/3"), "got: {status}");
3175 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3176 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3177 }
3178
3179 #[test]
3180 fn status_r_block_uses_real_lines_in_hide_mode() {
3181 let m = MockSource::new();
3190 let mut buf = Vec::new();
3193 for n in 0..10 {
3194 let kind = if n >= 8 { "B" } else { "A" };
3195 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
3196 }
3197 m.append(&buf);
3198 m.finish();
3199
3200 let mut idx = LineIndex::new();
3201 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3202 idx.extend_to_end(&m);
3203
3204 let fmt = crate::format::LogFormat::compile(
3205 "rec",
3206 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3207 )
3208 .unwrap();
3209 let f = crate::filter::CompiledFilter::compile(
3210 &fmt,
3211 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3212 CaseMode::Sensitive,
3213 )
3214 .unwrap();
3215
3216 let mut v = Viewport::new(80, 5, "f".into());
3219 v.set_filter(Some(f));
3220 v.extend_visible_lines(&idx, &m);
3221
3222 v.goto_record(8, &m, &mut idx);
3224
3225 let frame = v.frame(&m, &mut idx);
3226 assert!(
3228 frame.status.contains("R9-10/10"),
3229 "expected R9-10/10 in status, got: {}",
3230 frame.status,
3231 );
3232 }
3233
3234 #[test]
3235 fn status_dual_readout_when_records_active() {
3236 let m = MockSource::new();
3237 m.append(b"[1] a\n cont\n[2] b\n");
3238 m.finish();
3239 let mut idx = LineIndex::new();
3240 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3241 idx.extend_to_end(&m);
3242 let mut v = Viewport::new(20, 5, "f".into());
3243 let frame = v.frame(&m, &mut idx);
3244 let status = &frame.status;
3245 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3246 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3247 }
3248
3249 #[test]
3250 fn format_status_uses_custom_template_when_set() {
3251 let m = MockSource::new();
3252 m.append(b"a\nb\nc\n");
3253 m.finish();
3254 let mut idx = LineIndex::new();
3255 idx.extend_to_end(&m);
3256 let mut v = Viewport::new(20, 5, "f".into());
3257 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3258 v.set_prompt(Some(prompt));
3259 let frame = v.frame(&m, &mut idx);
3260 assert_eq!(frame.status, "f 100%");
3261 }
3262
3263 #[test]
3264 fn status_shows_preprocess_failed_tag_when_set() {
3265 let m = MockSource::new();
3266 m.append(b"a\n");
3267 let mut idx = LineIndex::new();
3268 idx.extend_to_end(&m);
3269 let mut v = Viewport::new(40, 5, "f".into());
3270 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3271 let frame = v.frame(&m, &mut idx);
3272 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3273 "got: {}", frame.status);
3274 }
3275
3276 #[test]
3277 fn default_status_includes_help_hint() {
3278 let (m, mut idx) = setup(b"a\nb\nc\n");
3279 let mut v = Viewport::new(80, 5, "f".into());
3280 let frame = v.frame(&m, &mut idx);
3281 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3282 }
3283
3284 #[test]
3285 fn custom_prompt_does_not_get_help_hint() {
3286 let (m, mut idx) = setup(b"a\nb\nc\n");
3287 let mut v = Viewport::new(80, 5, "f".into());
3288 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3289 let frame = v.frame(&m, &mut idx);
3290 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3291 }
3292
3293 #[test]
3294 fn status_shows_file_index_when_multifile() {
3295 let m = MockSource::new();
3296 m.append(b"a\n");
3297 let mut idx = LineIndex::new();
3298 idx.extend_to_end(&m);
3299 let mut v = Viewport::new(60, 5, "f.log".into());
3300 v.set_file_index(0, 3);
3301 let frame = v.frame(&m, &mut idx);
3302 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
3303 }
3304
3305 #[test]
3306 fn status_omits_file_index_when_single_file() {
3307 let m = MockSource::new();
3308 m.append(b"a\n");
3309 let mut idx = LineIndex::new();
3310 idx.extend_to_end(&m);
3311 let mut v = Viewport::new(60, 5, "f.log".into());
3312 v.set_file_index(0, 1);
3313 let frame = v.frame(&m, &mut idx);
3314 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3315 }
3316
3317 #[test]
3318 fn status_shows_tag_active_when_multimatch() {
3319 let m = MockSource::new();
3320 m.append(b"a\n");
3321 let mut idx = LineIndex::new();
3322 idx.extend_to_end(&m);
3323 let mut v = Viewport::new(80, 5, "f.log".into());
3324 v.set_tag_active(Some(("foo".into(), 2, 3)));
3325 let frame = v.frame(&m, &mut idx);
3326 assert!(
3327 frame.status.contains("[tag: foo (2/3)]"),
3328 "got: {}",
3329 frame.status
3330 );
3331 }
3332
3333 #[test]
3334 fn status_omits_tag_active_when_single_match() {
3335 let m = MockSource::new();
3336 m.append(b"a\n");
3337 let mut idx = LineIndex::new();
3338 idx.extend_to_end(&m);
3339 let mut v = Viewport::new(80, 5, "f.log".into());
3340 v.set_tag_active(Some(("foo".into(), 1, 1)));
3341 let frame = v.frame(&m, &mut idx);
3342 assert!(
3343 !frame.status.contains("[tag:"),
3344 "should not show indicator for single match: {}",
3345 frame.status
3346 );
3347 }
3348
3349 #[test]
3350 fn hscroll_noop_when_wrapping() {
3351 let mut v = Viewport::new(80, 24, "t".into());
3352 v.hscroll_right_step();
3354 assert_eq!(v.left_col(), 0);
3355 }
3356
3357 #[test]
3358 fn hscroll_active_in_chop_and_clamps_at_zero() {
3359 let mut v = Viewport::new(80, 24, "t".into());
3360 v.toggle_chop(); assert!(v.hscroll_active());
3362 v.hscroll_right_step();
3363 assert_eq!(v.left_col(), 8);
3364 v.hscroll_right_half();
3365 assert_eq!(v.left_col(), 8 + 40); v.hscroll_left_half();
3367 assert_eq!(v.left_col(), 8);
3368 v.hscroll_left_half();
3369 assert_eq!(v.left_col(), 0); }
3371
3372 #[test]
3373 fn hscroll_resets_to_zero_when_wrap_turned_on() {
3374 let mut v = Viewport::new(80, 24, "t".into());
3375 v.toggle_chop(); v.hscroll_right_step();
3377 assert_eq!(v.left_col(), 8);
3378 v.toggle_chop(); assert_eq!(v.left_col(), 0);
3380 }
3381
3382 #[test]
3383 fn reset_hscroll_zeroes_left_col() {
3384 let mut v = Viewport::new(80, 24, "t".into());
3386 v.toggle_chop();
3387 v.hscroll_right_step();
3388 assert_eq!(v.left_col(), 8);
3389 v.reset_hscroll();
3390 assert_eq!(v.left_col(), 0);
3391 }
3392
3393 #[test]
3396 fn reconstruct_picks_up_state_from_prior_lines() {
3397 let m = MockSource::new();
3398 m.append(b"\x1b[31mline 1\n");
3399 m.append(b"line 2 (still red, no reset)\n");
3400 m.append(b"line 3\n");
3401 let mut idx = LineIndex::new();
3402 idx.extend_to_end(&m);
3403 let state = reconstruct_render_state(&m, &idx, 2);
3404 assert_eq!(
3405 state.style.fg,
3406 Some(crate::ansi::Color::Ansi(1)),
3407 "red SGR from line 0 should persist to line 2"
3408 );
3409 }
3410
3411 #[test]
3412 fn reconstruct_respects_reset_between_lines() {
3413 let m = MockSource::new();
3414 m.append(b"\x1b[31mline 1\x1b[0m\n");
3415 m.append(b"line 2 (default)\n");
3416 let mut idx = LineIndex::new();
3417 idx.extend_to_end(&m);
3418 let state = reconstruct_render_state(&m, &idx, 1);
3419 assert_eq!(state.style.fg, None);
3420 }
3421
3422 #[test]
3423 fn reconstruct_caps_walkback_at_max_lines() {
3424 let m = MockSource::new();
3425 m.append(b"\x1b[31mvery early\n");
3426 for _ in 0..300 {
3427 m.append(b"line\n");
3428 }
3429 let mut idx = LineIndex::new();
3430 idx.extend_to_end(&m);
3431 let state = reconstruct_render_state(&m, &idx, 290);
3434 assert_eq!(state.style.fg, None);
3435 }
3436
3437 #[test]
3438 fn or_groups_narrow_within_required_line_mode() {
3439 let mut raw = crate::or::OrSpecRaw::new();
3440 raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
3441 raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
3442 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3443 let mut v = Viewport::new(80, 24, "t".into());
3444 v.set_or_groups(og);
3445 assert!(v.or_active());
3446 assert!(v.line_passes(b"login failed"));
3447 assert!(v.line_passes(b"access denied"));
3448 assert!(!v.line_passes(b"login ok"));
3449 }
3450
3451 #[test]
3452 fn status_shows_or_indicator_when_active() {
3453 let mut raw = crate::or::OrSpecRaw::new();
3454 raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
3455 let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3456 let (m, mut idx) = setup(b"x\ny\nx\n");
3457 idx.extend_to_end(&m);
3458 let mut v = Viewport::new(80, 5, "f".into());
3459 v.set_or_groups(og);
3460 v.extend_visible_lines(&idx, &m);
3461 let status = v.format_status(&idx, &m);
3462 assert!(status.contains("[or]"), "expected [or] in status: {status}");
3463 assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
3464 }
3465
3466 #[test]
3467 fn status_shows_col_offset_when_scrolled() {
3468 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
3470 let (m, mut idx) = setup(content);
3471 let mut v = Viewport::new(10, 3, "t".into());
3472 v.toggle_chop(); v.hscroll_right_step(); let f = v.frame(&m, &mut idx);
3475 assert!(
3476 f.status.contains('\u{00bb}'),
3477 "expected » in status after hscroll_right_step, got: {}",
3478 f.status
3479 );
3480 }
3481
3482 #[test]
3483 fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
3484 let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
3493 let (m, mut idx) = setup(content);
3494
3495 let mut v = Viewport::new(10, 3, "t".into());
3497 v.toggle_chop(); let frame0 = v.frame(&m, &mut idx);
3501 assert_eq!(
3502 frame0.body[0][0],
3503 Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
3504 "at left_col=0 first cell should be 'A'"
3505 );
3506 assert!(
3508 !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
3509 "no left marker expected at left_col=0"
3510 );
3511
3512 v.hscroll_right_step();
3514 assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
3515
3516 let frame1 = v.frame(&m, &mut idx);
3517 assert_eq!(
3519 frame1.body[0][0],
3520 Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
3521 "after scrolling right, first cell should be the '<' left marker"
3522 );
3523 assert_eq!(
3527 frame1.body[0][1],
3528 Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
3529 "second cell should be 'J' (display column left_col+1 = 9)"
3530 );
3531 }
3532}