1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::line_index::LineIndex;
8use crate::render::{count_rows, render_line, Cell, RenderOpts};
9use crate::source::Source;
10
11const MAX_RECONSTRUCT_LINES: usize = 256;
15
16fn reconstruct_render_state(
23 src: &dyn Source,
24 idx: &crate::line_index::LineIndex,
25 target_line: usize,
26) -> crate::render::RenderState {
27 let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
28 let mut state = crate::render::RenderState::default();
29 for line_no in start..target_line {
30 let range = idx.line_range(line_no, src);
31 let raw = src.bytes(range);
32 for &b in raw.as_ref() {
33 let _ = crate::ansi::step(
34 &mut state.parse,
35 &mut state.style,
36 &mut state.hyperlink,
37 b,
38 );
39 }
40 }
41 state
42}
43
44fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
50 let mut text = String::new();
51 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
52 for (col, cell) in row.iter().enumerate() {
53 match cell {
54 Cell::Char { ch, .. } => {
55 starts.push(col);
56 text.push(*ch);
57 }
58 Cell::Empty => {
59 starts.push(col);
60 text.push(' ');
61 }
62 Cell::Continuation => {}
63 }
64 }
65 starts.push(row.len());
66 (text, starts)
67}
68
69fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
74 if row.is_empty() {
75 return Vec::new();
76 }
77 let last_content_col = row
78 .iter()
79 .enumerate()
80 .rev()
81 .find_map(|(c, cell)| match cell {
82 Cell::Char { width, .. } => Some(c + *width as usize),
83 Cell::Continuation => Some(c + 1),
84 Cell::Empty => None,
85 })
86 .unwrap_or(0);
87 if last_content_col == 0 {
88 return Vec::new();
89 }
90 let (text, starts) = row_text_and_starts(row);
91 let mut out = Vec::new();
92 for m in regex.find_iter(&text) {
93 if m.start() == m.end() {
94 continue;
95 }
96 let char_start = text[..m.start()].chars().count();
97 let char_end = text[..m.end()].chars().count();
98 if char_start >= starts.len() - 1 || char_end <= char_start {
99 continue;
100 }
101 let col_start = starts[char_start];
102 let col_end = starts[char_end].min(last_content_col);
103 if col_end > col_start {
104 out.push(col_start..col_end);
105 }
106 }
107 out
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum RowStyle {
112 Normal,
113 Dim,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SearchDirection {
120 Forward,
121 Backward,
122}
123
124#[derive(Debug, Clone)]
125pub struct SearchState {
126 pub raw: String,
127 pub regex: Regex,
128 pub direction: SearchDirection,
129}
130
131#[derive(Debug, Clone)]
132pub struct Frame {
133 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
140 pub status: String,
141}
142
143pub struct Viewport {
144 top_line: usize,
145 top_row: usize,
146 cols: u16,
147 rows: u16,
148 pub opts: RenderOpts,
149 pub show_line_numbers: bool,
150 pub source_label: String,
151 follow_mode: bool,
152 live_mode: bool,
153 prettify_label: Option<String>,
154 format_label: Option<String>,
155 filter: Option<CompiledFilter>,
156 grep: Option<GrepPredicate>,
157 dim_mode: bool,
158 visible_lines: Vec<usize>,
161 visible_scanned: usize,
164 search: Option<SearchState>,
165 display: Option<crate::format::DisplayRenderer>,
169 hex_mode: bool,
170 hex_group_size: usize,
173 prompt: Option<crate::prompt::ParsedPrompt>,
176 preprocess_failure: Option<String>,
179 file_index: Option<(usize, usize)>,
181 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
185 render_state: crate::render::RenderState,
189 render_state_for: usize,
192}
193
194impl Viewport {
195 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
196 let opts = RenderOpts { cols, ..RenderOpts::default() };
197 Self {
198 top_line: 0,
199 top_row: 0,
200 cols,
201 rows,
202 opts,
203 show_line_numbers: false,
204 source_label,
205 follow_mode: false,
206 live_mode: false,
207 prettify_label: None,
208 format_label: None,
209 filter: None,
210 grep: None,
211 dim_mode: false,
212 visible_lines: Vec::new(),
213 visible_scanned: 0,
214 search: None,
215 display: None,
216 hex_mode: false,
217 hex_group_size: 2,
218 prompt: None,
219 preprocess_failure: None,
220 file_index: None,
221 tag_active: None,
222 ansi_mode: crate::render::AnsiMode::Strict,
223 render_state: crate::render::RenderState::default(),
224 render_state_for: usize::MAX,
225 }
226 }
227
228 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
229 self.display = renderer;
230 }
231
232 pub fn set_hex_mode(&mut self, on: bool) {
233 self.hex_mode = on;
234 }
235
236 pub fn hex_mode(&self) -> bool {
238 self.hex_mode
239 }
240
241 pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
244 if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
245 self.hex_group_size = bytes_per_group;
246 }
247 }
248
249 pub fn hex_group_size(&self) -> usize {
251 self.hex_group_size
252 }
253
254 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
255 self.prompt = prompt;
256 }
257
258 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
259 self.preprocess_failure = msg;
260 }
261
262 pub fn set_file_index(&mut self, current: usize, total: usize) {
263 self.file_index = if total > 1 {
264 Some((current, total))
265 } else {
266 None
267 };
268 }
269
270 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
271 self.tag_active = info;
272 }
273
274 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
275 self.ansi_mode = mode;
276 }
277
278 pub fn set_source_label(&mut self, label: String) {
279 self.source_label = label;
280 }
281
282 pub fn source_label_clone(&self) -> String {
283 self.source_label.clone()
284 }
285
286 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
291 let range = idx.line_range(line_n, src);
292 let raw = src.bytes(range);
293 if let Some(r) = self.display.as_ref() {
294 if let Some(rendered) = r.render_line(&raw) {
295 return std::borrow::Cow::Owned(rendered.into_bytes());
296 }
297 }
298 raw
299 }
300
301 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
305 let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
306 self.search = Some(SearchState { raw, regex, direction });
307 Ok(())
308 }
309
310 pub fn clear_search(&mut self) { self.search = None; }
311
312 pub fn search_active(&self) -> bool { self.search.is_some() }
313
314 pub fn search_direction(&self) -> SearchDirection {
315 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
316 }
317
318 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
322 if idx.records_mode() {
323 self.search_repeat_records(src, idx, reverse)
324 } else {
325 self.search_repeat_lines(src, idx, reverse)
326 }
327 }
328
329 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
331 let Some(s) = self.search.as_ref() else { return false; };
332 let forward = matches!(
333 (s.direction, reverse),
334 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
335 );
336 idx.extend_to_end(src);
337 let pattern = s.regex.clone();
338 if self.hide_mode() {
339 self.extend_visible_lines(idx, src);
340 self.search_step_in_visible(&pattern, src, idx, forward)
341 } else {
342 self.search_step_in_logical(&pattern, src, idx, forward)
343 }
344 }
345
346 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
350 let Some(s) = self.search.as_ref() else { return false; };
351 let forward = matches!(
352 (s.direction, reverse),
353 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
354 );
355 let pattern = s.regex.clone();
356 idx.extend_to_end(src);
357
358 let total = idx.record_count();
359 if total == 0 { return false; }
360
361 let cur_record = idx.line_to_record(self.top_line);
362
363 let range: Box<dyn Iterator<Item = usize>> = if forward {
364 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
365 } else {
366 let earlier: Vec<usize> = (0..cur_record).rev().collect();
367 let later: Vec<usize> = (cur_record..total).rev().collect();
368 Box::new(earlier.into_iter().chain(later))
369 };
370
371 for r in range {
372 let bytes = idx.record_bytes_stripped(r, src);
373 let text = String::from_utf8_lossy(&bytes);
374 if pattern.is_match(&text) {
375 let line_range = idx.record_line_range(r);
376 self.top_line = line_range.start;
377 self.top_row = 0;
378 return true;
379 }
380 }
381 false
382 }
383
384 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
385 let display = self.line_display_bytes(src, idx, line_n);
390 let bytes = crate::ansi::strip_sgr(&display);
391 match std::str::from_utf8(&bytes) {
392 Ok(s) => pattern.is_match(s),
393 Err(_) => false,
394 }
395 }
396
397 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
398 let total = idx.line_count();
399 if total == 0 { return false; }
400 let start = self.top_line;
401 for offset in 1..=total {
404 let line_n = if forward {
405 (start + offset) % total
406 } else {
407 (start + total - offset) % total
408 };
409 if self.line_matches(pattern, src, idx, line_n) {
410 self.top_line = line_n;
411 self.top_row = 0;
412 return true;
413 }
414 }
415 false
416 }
417
418 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
419 let total = self.visible_lines.len();
420 if total == 0 { return false; }
421 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
423 for offset in 1..=total {
424 let visible_idx = if forward {
425 (cur + offset) % total
426 } else {
427 (cur + total - offset) % total
428 };
429 let line_n = self.visible_lines[visible_idx];
430 if self.line_matches(pattern, src, idx, line_n) {
431 self.top_line = line_n;
432 self.top_row = 0;
433 return true;
434 }
435 }
436 false
437 }
438
439 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
440 self.filter = filter;
441 self.visible_lines.clear();
442 self.visible_scanned = 0;
443 self.top_line = 0;
445 self.top_row = 0;
446 }
447
448 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
449 self.grep = grep;
450 self.visible_lines.clear();
451 self.visible_scanned = 0;
452 self.top_line = 0;
453 self.top_row = 0;
454 }
455
456 pub fn grep_active(&self) -> bool { self.grep.is_some() }
457
458 pub fn set_dim_mode(&mut self, on: bool) {
459 self.dim_mode = on;
460 self.visible_lines.clear();
464 self.visible_scanned = 0;
465 }
466
467 pub fn filter_active(&self) -> bool { self.filter.is_some() }
468
469 pub fn dim_mode(&self) -> bool { self.dim_mode }
470
471 fn hide_mode(&self) -> bool {
472 (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
473 }
474
475 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
480 if !self.hide_mode() {
481 return;
482 }
483 if idx.records_mode() {
484 self.extend_visible_lines_records(idx, src);
485 } else {
486 self.extend_visible_lines_per_line(idx, src);
487 }
488 }
489
490 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
492 let total = idx.line_count();
493 while self.visible_scanned < total {
494 let line_n = self.visible_scanned;
495 let bytes = idx.line_bytes_stripped(line_n, src);
496 if self.line_passes(&bytes) {
497 self.visible_lines.push(line_n);
498 }
499 self.visible_scanned += 1;
500 }
501 }
502
503 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
510 self.visible_lines.clear();
511 self.visible_scanned = 0; let total_records = idx.record_count();
513 for r in 0..total_records {
514 if self.record_passes(idx, src, r) {
515 for line_n in idx.record_line_range(r) {
516 self.visible_lines.push(line_n);
517 }
518 }
519 }
520 }
521
522 fn line_passes(&self, line: &[u8]) -> bool {
528 let filter_ok = match self.filter.as_ref() {
529 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
530 None => true,
531 };
532 let grep_ok = match self.grep.as_ref() {
533 Some(g) => g.matches(line),
534 None => true,
535 };
536 filter_ok && grep_ok
537 }
538
539 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
547 let bytes = if self.filter.is_some() || self.grep.is_some() {
548 Some(idx.record_bytes_stripped(r, src))
549 } else {
550 None
551 };
552 let filter_ok = match self.filter.as_ref() {
553 Some(f) => matches!(
554 f.evaluate_record(bytes.as_deref().unwrap()),
555 FilterMatch::Matched,
556 ),
557 None => true,
558 };
559 let grep_ok = match self.grep.as_ref() {
560 Some(g) => g.matches(bytes.as_deref().unwrap()),
561 None => true,
562 };
563 filter_ok && grep_ok
564 }
565
566 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
570 if !self.dim_mode {
571 return false;
572 }
573 if idx.records_mode() {
574 let r = idx.line_to_record(line_n);
575 !self.record_passes(idx, src, r)
576 } else {
577 let bytes = idx.line_bytes_stripped(line_n, src);
578 !self.line_passes(&bytes)
579 }
580 }
581
582 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
590 let body_rows = self.body_rows() as usize;
591 if self.hide_mode() && !self.visible_lines.is_empty() {
592 let cur = self
593 .visible_lines
594 .iter()
595 .position(|&l| l >= self.top_line)
596 .unwrap_or(self.visible_lines.len().saturating_sub(1));
597 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
598 return self.visible_lines[last_pos];
599 }
600 let total = idx.line_count();
601 if total == 0 {
602 return self.top_line;
603 }
604 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
605 }
606
607 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
608
609 pub fn follow_mode(&self) -> bool { self.follow_mode }
610
611 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
612
613 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
614
615 pub fn live_mode(&self) -> bool { self.live_mode }
616
617 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
618
619 pub fn set_prettify_label(&mut self, label: Option<String>) {
622 self.prettify_label = label;
623 }
624
625 pub fn set_format_label(&mut self, label: Option<String>) {
628 self.format_label = label;
629 }
630
631 pub fn invalidate_filter_cache(&mut self) {
636 self.visible_lines.clear();
637 self.visible_scanned = 0;
638 }
639
640 pub fn clamp_top_line(&mut self, line_count: usize) {
643 if line_count == 0 {
644 self.top_line = 0;
645 self.top_row = 0;
646 } else if self.top_line >= line_count {
647 self.top_line = line_count - 1;
648 self.top_row = 0;
649 }
650 }
651
652 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
656 let body = self.body_rows() as usize;
657 if self.hide_mode() {
658 let pos = self
660 .visible_lines
661 .iter()
662 .position(|&l| l >= self.top_line)
663 .unwrap_or(self.visible_lines.len());
664 pos + body >= self.visible_lines.len()
665 } else {
666 self.top_line + body >= idx.line_count()
667 }
668 }
669
670 fn gutter_width(&self, idx: &LineIndex) -> u16 {
672 if !self.show_line_numbers { return 0; }
673 let n = idx.line_count().max(1);
674 let digits = (n as f64).log10().floor() as u16 + 1;
675 digits + 1
676 }
677
678 fn render_opts(&self, gutter: u16) -> RenderOpts {
679 let mut o = self.opts.clone();
680 o.cols = self.cols.saturating_sub(gutter);
681 o.mode = self.ansi_mode;
682 o
683 }
684
685 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
686 if self.hex_mode {
687 return self.frame_hex(src);
688 }
689 let body_rows = self.body_rows() as usize;
690 idx.extend_to_line(self.top_line + body_rows + 1, src);
691
692 let gutter = self.gutter_width(idx);
693 let r_opts = self.render_opts(gutter);
694
695 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
699 reconstruct_render_state(src, idx, self.top_line)
700 } else {
701 crate::render::RenderState::default()
702 };
703 self.render_state = render_state.clone();
705 self.render_state_for = self.top_line;
706
707 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
708 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
709 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
710 let hide = self.hide_mode();
712 let total_lines = idx.line_count();
713
714 let mut hide_pos = if hide {
716 self.visible_lines
717 .iter()
718 .position(|&l| l >= self.top_line)
719 .unwrap_or(self.visible_lines.len())
720 } else {
721 0
722 };
723 let mut line_n = if hide {
724 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
725 } else {
726 self.top_line
727 };
728 let mut skip = if hide { 0 } else { self.top_row };
729
730 while body.len() < body_rows {
731 if line_n >= total_lines {
732 let mut row = Vec::with_capacity(self.cols as usize);
733 if gutter > 0 {
734 for _ in 0..gutter { row.push(Cell::Empty); }
735 }
736 while row.len() < self.cols as usize { row.push(Cell::Empty); }
737 body.push(row);
738 row_styles.push(RowStyle::Normal);
739 highlights.push(Vec::new());
740 line_n += 1;
741 continue;
742 }
743 let raw = src.bytes(idx.line_range(line_n, src));
746 let display_bytes = if let Some(r) = self.display.as_ref() {
747 match r.render_line(&raw) {
748 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
749 None => raw.clone(),
750 }
751 } else {
752 raw.clone()
753 };
754 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
755 Some(&mut render_state)
756 } else {
757 None
758 };
759 let rows = render_line(&display_bytes, &r_opts, state_arg);
760 let style = if self.filter.is_some() || self.grep.is_some() {
761 if self.dim_mode {
762 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
763 } else {
764 RowStyle::Normal
766 }
767 } else {
768 RowStyle::Normal
769 };
770
771 for (i, mut content_row) in rows.into_iter().enumerate() {
772 if i < skip { continue; }
773 if body.len() >= body_rows { break; }
774 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
775 if gutter > 0 {
776 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
777 for c in label.chars() {
778 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
779 }
780 }
781 full.append(&mut content_row);
782 let row_highlights = if let Some(s) = self.search.as_ref() {
786 find_row_highlights(&full, &s.regex)
787 } else {
788 Vec::new()
789 };
790 body.push(full);
791 row_styles.push(style);
792 highlights.push(row_highlights);
793 }
794 skip = 0;
795 if hide {
797 hide_pos += 1;
798 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
799 } else {
800 line_n += 1;
801 }
802 }
803
804 self.render_state_for = usize::MAX;
807
808 let status = self.format_status(idx, src);
809 Frame { body, row_styles, highlights, status }
810 }
811
812 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
813 if let Some(p) = self.prompt.as_ref() {
814 let ctx = self.build_prompt_context(idx, src);
815 return p.render(&ctx);
816 }
817 let body_rows = self.body_rows() as usize;
818 let total = idx.line_count();
819 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
822 let visible_total = self.visible_lines.len();
823 let cur = self
825 .visible_lines
826 .iter()
827 .position(|&l| l >= self.top_line)
828 .unwrap_or(visible_total);
829 let top = cur + 1;
830 let bottom = (cur + body_rows).min(visible_total.max(1));
831 let total_str = if src.is_complete() {
832 format!("{visible_total}/{total}")
833 } else {
834 format!("{visible_total}/{total}+")
835 };
836 (top, bottom, visible_total, total_str)
837 } else {
838 let top = self.top_line + 1;
839 let bottom = (self.top_line + body_rows).min(total.max(1));
840 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
841 (top, bottom, total, total_str)
842 };
843 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
844 let bottom_line = self.bottom_visible_line(idx);
848 let (line_prefix, records_block) = if idx.records_mode() {
849 let line_total = idx.line_count();
850 let rec_total = idx.record_count();
851 let rec_block = if line_total == 0 || rec_total == 0 {
852 format!("R0-0/{}", rec_total)
853 } else {
854 let rec_top = idx.line_to_record(self.top_line) + 1;
855 let rec_bottom = idx.line_to_record(bottom_line) + 1;
856 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
857 (rec_top, rec_top)
861 } else {
862 (rec_top, rec_bottom)
863 };
864 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
865 };
866 ("L", Some(rec_block))
867 } else {
868 ("", None)
869 };
870 let middle = match records_block {
871 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
872 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
873 };
874 let label_with_index = match self.file_index {
875 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
876 None => self.source_label.clone(),
877 };
878 let mut s = format!("{} {}", label_with_index, middle);
879 if !self.hide_mode() && self.top_row > 0 {
884 let line_rows = if total > 0 {
885 let bytes = self.line_display_bytes(src, idx, self.top_line);
886 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
887 } else { 1 };
888 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
889 }
890 if let Some(f) = self.filter.as_ref() {
891 s.push_str(&format!(" [{}]", f.format_name));
892 }
893 if self.grep.is_some() {
894 s.push_str(" [grep]");
895 }
896 if self.filter.is_some() || self.grep.is_some() {
897 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
898 }
899 if let Some(sr) = self.search.as_ref() {
900 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
901 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
902 }
903 if let Some(label) = self.prettify_label.as_ref() {
904 s.push_str(&format!(" [pretty:{label}]"));
905 }
906 if self.live_mode { s.push_str(" (L)"); }
907 if self.follow_mode { s.push_str(" (F)"); }
908 if let Some(msg) = self.preprocess_failure.as_ref() {
909 let first_line = msg.lines().next().unwrap_or("");
910 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
911 }
912 let tag_suffix = match &self.tag_active {
913 Some((name, cur, total)) if *total > 1 => {
914 format!(" [tag: {name} ({cur}/{total})]")
915 }
916 _ => String::new(),
917 };
918 s.push_str(&tag_suffix);
919 let used = s.chars().count();
922 let hint = ":help";
923 if (self.cols as usize) > used + 1 + hint.chars().count() {
924 let pad = self.cols as usize - used - hint.chars().count();
925 s.push_str(&" ".repeat(pad));
926 s.push_str(hint);
927 } else {
928 s.push(' ');
929 s.push_str(hint);
930 }
931 s
932 }
933
934 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
935 use crate::prompt::PromptContext;
936
937 let body_rows = self.body_rows() as usize;
938 let total = idx.line_count();
939 let top = self.top_line + 1;
940 let bottom = (self.top_line + body_rows).min(total.max(1));
941 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
942 let bottom_line = self.bottom_visible_line(idx);
943
944 let records_mode = idx.records_mode();
945 let (rec_top, rec_bottom, rec_total) = if records_mode {
946 let rt = idx.line_to_record(self.top_line) + 1;
947 let rb_raw = idx.line_to_record(bottom_line) + 1;
948 let rb = if rb_raw < rt { rt } else { rb_raw };
949 (rt, rb, idx.record_count())
950 } else {
951 (0, 0, 0)
952 };
953
954 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
955 let line_rows = if total > 0 {
956 let bytes = self.line_display_bytes(src, idx, self.top_line);
957 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
958 } else { 1 };
959 format!("+{}/{}", self.top_row, line_rows)
960 } else {
961 String::new()
962 };
963
964 let format_tag = self.format_label.as_ref()
965 .map(|n| format!(" [{}]", n))
966 .unwrap_or_default();
967 let filter_tag = self.filter.as_ref()
968 .map(|f| format!(" [{}]", f.format_name))
969 .unwrap_or_default();
970 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
971 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
972 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
973 } else {
974 String::new()
975 };
976 let search_tag = self.search.as_ref()
977 .map(|s| {
978 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
979 format!(" [{}{}]", p, s.raw)
980 })
981 .unwrap_or_default();
982 let pretty_tag = self.prettify_label.as_ref()
983 .map(|l| format!(" [pretty:{l}]"))
984 .unwrap_or_default();
985 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
986 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
987 let preprocess_failed_tag = self.preprocess_failure.as_ref()
988 .map(|msg| {
989 let first_line = msg.lines().next().unwrap_or("");
990 format!(" [preprocess-failed: {}]", first_line)
991 })
992 .unwrap_or_default();
993
994 let file_index_tag = match self.file_index {
995 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
996 None => String::new(),
997 };
998
999 let tag_tag = match &self.tag_active {
1000 Some((name, cur, total)) if *total > 1 => {
1001 format!(" [tag: {name} ({cur}/{total})]")
1002 }
1003 _ => String::new(),
1004 };
1005
1006 PromptContext {
1007 label: self.source_label.clone(),
1008 top,
1009 bottom,
1010 total,
1011 pct: pct.min(100) as u8,
1012 rec_top,
1013 rec_bottom,
1014 rec_total,
1015 records_mode,
1016 wrap_offset,
1017 format_tag,
1018 filter_tag,
1019 grep_tag,
1020 hide_tag,
1021 search_tag,
1022 pretty_tag,
1023 live_tag,
1024 follow_tag,
1025 preprocess_failed_tag,
1026 file_index_tag,
1027 tag_tag,
1028 }
1029 }
1030
1031 fn frame_hex(&self, src: &dyn Source) -> Frame {
1032 use crate::hex::format_hex_row;
1033 use crate::render::{render_line, Cell, RenderOpts};
1034
1035 let body_rows = self.rows.saturating_sub(1) as usize;
1036 let total_bytes = src.len();
1037 let total_hex_rows = total_bytes.div_ceil(16);
1038
1039 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1040 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1041 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1042
1043 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
1044
1045 for row_idx in 0..body_rows {
1046 let hex_row = self.top_line + row_idx;
1047 if hex_row >= total_hex_rows {
1048 body.push(vec![Cell::Empty; self.cols as usize]);
1049 } else {
1050 let offset = hex_row * 16;
1051 let end = (offset + 16).min(total_bytes);
1052 let bytes_cow = src.bytes(offset..end);
1053 let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1054 let rows = render_line(text.as_bytes(), &opts, None);
1055 body.push(rows.into_iter().next().unwrap_or_else(|| {
1056 vec![Cell::Empty; self.cols as usize]
1057 }));
1058 }
1059 row_styles.push(RowStyle::Normal);
1060 highlights.push(Vec::new());
1061 }
1062
1063 let status = self.format_status_hex(src);
1064 Frame { body, row_styles, highlights, status }
1065 }
1066
1067 fn format_status_hex(&self, src: &dyn Source) -> String {
1068 let total_bytes = src.len();
1069 let body_rows = self.rows.saturating_sub(1) as usize;
1070 let top_byte = self.top_line * 16;
1072 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1075 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1076 let label_with_index = match self.file_index {
1077 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1078 None => self.source_label.clone(),
1079 };
1080 let tag_suffix = match &self.tag_active {
1081 Some((name, cur, total)) if *total > 1 => {
1082 format!(" [tag: {name} ({cur}/{total})]")
1083 }
1084 _ => String::new(),
1085 };
1086 format!(
1087 "{} off {}-{}/{} {}% [hex]{}",
1088 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1089 )
1090 }
1091
1092 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1097 if delta == 0 { return; }
1098 if self.hide_mode() {
1099 self.scroll_lines(delta, src, idx);
1100 return;
1101 }
1102 if delta > 0 {
1103 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1104 let total = idx.line_count();
1105 if total == 0 { return; }
1106 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1107 self.top_line = target;
1108 self.top_row = 0;
1109 } else {
1110 let back = (-delta) as usize;
1111 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1116 let extra_back = back.saturating_sub(consumed_for_snap);
1117 self.top_line = self.top_line.saturating_sub(extra_back);
1118 self.top_row = 0;
1119 }
1120 }
1121
1122 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1123 if delta == 0 { return; }
1124 if self.hide_mode() {
1125 self.extend_visible_lines(idx, src);
1129 let total = self.visible_lines.len();
1130 if total == 0 {
1131 self.top_line = 0;
1132 self.top_row = 0;
1133 return;
1134 }
1135 let cur = self
1136 .visible_lines
1137 .iter()
1138 .position(|&l| l >= self.top_line)
1139 .unwrap_or(total);
1140 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1141 self.top_line = self.visible_lines[new];
1142 self.top_row = 0;
1143 return;
1144 }
1145 if delta > 0 {
1146 let mut remaining = delta as usize;
1147 while remaining > 0 {
1148 idx.extend_to_line(self.top_line + 1, src);
1149 let total = idx.line_count();
1150 if total == 0 { break; }
1151 let bytes = self.line_display_bytes(src, idx, self.top_line);
1152 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1153 if self.top_row + 1 < line_rows {
1154 self.top_row += 1;
1155 } else if self.top_line + 1 < total {
1156 self.top_row = 0;
1157 self.top_line += 1;
1158 } else {
1159 break;
1160 }
1161 remaining -= 1;
1162 }
1163 } else {
1164 let mut remaining = (-delta) as usize;
1165 while remaining > 0 {
1166 if self.top_row > 0 {
1167 self.top_row -= 1;
1168 } else if self.top_line > 0 {
1169 self.top_line -= 1;
1170 let bytes = self.line_display_bytes(src, idx, self.top_line);
1171 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1172 self.top_row = line_rows.saturating_sub(1);
1173 } else {
1174 break;
1175 }
1176 remaining -= 1;
1177 }
1178 }
1179 }
1180
1181 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1182 let n = self.body_rows() as i64;
1183 self.scroll_lines(n, src, idx);
1184 }
1185
1186 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1187 let n = self.body_rows() as i64;
1188 self.scroll_lines(-n, src, idx);
1189 }
1190
1191 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1192 let n = (self.body_rows() / 2).max(1) as i64;
1193 self.scroll_lines(n, src, idx);
1194 }
1195
1196 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1197 let n = (self.body_rows() / 2).max(1) as i64;
1198 self.scroll_lines(-n, src, idx);
1199 }
1200
1201 pub fn goto_top(&mut self) {
1202 self.top_line = 0;
1203 self.top_row = 0;
1204 }
1205
1206 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1207 idx.extend_to_end(src);
1208 let body = self.body_rows() as usize;
1209 if self.hide_mode() {
1210 self.extend_visible_lines(idx, src);
1211 let total = self.visible_lines.len();
1212 let target_visible = total.saturating_sub(body);
1213 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1214 self.top_row = 0;
1215 } else {
1216 let total = idx.line_count();
1217 self.top_line = total.saturating_sub(body);
1218 self.top_row = 0;
1219 }
1220 }
1221
1222 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1224 idx.extend_to_line(n, src);
1225 let target = n.min(idx.line_count().saturating_sub(1));
1226 self.top_line = target;
1227 self.top_row = 0;
1228 }
1229
1230 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1232 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1236 idx.extend_to_end(src);
1237 }
1238 if idx.record_count() == 0 {
1239 return;
1240 }
1241 let target = n.min(idx.record_count().saturating_sub(1));
1242 let line_range = idx.record_line_range(target);
1243 self.top_line = line_range.start;
1244 self.top_row = 0;
1245 }
1246
1247 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1250 let p = p.min(100) as usize;
1251 let target_byte = src.len().saturating_mul(p) / 100;
1252 idx.extend_to_byte_for_query(src, target_byte);
1253 let line_n = idx.line_at_byte(target_byte)
1254 .or_else(|| {
1255 let lc = idx.line_count();
1257 if lc > 0 { Some(lc - 1) } else { None }
1258 })
1259 .unwrap_or(0);
1260 self.top_line = line_n;
1261 self.top_row = 0;
1262 }
1263
1264 pub fn top_line(&self) -> usize {
1266 self.top_line
1267 }
1268
1269 pub fn resize(&mut self, cols: u16, rows: u16) {
1270 self.cols = cols.max(1);
1271 self.rows = rows.max(2);
1272 self.opts.cols = self.cols;
1273 }
1274
1275 pub fn toggle_line_numbers(&mut self) {
1276 self.show_line_numbers = !self.show_line_numbers;
1277 }
1278
1279 pub fn toggle_chop(&mut self) {
1280 self.opts.wrap = !self.opts.wrap;
1281 }
1282
1283 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use crate::source::MockSource;
1293
1294 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1295 let m = MockSource::new();
1296 m.append(content);
1297 m.finish();
1298 let idx = LineIndex::new();
1299 (m, idx)
1300 }
1301
1302 #[test]
1303 fn frame_renders_body_height_rows() {
1304 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1305 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1307 assert_eq!(frame.body.len(), 4);
1308 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1309 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1310 }
1311
1312 #[test]
1313 fn scroll_down_advances_top_line() {
1314 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1315 let mut v = Viewport::new(10, 5, "test".into());
1316 v.scroll_lines(2, &m, &mut idx);
1317 assert_eq!(v.top_line, 2);
1318 assert_eq!(v.top_row, 0);
1319 }
1320
1321 #[test]
1322 fn scroll_up_clamps_at_zero() {
1323 let (m, mut idx) = setup(b"a\nb\nc\n");
1324 let mut v = Viewport::new(10, 5, "test".into());
1325 v.scroll_lines(-5, &m, &mut idx);
1326 assert_eq!(v.top_line, 0);
1327 assert_eq!(v.top_row, 0);
1328 }
1329
1330 #[test]
1331 fn scroll_down_clamps_at_last_line() {
1332 let (m, mut idx) = setup(b"a\nb\nc\n");
1333 let mut v = Viewport::new(10, 5, "test".into());
1334 v.scroll_lines(50, &m, &mut idx);
1335 assert_eq!(v.top_line, 2);
1336 }
1337
1338 #[test]
1339 fn scroll_logical_lines_skips_wrap_rows() {
1340 let mut content = vec![b'X'; 500];
1342 content.push(b'\n');
1343 content.extend_from_slice(b"second\n");
1344 content.extend_from_slice(b"third\n");
1345 let (m, mut idx) = setup(&content);
1346 let mut v = Viewport::new(10, 8, "f".into());
1347 v.scroll_logical_lines(1, &m, &mut idx);
1348 assert_eq!((v.top_line, v.top_row), (1, 0));
1349 v.scroll_logical_lines(1, &m, &mut idx);
1350 assert_eq!((v.top_line, v.top_row), (2, 0));
1351 }
1352
1353 #[test]
1354 fn scroll_logical_lines_back_snaps_to_line_start() {
1355 let mut content = vec![b'A'; 50];
1357 content.push(b'\n');
1358 content.extend_from_slice(&[b'B'; 50]);
1359 content.push(b'\n');
1360 let (m, mut idx) = setup(&content);
1361 let mut v = Viewport::new(10, 8, "f".into());
1362 v.scroll_lines(7, &m, &mut idx);
1363 assert_eq!(v.top_line, 1, "should be on line 1");
1364 assert!(v.top_row > 0, "should be inside line 1's wraps");
1365 v.scroll_logical_lines(-1, &m, &mut idx);
1366 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1367 v.scroll_logical_lines(-1, &m, &mut idx);
1368 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1369 }
1370
1371 #[test]
1372 fn scroll_down_walks_wraps_of_last_line() {
1373 let mut content = b"first\n".to_vec();
1375 content.extend_from_slice(&[b'X'; 30]);
1376 content.push(b'\n');
1377 let (m, mut idx) = setup(&content);
1378 let mut v = Viewport::new(10, 5, "f".into());
1379 v.scroll_lines(1, &m, &mut idx);
1380 assert_eq!((v.top_line, v.top_row), (1, 0));
1381 v.scroll_lines(1, &m, &mut idx);
1382 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1383 v.scroll_lines(1, &m, &mut idx);
1384 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1385 }
1386
1387 #[test]
1388 fn scroll_down_walks_wrap_rows_within_long_line() {
1389 let mut content = vec![b'X'; 30];
1391 content.push(b'\n');
1392 content.extend_from_slice(b"second\n");
1393 let (m, mut idx) = setup(&content);
1394 let mut v = Viewport::new(10, 5, "f".into());
1395 v.scroll_lines(1, &m, &mut idx);
1396 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1397 v.scroll_lines(1, &m, &mut idx);
1398 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1399 v.scroll_lines(1, &m, &mut idx);
1400 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1401 }
1402
1403 #[test]
1404 fn status_line_shows_range_and_pct() {
1405 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1406 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1408 assert!(frame.status.starts_with("f 1-4/10"));
1409 }
1410
1411 #[test]
1412 fn page_down_advances_by_body_rows() {
1413 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1414 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1416 assert_eq!(v.top_line, 4);
1417 }
1418
1419 #[test]
1420 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1421 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1422 let mut v = Viewport::new(10, 5, "f".into());
1423 v.page_down(&m, &mut idx);
1424 v.page_up(&m, &mut idx);
1425 assert_eq!(v.top_line, 0);
1426 assert_eq!(v.top_row, 0);
1427 }
1428
1429 #[test]
1430 fn half_page_down_advances_by_half_body() {
1431 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1432 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1434 assert_eq!(v.top_line, 3);
1435 }
1436
1437 #[test]
1438 fn goto_top_resets_position() {
1439 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1440 let mut v = Viewport::new(10, 5, "f".into());
1441 v.scroll_lines(2, &m, &mut idx);
1442 v.goto_top();
1443 assert_eq!(v.top_line, 0);
1444 assert_eq!(v.top_row, 0);
1445 }
1446
1447 #[test]
1448 fn goto_bottom_scrolls_to_last_page() {
1449 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1450 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1452 assert_eq!(v.top_line, 6);
1454 }
1455
1456 #[test]
1457 fn goto_line_positions_top_line() {
1458 let m = MockSource::new();
1459 m.append(b"a\nb\nc\nd\ne\n");
1460 let mut idx = LineIndex::new();
1461 idx.extend_to_end(&m);
1462 let mut v = Viewport::new(20, 5, "f".into());
1463 v.goto_line(3, &m, &mut idx);
1464 assert_eq!(v.top_line(), 3);
1465 }
1466
1467 #[test]
1468 fn goto_line_clamps_to_last_line() {
1469 let m = MockSource::new();
1470 m.append(b"a\nb\n");
1471 let mut idx = LineIndex::new();
1472 idx.extend_to_end(&m);
1473 let mut v = Viewport::new(20, 5, "f".into());
1474 v.goto_line(999, &m, &mut idx);
1475 assert_eq!(v.top_line(), 1);
1476 }
1477
1478 #[test]
1479 fn goto_record_positions_at_record_start_line() {
1480 let m = MockSource::new();
1481 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1482 let mut idx = LineIndex::new();
1483 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1484 idx.extend_to_end(&m);
1485 let mut v = Viewport::new(20, 5, "f".into());
1486 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1488 }
1489
1490 #[test]
1491 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1492 let m = MockSource::new();
1493 m.append(b"a\nb\nc\n");
1494 let mut idx = LineIndex::new();
1495 idx.extend_to_end(&m);
1496 let mut v = Viewport::new(20, 5, "f".into());
1497 v.goto_record(2, &m, &mut idx);
1498 assert_eq!(v.top_line(), 2);
1499 }
1500
1501 #[test]
1502 fn goto_percent_50_lands_in_middle() {
1503 let m = MockSource::new();
1504 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1506 idx.extend_to_end(&m);
1507 let mut v = Viewport::new(20, 5, "f".into());
1508 v.goto_percent(50, &m, &mut idx);
1509 assert_eq!(v.top_line(), 2); }
1511
1512 #[test]
1513 fn goto_percent_100_lands_at_last_line() {
1514 let m = MockSource::new();
1515 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1517 idx.extend_to_end(&m);
1518 let mut v = Viewport::new(20, 5, "f".into());
1519 v.goto_percent(100, &m, &mut idx);
1520 assert_eq!(v.top_line(), 2);
1521 }
1522
1523 #[test]
1524 fn goto_percent_0_lands_at_first_line() {
1525 let m = MockSource::new();
1526 m.append(b"a\nb\nc\n");
1527 let mut idx = LineIndex::new();
1528 idx.extend_to_end(&m);
1529 let mut v = Viewport::new(20, 5, "f".into());
1530 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1532 v.goto_percent(0, &m, &mut idx);
1533 assert_eq!(v.top_line(), 0);
1534 }
1535
1536 #[test]
1537 fn resize_updates_dimensions_and_render_opts() {
1538 let (m, mut idx) = setup(b"1\n2\n");
1539 let mut v = Viewport::new(10, 5, "f".into());
1540 v.resize(40, 12);
1541 assert_eq!(v.cols, 40);
1542 assert_eq!(v.rows, 12);
1543 assert_eq!(v.opts.cols, 40);
1544 let _ = v.frame(&m, &mut idx);
1545 }
1546
1547 #[test]
1548 fn toggle_line_numbers_changes_gutter() {
1549 let (m, mut idx) = setup(b"a\nb\nc\n");
1550 let mut v = Viewport::new(10, 5, "f".into());
1551 let frame_off = v.frame(&m, &mut idx);
1552 v.toggle_line_numbers();
1553 let frame_on = v.frame(&m, &mut idx);
1554 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1556 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1557 }
1558
1559 #[test]
1560 fn toggle_chop_changes_wrap_mode() {
1561 let (m, mut idx) = setup(b"abcdefghij\n");
1562 let mut v = Viewport::new(4, 5, "f".into());
1563 v.toggle_chop();
1564 let frame = v.frame(&m, &mut idx);
1565 assert_eq!(frame.body[0][..4],
1568 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1569 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1570 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1571 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1572 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1574 }
1575
1576 #[test]
1579 fn is_at_bottom_initially_only_when_source_fits() {
1580 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1583 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1584 }
1585
1586 #[test]
1587 fn is_at_bottom_false_when_top_and_more_lines_below() {
1588 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);
1591 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1592 }
1593
1594 #[test]
1595 fn is_at_bottom_true_after_goto_bottom() {
1596 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1597 let mut v = Viewport::new(10, 5, "f".into());
1598 v.goto_bottom(&m, &mut idx);
1599 assert!(v.is_at_bottom(&idx));
1600 }
1601
1602 #[test]
1603 fn status_shows_follow_suffix_when_follow_mode_on() {
1604 let (m, mut idx) = setup(b"a\nb\n");
1605 let mut v = Viewport::new(20, 5, "f".into());
1606 let frame_off = v.frame(&m, &mut idx);
1607 assert!(!frame_off.status.contains("(F)"));
1608 v.set_follow_mode(true);
1609 let frame_on = v.frame(&m, &mut idx);
1610 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1611 }
1612
1613 #[test]
1614 fn toggle_follow_flips_state() {
1615 let mut v = Viewport::new(10, 5, "f".into());
1616 assert!(!v.follow_mode());
1617 v.toggle_follow();
1618 assert!(v.follow_mode());
1619 v.toggle_follow();
1620 assert!(!v.follow_mode());
1621 }
1622
1623 #[test]
1624 fn status_shows_prettify_label_when_set() {
1625 let (m, mut idx) = setup(b"a\n");
1626 let mut v = Viewport::new(40, 5, "f".into());
1627 let frame_off = v.frame(&m, &mut idx);
1628 assert!(!frame_off.status.contains("[pretty"));
1629 v.set_prettify_label(Some("json".into()));
1630 let frame_on = v.frame(&m, &mut idx);
1631 assert!(frame_on.status.contains("[pretty:json]"),
1632 "expected [pretty:json] in status, got: {}", frame_on.status);
1633 v.set_prettify_label(Some("json:err".into()));
1634 let frame_err = v.frame(&m, &mut idx);
1635 assert!(frame_err.status.contains("[pretty:json:err]"),
1636 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1637 }
1638
1639 #[test]
1640 fn status_shows_l_suffix_when_live_mode_on() {
1641 let (m, mut idx) = setup(b"a\nb\n");
1642 let mut v = Viewport::new(20, 5, "f".into());
1643 let frame_off = v.frame(&m, &mut idx);
1644 assert!(!frame_off.status.contains("(L)"));
1645 v.set_live_mode(true);
1646 let frame_on = v.frame(&m, &mut idx);
1647 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1648 }
1649
1650 #[test]
1651 fn clamp_top_line_pulls_back_when_total_shrinks() {
1652 let mut v = Viewport::new(20, 5, "f".into());
1653 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1662 let (m, mut idx) = setup(b"only\n");
1664 let _ = v.frame(&m, &mut idx);
1665 }
1666
1667 fn simulate_growth_tick(
1670 v: &mut Viewport,
1671 src: &MockSource,
1672 idx: &mut LineIndex,
1673 ) {
1674 if !v.follow_mode() { return; }
1675 let was_at_bottom = v.is_at_bottom(idx);
1676 let lines_before = idx.line_count();
1677 idx.notice_new_bytes(src);
1678 if idx.line_count() != lines_before && was_at_bottom {
1679 v.goto_bottom(src, idx);
1680 }
1681 }
1682
1683 #[test]
1684 fn auto_scroll_engages_when_at_bottom() {
1685 let m = MockSource::new();
1686 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1688 let mut v = Viewport::new(10, 5, "f".into());
1689 v.set_follow_mode(true);
1690 idx.extend_to_end(&m);
1691 assert!(v.is_at_bottom(&idx));
1692 let top_before = {
1693 let f = v.frame(&m, &mut idx);
1694 f.status.clone() };
1696 let _ = top_before;
1697 m.append(b"5\n6\n7\n8\n");
1699 simulate_growth_tick(&mut v, &m, &mut idx);
1700 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1702 let frame = v.frame(&m, &mut idx);
1703 let last_row = &frame.body[frame.body.len() - 1];
1706 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1707 }
1708
1709 #[test]
1710 fn auto_scroll_suppressed_when_scrolled_up() {
1711 let m = MockSource::new();
1712 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1714 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1716 idx.extend_to_end(&m);
1717 v.goto_bottom(&m, &mut idx);
1718 v.scroll_lines(-2, &m, &mut idx);
1720 assert!(!v.is_at_bottom(&idx));
1721 let frame_before = v.frame(&m, &mut idx);
1722 let top_first_cell_before = frame_before.body[0][0].clone();
1723 m.append(b"9\n10\n");
1725 simulate_growth_tick(&mut v, &m, &mut idx);
1726 let frame_after = v.frame(&m, &mut idx);
1728 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1729 }
1730
1731 #[test]
1734 fn set_search_compiles_regex() {
1735 let mut v = Viewport::new(10, 5, "f".into());
1736 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1737 assert!(v.search_active());
1738 }
1739
1740 #[test]
1741 fn set_search_rejects_bad_regex() {
1742 let mut v = Viewport::new(10, 5, "f".into());
1743 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1744 assert!(!err.is_empty());
1745 assert!(!v.search_active(), "no search should be set on error");
1746 }
1747
1748 #[test]
1749 fn search_step_forward_finds_match_after_top() {
1750 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1751 let mut v = Viewport::new(20, 5, "f".into());
1752 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1753 let found = v.search_repeat(&m, &mut idx, false);
1754 assert!(found);
1755 assert_eq!(v.top_line, 2);
1757 }
1758
1759 #[test]
1760 fn search_step_backward_finds_match_before_top() {
1761 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1762 let mut v = Viewport::new(20, 5, "f".into());
1763 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1765 let found = v.search_repeat(&m, &mut idx, false);
1766 assert!(found);
1767 assert_eq!(v.top_line, 0);
1768 }
1769
1770 #[test]
1771 fn search_wraps_at_end() {
1772 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1773 let mut v = Viewport::new(20, 5, "f".into());
1774 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1776 let found = v.search_repeat(&m, &mut idx, false);
1777 assert!(found, "search should wrap forward past EOF");
1778 assert_eq!(v.top_line, 0);
1779 }
1780
1781 #[test]
1782 fn search_no_match_returns_false_and_does_not_move() {
1783 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1784 let mut v = Viewport::new(20, 5, "f".into());
1785 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1786 let found = v.search_repeat(&m, &mut idx, false);
1787 assert!(!found);
1788 assert_eq!(v.top_line, 0);
1789 }
1790
1791 #[test]
1792 fn frame_records_highlight_ranges_for_matches() {
1793 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1794 let mut v = Viewport::new(20, 5, "f".into());
1795 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1796 let frame = v.frame(&m, &mut idx);
1797 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1799 assert!(frame.highlights[0].is_empty());
1800 assert!(frame.highlights[1].is_empty());
1801 assert_eq!(frame.highlights[2], vec![0..5]);
1802 assert!(frame.highlights[3].is_empty());
1803 }
1804
1805 #[test]
1806 fn frame_highlights_substring_inside_a_row() {
1807 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1808 let mut v = Viewport::new(40, 5, "f".into());
1809 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1810 let frame = v.frame(&m, &mut idx);
1811 assert_eq!(frame.highlights[0], vec![18..22]);
1813 assert!(frame.highlights[1].is_empty());
1814 }
1815
1816 #[test]
1817 fn search_highlight_with_filter_dim_keeps_row_dim() {
1818 let (m, mut idx) = setup(b"alpha\nbeta\n");
1821 let mut v = Viewport::new(20, 5, "f".into());
1822 let fmt = crate::format::LogFormat::compile(
1823 "simple",
1824 r"^(?P<line>.+)$",
1825 )
1826 .unwrap();
1827 let f = crate::filter::CompiledFilter::compile(
1828 &fmt,
1829 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1830 )
1831 .unwrap();
1832 v.set_filter(Some(f));
1833 v.set_dim_mode(true);
1834 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1835 let frame = v.frame(&m, &mut idx);
1836 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1837 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1838 assert_eq!(frame.highlights[1], vec![0..4]);
1839 }
1840
1841 #[test]
1842 fn grep_only_hides_non_matching_lines() {
1843 use crate::grep::GrepPredicate;
1844 let src = crate::source::MockSource::new();
1845 src.append(b"keep this error\n");
1846 src.append(b"drop this one\n");
1847 src.append(b"another error line\n");
1848 src.finish();
1849 let mut idx = crate::line_index::LineIndex::new();
1850 idx.extend_to_end(&src);
1851
1852 let mut v = Viewport::new(40, 5, "test".into());
1853 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1854 v.extend_visible_lines(&idx, &src);
1855
1856 let frame = v.frame(&src, &mut idx);
1858 let body_text: Vec<String> = frame.body.iter()
1859 .map(|row| row.iter().filter_map(|c| match c {
1860 crate::render::Cell::Char { ch, .. } => Some(*ch),
1861 _ => None,
1862 }).collect())
1863 .collect();
1864 assert!(body_text[0].contains("keep this error"));
1865 assert!(body_text[1].contains("another error line"));
1866 assert!(frame.status.contains("[grep]"));
1867 }
1868
1869 #[test]
1870 fn filter_and_grep_combine_with_and() {
1871 use crate::grep::GrepPredicate;
1872 let fmt = crate::format::LogFormat::compile(
1873 "simple",
1874 r"^(?P<level>\w+) (?P<msg>.+)$",
1875 ).unwrap();
1876 let f = crate::filter::CompiledFilter::compile(
1877 &fmt,
1878 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1879 ).unwrap();
1880 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1881
1882 let src = crate::source::MockSource::new();
1883 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();
1888 let mut idx = crate::line_index::LineIndex::new();
1889 idx.extend_to_end(&src);
1890
1891 let mut v = Viewport::new(80, 5, "test".into());
1892 v.set_filter(Some(f));
1893 v.set_grep(Some(g));
1894 v.extend_visible_lines(&idx, &src);
1895 assert_eq!(v.visible_lines(), &[0usize]);
1896 }
1897
1898 #[test]
1899 fn search_status_shows_pattern() {
1900 let (m, mut idx) = setup(b"x\n");
1901 let mut v = Viewport::new(20, 5, "f".into());
1902 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1903 let frame = v.frame(&m, &mut idx);
1904 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1905 }
1906
1907 #[test]
1908 fn repeat_search_after_first_match_advances() {
1909 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1910 let mut v = Viewport::new(40, 5, "f".into());
1911 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1912 assert!(v.search_repeat(&m, &mut idx, false));
1913 assert_eq!(v.top_line, 1, "first foo");
1914 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1915 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1916 assert_eq!(v.top_line, 3, "should advance to next foo");
1917 }
1918
1919 #[test]
1920 fn auto_scroll_paused_when_follow_off() {
1921 let m = MockSource::new();
1922 m.append(b"1\n2\n3\n4\n");
1923 let mut idx = LineIndex::new();
1924 let mut v = Viewport::new(10, 5, "f".into());
1925 idx.extend_to_end(&m);
1927 let frame_before = v.frame(&m, &mut idx);
1928 let top_first_cell = frame_before.body[0][0].clone();
1929 m.append(b"5\n6\n7\n8\n");
1930 simulate_growth_tick(&mut v, &m, &mut idx);
1931 let frame_after = v.frame(&m, &mut idx);
1932 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1933 }
1934
1935 #[test]
1938 fn search_jumps_to_next_matching_record() {
1939 let m = MockSource::new();
1940 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1941 let mut idx = LineIndex::new();
1942 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1943 idx.extend_to_end(&m);
1944 let mut v = Viewport::new(40, 10, "f".into());
1945 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1946 let hit = v.search_repeat(&m, &mut idx, false);
1947 assert!(hit, "should find 'charlie' in record 2");
1948 assert_eq!(v.top_line(), 3); }
1950
1951 #[test]
1952 fn search_finds_cross_line_match_in_record_with_s_flag() {
1953 let m = MockSource::new();
1954 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1955 let mut idx = LineIndex::new();
1956 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1957 idx.extend_to_end(&m);
1958 let mut v = Viewport::new(40, 10, "f".into());
1959 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1960 let hit = v.search_repeat(&m, &mut idx, false);
1961 assert!(hit, "should match across \\n inside record 0 with (?s)");
1962 assert_eq!(v.top_line(), 0);
1963 }
1964
1965 #[test]
1966 fn search_repeat_with_no_match_returns_false() {
1967 let m = MockSource::new();
1968 m.append(b"[1] alpha\n[2] bravo\n");
1969 let mut idx = LineIndex::new();
1970 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1971 idx.extend_to_end(&m);
1972 let mut v = Viewport::new(40, 10, "f".into());
1973 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1974 let hit = v.search_repeat(&m, &mut idx, false);
1975 assert!(!hit);
1976 }
1977
1978 #[test]
1981 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1982 let m = MockSource::new();
1985 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1986 let mut idx = LineIndex::new();
1987 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1988 idx.extend_to_end(&m);
1989 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1990 let mut v = Viewport::new(40, 10, "f".into());
1991 v.set_grep(Some(grep));
1992 v.extend_visible_lines(&idx, &m);
1993 assert_eq!(v.visible_lines(), &[0usize, 1]);
1996 }
1997
1998 #[test]
1999 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2000 let m = MockSource::new();
2006 m.append(
2007 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
2008 );
2009 let mut idx = LineIndex::new();
2010 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2011 idx.extend_to_end(&m);
2012 let fmt = crate::format::LogFormat::compile(
2013 "rec",
2014 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2015 )
2016 .unwrap();
2017 let f = crate::filter::CompiledFilter::compile(
2018 &fmt,
2019 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2020 )
2021 .unwrap();
2022 let mut v = Viewport::new(40, 10, "f".into());
2023 v.set_filter(Some(f));
2024 v.extend_visible_lines(&idx, &m);
2025 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2027 }
2028
2029 #[test]
2030 fn grep_matches_across_record_newlines_in_records_mode() {
2031 let m = MockSource::new();
2033 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2034 let mut idx = LineIndex::new();
2035 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2036 idx.extend_to_end(&m);
2037 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
2038 let mut v = Viewport::new(40, 10, "f".into());
2039 v.set_grep(Some(grep));
2040 v.extend_visible_lines(&idx, &m);
2041 assert_eq!(v.visible_lines(), &[0usize, 1]);
2043 }
2044
2045 #[test]
2046 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2047 let m = MockSource::new();
2050 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2051 let mut idx = LineIndex::new();
2052 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2053 idx.extend_to_end(&m);
2054 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
2055 let mut v = Viewport::new(40, 10, "f".into());
2056 v.set_grep(Some(grep));
2057 v.set_dim_mode(true);
2058 v.extend_visible_lines(&idx, &m);
2059 assert_eq!(v.visible_lines(), &[] as &[usize]);
2061 assert!(!v.should_dim_line(0, &idx, &m));
2063 assert!(!v.should_dim_line(1, &idx, &m));
2064 assert!(v.should_dim_line(2, &idx, &m));
2066 assert!(v.should_dim_line(3, &idx, &m));
2067 }
2068
2069 #[test]
2070 fn status_unchanged_when_records_inactive() {
2071 let (m, mut idx) = setup(b"a\nb\nc\n");
2072 let mut v = Viewport::new(20, 5, "f".into());
2073 let frame = v.frame(&m, &mut idx);
2074 let status = &frame.status;
2075 assert!(status.contains("1-3/3"), "got: {status}");
2077 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2078 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2079 }
2080
2081 #[test]
2082 fn status_r_block_uses_real_lines_in_hide_mode() {
2083 let m = MockSource::new();
2092 let mut buf = Vec::new();
2095 for n in 0..10 {
2096 let kind = if n >= 8 { "B" } else { "A" };
2097 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
2098 }
2099 m.append(&buf);
2100 m.finish();
2101
2102 let mut idx = LineIndex::new();
2103 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2104 idx.extend_to_end(&m);
2105
2106 let fmt = crate::format::LogFormat::compile(
2107 "rec",
2108 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2109 )
2110 .unwrap();
2111 let f = crate::filter::CompiledFilter::compile(
2112 &fmt,
2113 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2114 )
2115 .unwrap();
2116
2117 let mut v = Viewport::new(80, 5, "f".into());
2120 v.set_filter(Some(f));
2121 v.extend_visible_lines(&idx, &m);
2122
2123 v.goto_record(8, &m, &mut idx);
2125
2126 let frame = v.frame(&m, &mut idx);
2127 assert!(
2129 frame.status.contains("R9-10/10"),
2130 "expected R9-10/10 in status, got: {}",
2131 frame.status,
2132 );
2133 }
2134
2135 #[test]
2136 fn status_dual_readout_when_records_active() {
2137 let m = MockSource::new();
2138 m.append(b"[1] a\n cont\n[2] b\n");
2139 m.finish();
2140 let mut idx = LineIndex::new();
2141 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2142 idx.extend_to_end(&m);
2143 let mut v = Viewport::new(20, 5, "f".into());
2144 let frame = v.frame(&m, &mut idx);
2145 let status = &frame.status;
2146 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2147 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2148 }
2149
2150 #[test]
2151 fn format_status_uses_custom_template_when_set() {
2152 let m = MockSource::new();
2153 m.append(b"a\nb\nc\n");
2154 m.finish();
2155 let mut idx = LineIndex::new();
2156 idx.extend_to_end(&m);
2157 let mut v = Viewport::new(20, 5, "f".into());
2158 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2159 v.set_prompt(Some(prompt));
2160 let frame = v.frame(&m, &mut idx);
2161 assert_eq!(frame.status, "f 100%");
2162 }
2163
2164 #[test]
2165 fn status_shows_preprocess_failed_tag_when_set() {
2166 let m = MockSource::new();
2167 m.append(b"a\n");
2168 let mut idx = LineIndex::new();
2169 idx.extend_to_end(&m);
2170 let mut v = Viewport::new(40, 5, "f".into());
2171 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2172 let frame = v.frame(&m, &mut idx);
2173 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2174 "got: {}", frame.status);
2175 }
2176
2177 #[test]
2178 fn default_status_includes_help_hint() {
2179 let (m, mut idx) = setup(b"a\nb\nc\n");
2180 let mut v = Viewport::new(80, 5, "f".into());
2181 let frame = v.frame(&m, &mut idx);
2182 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2183 }
2184
2185 #[test]
2186 fn custom_prompt_does_not_get_help_hint() {
2187 let (m, mut idx) = setup(b"a\nb\nc\n");
2188 let mut v = Viewport::new(80, 5, "f".into());
2189 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2190 let frame = v.frame(&m, &mut idx);
2191 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2192 }
2193
2194 #[test]
2195 fn status_shows_file_index_when_multifile() {
2196 let m = MockSource::new();
2197 m.append(b"a\n");
2198 let mut idx = LineIndex::new();
2199 idx.extend_to_end(&m);
2200 let mut v = Viewport::new(60, 5, "f.log".into());
2201 v.set_file_index(0, 3);
2202 let frame = v.frame(&m, &mut idx);
2203 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2204 }
2205
2206 #[test]
2207 fn status_omits_file_index_when_single_file() {
2208 let m = MockSource::new();
2209 m.append(b"a\n");
2210 let mut idx = LineIndex::new();
2211 idx.extend_to_end(&m);
2212 let mut v = Viewport::new(60, 5, "f.log".into());
2213 v.set_file_index(0, 1);
2214 let frame = v.frame(&m, &mut idx);
2215 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2216 }
2217
2218 #[test]
2219 fn status_shows_tag_active_when_multimatch() {
2220 let m = MockSource::new();
2221 m.append(b"a\n");
2222 let mut idx = LineIndex::new();
2223 idx.extend_to_end(&m);
2224 let mut v = Viewport::new(80, 5, "f.log".into());
2225 v.set_tag_active(Some(("foo".into(), 2, 3)));
2226 let frame = v.frame(&m, &mut idx);
2227 assert!(
2228 frame.status.contains("[tag: foo (2/3)]"),
2229 "got: {}",
2230 frame.status
2231 );
2232 }
2233
2234 #[test]
2235 fn status_omits_tag_active_when_single_match() {
2236 let m = MockSource::new();
2237 m.append(b"a\n");
2238 let mut idx = LineIndex::new();
2239 idx.extend_to_end(&m);
2240 let mut v = Viewport::new(80, 5, "f.log".into());
2241 v.set_tag_active(Some(("foo".into(), 1, 1)));
2242 let frame = v.frame(&m, &mut idx);
2243 assert!(
2244 !frame.status.contains("[tag:"),
2245 "should not show indicator for single match: {}",
2246 frame.status
2247 );
2248 }
2249
2250 #[test]
2253 fn reconstruct_picks_up_state_from_prior_lines() {
2254 let m = MockSource::new();
2255 m.append(b"\x1b[31mline 1\n");
2256 m.append(b"line 2 (still red, no reset)\n");
2257 m.append(b"line 3\n");
2258 let mut idx = LineIndex::new();
2259 idx.extend_to_end(&m);
2260 let state = reconstruct_render_state(&m, &idx, 2);
2261 assert_eq!(
2262 state.style.fg,
2263 Some(crate::ansi::Color::Ansi(1)),
2264 "red SGR from line 0 should persist to line 2"
2265 );
2266 }
2267
2268 #[test]
2269 fn reconstruct_respects_reset_between_lines() {
2270 let m = MockSource::new();
2271 m.append(b"\x1b[31mline 1\x1b[0m\n");
2272 m.append(b"line 2 (default)\n");
2273 let mut idx = LineIndex::new();
2274 idx.extend_to_end(&m);
2275 let state = reconstruct_render_state(&m, &idx, 1);
2276 assert_eq!(state.style.fg, None);
2277 }
2278
2279 #[test]
2280 fn reconstruct_caps_walkback_at_max_lines() {
2281 let m = MockSource::new();
2282 m.append(b"\x1b[31mvery early\n");
2283 for _ in 0..300 {
2284 m.append(b"line\n");
2285 }
2286 let mut idx = LineIndex::new();
2287 idx.extend_to_end(&m);
2288 let state = reconstruct_render_state(&m, &idx, 290);
2291 assert_eq!(state.style.fg, None);
2292 }
2293}