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 prompt: Option<crate::prompt::ParsedPrompt>,
173 preprocess_failure: Option<String>,
176 file_index: Option<(usize, usize)>,
178 tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
182 render_state: crate::render::RenderState,
186 render_state_for: usize,
189}
190
191impl Viewport {
192 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
193 let opts = RenderOpts { cols, ..RenderOpts::default() };
194 Self {
195 top_line: 0,
196 top_row: 0,
197 cols,
198 rows,
199 opts,
200 show_line_numbers: false,
201 source_label,
202 follow_mode: false,
203 live_mode: false,
204 prettify_label: None,
205 format_label: None,
206 filter: None,
207 grep: None,
208 dim_mode: false,
209 visible_lines: Vec::new(),
210 visible_scanned: 0,
211 search: None,
212 display: None,
213 hex_mode: false,
214 prompt: None,
215 preprocess_failure: None,
216 file_index: None,
217 tag_active: None,
218 ansi_mode: crate::render::AnsiMode::Strict,
219 render_state: crate::render::RenderState::default(),
220 render_state_for: usize::MAX,
221 }
222 }
223
224 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
225 self.display = renderer;
226 }
227
228 pub fn set_hex_mode(&mut self, on: bool) {
229 self.hex_mode = on;
230 }
231
232 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
233 self.prompt = prompt;
234 }
235
236 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
237 self.preprocess_failure = msg;
238 }
239
240 pub fn set_file_index(&mut self, current: usize, total: usize) {
241 self.file_index = if total > 1 {
242 Some((current, total))
243 } else {
244 None
245 };
246 }
247
248 pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
249 self.tag_active = info;
250 }
251
252 pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
253 self.ansi_mode = mode;
254 }
255
256 pub fn set_source_label(&mut self, label: String) {
257 self.source_label = label;
258 }
259
260 pub fn source_label_clone(&self) -> String {
261 self.source_label.clone()
262 }
263
264 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
269 let range = idx.line_range(line_n, src);
270 let raw = src.bytes(range);
271 if let Some(r) = self.display.as_ref() {
272 if let Some(rendered) = r.render_line(&raw) {
273 return std::borrow::Cow::Owned(rendered.into_bytes());
274 }
275 }
276 raw
277 }
278
279 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
283 let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
284 self.search = Some(SearchState { raw, regex, direction });
285 Ok(())
286 }
287
288 pub fn clear_search(&mut self) { self.search = None; }
289
290 pub fn search_active(&self) -> bool { self.search.is_some() }
291
292 pub fn search_direction(&self) -> SearchDirection {
293 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
294 }
295
296 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
300 if idx.records_mode() {
301 self.search_repeat_records(src, idx, reverse)
302 } else {
303 self.search_repeat_lines(src, idx, reverse)
304 }
305 }
306
307 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
309 let Some(s) = self.search.as_ref() else { return false; };
310 let forward = matches!(
311 (s.direction, reverse),
312 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
313 );
314 idx.extend_to_end(src);
315 let pattern = s.regex.clone();
316 if self.hide_mode() {
317 self.extend_visible_lines(idx, src);
318 self.search_step_in_visible(&pattern, src, idx, forward)
319 } else {
320 self.search_step_in_logical(&pattern, src, idx, forward)
321 }
322 }
323
324 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
328 let Some(s) = self.search.as_ref() else { return false; };
329 let forward = matches!(
330 (s.direction, reverse),
331 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
332 );
333 let pattern = s.regex.clone();
334 idx.extend_to_end(src);
335
336 let total = idx.record_count();
337 if total == 0 { return false; }
338
339 let cur_record = idx.line_to_record(self.top_line);
340
341 let range: Box<dyn Iterator<Item = usize>> = if forward {
342 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
343 } else {
344 let earlier: Vec<usize> = (0..cur_record).rev().collect();
345 let later: Vec<usize> = (cur_record..total).rev().collect();
346 Box::new(earlier.into_iter().chain(later))
347 };
348
349 for r in range {
350 let bytes = idx.record_bytes_stripped(r, src);
351 let text = String::from_utf8_lossy(&bytes);
352 if pattern.is_match(&text) {
353 let line_range = idx.record_line_range(r);
354 self.top_line = line_range.start;
355 self.top_row = 0;
356 return true;
357 }
358 }
359 false
360 }
361
362 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
363 let display = self.line_display_bytes(src, idx, line_n);
368 let bytes = crate::ansi::strip_sgr(&display);
369 match std::str::from_utf8(&bytes) {
370 Ok(s) => pattern.is_match(s),
371 Err(_) => false,
372 }
373 }
374
375 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
376 let total = idx.line_count();
377 if total == 0 { return false; }
378 let start = self.top_line;
379 for offset in 1..=total {
382 let line_n = if forward {
383 (start + offset) % total
384 } else {
385 (start + total - offset) % total
386 };
387 if self.line_matches(pattern, src, idx, line_n) {
388 self.top_line = line_n;
389 self.top_row = 0;
390 return true;
391 }
392 }
393 false
394 }
395
396 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
397 let total = self.visible_lines.len();
398 if total == 0 { return false; }
399 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
401 for offset in 1..=total {
402 let visible_idx = if forward {
403 (cur + offset) % total
404 } else {
405 (cur + total - offset) % total
406 };
407 let line_n = self.visible_lines[visible_idx];
408 if self.line_matches(pattern, src, idx, line_n) {
409 self.top_line = line_n;
410 self.top_row = 0;
411 return true;
412 }
413 }
414 false
415 }
416
417 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
418 self.filter = filter;
419 self.visible_lines.clear();
420 self.visible_scanned = 0;
421 self.top_line = 0;
423 self.top_row = 0;
424 }
425
426 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
427 self.grep = grep;
428 self.visible_lines.clear();
429 self.visible_scanned = 0;
430 self.top_line = 0;
431 self.top_row = 0;
432 }
433
434 pub fn grep_active(&self) -> bool { self.grep.is_some() }
435
436 pub fn set_dim_mode(&mut self, on: bool) {
437 self.dim_mode = on;
438 self.visible_lines.clear();
442 self.visible_scanned = 0;
443 }
444
445 pub fn filter_active(&self) -> bool { self.filter.is_some() }
446
447 pub fn dim_mode(&self) -> bool { self.dim_mode }
448
449 fn hide_mode(&self) -> bool {
450 (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
451 }
452
453 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
458 if !self.hide_mode() {
459 return;
460 }
461 if idx.records_mode() {
462 self.extend_visible_lines_records(idx, src);
463 } else {
464 self.extend_visible_lines_per_line(idx, src);
465 }
466 }
467
468 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
470 let total = idx.line_count();
471 while self.visible_scanned < total {
472 let line_n = self.visible_scanned;
473 let bytes = idx.line_bytes_stripped(line_n, src);
474 if self.line_passes(&bytes) {
475 self.visible_lines.push(line_n);
476 }
477 self.visible_scanned += 1;
478 }
479 }
480
481 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
488 self.visible_lines.clear();
489 self.visible_scanned = 0; let total_records = idx.record_count();
491 for r in 0..total_records {
492 if self.record_passes(idx, src, r) {
493 for line_n in idx.record_line_range(r) {
494 self.visible_lines.push(line_n);
495 }
496 }
497 }
498 }
499
500 fn line_passes(&self, line: &[u8]) -> bool {
506 let filter_ok = match self.filter.as_ref() {
507 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
508 None => true,
509 };
510 let grep_ok = match self.grep.as_ref() {
511 Some(g) => g.matches(line),
512 None => true,
513 };
514 filter_ok && grep_ok
515 }
516
517 fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
525 let bytes = if self.filter.is_some() || self.grep.is_some() {
526 Some(idx.record_bytes_stripped(r, src))
527 } else {
528 None
529 };
530 let filter_ok = match self.filter.as_ref() {
531 Some(f) => matches!(
532 f.evaluate_record(bytes.as_deref().unwrap()),
533 FilterMatch::Matched,
534 ),
535 None => true,
536 };
537 let grep_ok = match self.grep.as_ref() {
538 Some(g) => g.matches(bytes.as_deref().unwrap()),
539 None => true,
540 };
541 filter_ok && grep_ok
542 }
543
544 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
548 if !self.dim_mode {
549 return false;
550 }
551 if idx.records_mode() {
552 let r = idx.line_to_record(line_n);
553 !self.record_passes(idx, src, r)
554 } else {
555 let bytes = idx.line_bytes_stripped(line_n, src);
556 !self.line_passes(&bytes)
557 }
558 }
559
560 fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
568 let body_rows = self.body_rows() as usize;
569 if self.hide_mode() && !self.visible_lines.is_empty() {
570 let cur = self
571 .visible_lines
572 .iter()
573 .position(|&l| l >= self.top_line)
574 .unwrap_or(self.visible_lines.len().saturating_sub(1));
575 let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
576 return self.visible_lines[last_pos];
577 }
578 let total = idx.line_count();
579 if total == 0 {
580 return self.top_line;
581 }
582 (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
583 }
584
585 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
586
587 pub fn follow_mode(&self) -> bool { self.follow_mode }
588
589 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
590
591 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
592
593 pub fn live_mode(&self) -> bool { self.live_mode }
594
595 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
596
597 pub fn set_prettify_label(&mut self, label: Option<String>) {
600 self.prettify_label = label;
601 }
602
603 pub fn set_format_label(&mut self, label: Option<String>) {
606 self.format_label = label;
607 }
608
609 pub fn invalidate_filter_cache(&mut self) {
614 self.visible_lines.clear();
615 self.visible_scanned = 0;
616 }
617
618 pub fn clamp_top_line(&mut self, line_count: usize) {
621 if line_count == 0 {
622 self.top_line = 0;
623 self.top_row = 0;
624 } else if self.top_line >= line_count {
625 self.top_line = line_count - 1;
626 self.top_row = 0;
627 }
628 }
629
630 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
634 let body = self.body_rows() as usize;
635 if self.hide_mode() {
636 let pos = self
638 .visible_lines
639 .iter()
640 .position(|&l| l >= self.top_line)
641 .unwrap_or(self.visible_lines.len());
642 pos + body >= self.visible_lines.len()
643 } else {
644 self.top_line + body >= idx.line_count()
645 }
646 }
647
648 fn gutter_width(&self, idx: &LineIndex) -> u16 {
650 if !self.show_line_numbers { return 0; }
651 let n = idx.line_count().max(1);
652 let digits = (n as f64).log10().floor() as u16 + 1;
653 digits + 1
654 }
655
656 fn render_opts(&self, gutter: u16) -> RenderOpts {
657 let mut o = self.opts.clone();
658 o.cols = self.cols.saturating_sub(gutter);
659 o.mode = self.ansi_mode;
660 o
661 }
662
663 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
664 if self.hex_mode {
665 return self.frame_hex(src);
666 }
667 let body_rows = self.body_rows() as usize;
668 idx.extend_to_line(self.top_line + body_rows + 1, src);
669
670 let gutter = self.gutter_width(idx);
671 let r_opts = self.render_opts(gutter);
672
673 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
677 reconstruct_render_state(src, idx, self.top_line)
678 } else {
679 crate::render::RenderState::default()
680 };
681 self.render_state = render_state.clone();
683 self.render_state_for = self.top_line;
684
685 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
686 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
687 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
688 let hide = self.hide_mode();
690 let total_lines = idx.line_count();
691
692 let mut hide_pos = if hide {
694 self.visible_lines
695 .iter()
696 .position(|&l| l >= self.top_line)
697 .unwrap_or(self.visible_lines.len())
698 } else {
699 0
700 };
701 let mut line_n = if hide {
702 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
703 } else {
704 self.top_line
705 };
706 let mut skip = if hide { 0 } else { self.top_row };
707
708 while body.len() < body_rows {
709 if line_n >= total_lines {
710 let mut row = Vec::with_capacity(self.cols as usize);
711 if gutter > 0 {
712 for _ in 0..gutter { row.push(Cell::Empty); }
713 }
714 while row.len() < self.cols as usize { row.push(Cell::Empty); }
715 body.push(row);
716 row_styles.push(RowStyle::Normal);
717 highlights.push(Vec::new());
718 line_n += 1;
719 continue;
720 }
721 let raw = src.bytes(idx.line_range(line_n, src));
724 let display_bytes = if let Some(r) = self.display.as_ref() {
725 match r.render_line(&raw) {
726 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
727 None => raw.clone(),
728 }
729 } else {
730 raw.clone()
731 };
732 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
733 Some(&mut render_state)
734 } else {
735 None
736 };
737 let rows = render_line(&display_bytes, &r_opts, state_arg);
738 let style = if self.filter.is_some() || self.grep.is_some() {
739 if self.dim_mode {
740 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
741 } else {
742 RowStyle::Normal
744 }
745 } else {
746 RowStyle::Normal
747 };
748
749 for (i, mut content_row) in rows.into_iter().enumerate() {
750 if i < skip { continue; }
751 if body.len() >= body_rows { break; }
752 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
753 if gutter > 0 {
754 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
755 for c in label.chars() {
756 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
757 }
758 }
759 full.append(&mut content_row);
760 let row_highlights = if let Some(s) = self.search.as_ref() {
764 find_row_highlights(&full, &s.regex)
765 } else {
766 Vec::new()
767 };
768 body.push(full);
769 row_styles.push(style);
770 highlights.push(row_highlights);
771 }
772 skip = 0;
773 if hide {
775 hide_pos += 1;
776 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
777 } else {
778 line_n += 1;
779 }
780 }
781
782 self.render_state_for = usize::MAX;
785
786 let status = self.format_status(idx, src);
787 Frame { body, row_styles, highlights, status }
788 }
789
790 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
791 if let Some(p) = self.prompt.as_ref() {
792 let ctx = self.build_prompt_context(idx, src);
793 return p.render(&ctx);
794 }
795 let body_rows = self.body_rows() as usize;
796 let total = idx.line_count();
797 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
800 let visible_total = self.visible_lines.len();
801 let cur = self
803 .visible_lines
804 .iter()
805 .position(|&l| l >= self.top_line)
806 .unwrap_or(visible_total);
807 let top = cur + 1;
808 let bottom = (cur + body_rows).min(visible_total.max(1));
809 let total_str = if src.is_complete() {
810 format!("{visible_total}/{total}")
811 } else {
812 format!("{visible_total}/{total}+")
813 };
814 (top, bottom, visible_total, total_str)
815 } else {
816 let top = self.top_line + 1;
817 let bottom = (self.top_line + body_rows).min(total.max(1));
818 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
819 (top, bottom, total, total_str)
820 };
821 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
822 let bottom_line = self.bottom_visible_line(idx);
826 let (line_prefix, records_block) = if idx.records_mode() {
827 let line_total = idx.line_count();
828 let rec_total = idx.record_count();
829 let rec_block = if line_total == 0 || rec_total == 0 {
830 format!("R0-0/{}", rec_total)
831 } else {
832 let rec_top = idx.line_to_record(self.top_line) + 1;
833 let rec_bottom = idx.line_to_record(bottom_line) + 1;
834 let (rec_top, rec_bottom) = if rec_bottom < rec_top {
835 (rec_top, rec_top)
839 } else {
840 (rec_top, rec_bottom)
841 };
842 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
843 };
844 ("L", Some(rec_block))
845 } else {
846 ("", None)
847 };
848 let middle = match records_block {
849 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
850 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
851 };
852 let label_with_index = match self.file_index {
853 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
854 None => self.source_label.clone(),
855 };
856 let mut s = format!("{} {}", label_with_index, middle);
857 if !self.hide_mode() && self.top_row > 0 {
862 let line_rows = if total > 0 {
863 let bytes = self.line_display_bytes(src, idx, self.top_line);
864 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
865 } else { 1 };
866 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
867 }
868 if let Some(f) = self.filter.as_ref() {
869 s.push_str(&format!(" [{}]", f.format_name));
870 }
871 if self.grep.is_some() {
872 s.push_str(" [grep]");
873 }
874 if self.filter.is_some() || self.grep.is_some() {
875 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
876 }
877 if let Some(sr) = self.search.as_ref() {
878 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
879 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
880 }
881 if let Some(label) = self.prettify_label.as_ref() {
882 s.push_str(&format!(" [pretty:{label}]"));
883 }
884 if self.live_mode { s.push_str(" (L)"); }
885 if self.follow_mode { s.push_str(" (F)"); }
886 if let Some(msg) = self.preprocess_failure.as_ref() {
887 let first_line = msg.lines().next().unwrap_or("");
888 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
889 }
890 let tag_suffix = match &self.tag_active {
891 Some((name, cur, total)) if *total > 1 => {
892 format!(" [tag: {name} ({cur}/{total})]")
893 }
894 _ => String::new(),
895 };
896 s.push_str(&tag_suffix);
897 s
898 }
899
900 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
901 use crate::prompt::PromptContext;
902
903 let body_rows = self.body_rows() as usize;
904 let total = idx.line_count();
905 let top = self.top_line + 1;
906 let bottom = (self.top_line + body_rows).min(total.max(1));
907 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
908 let bottom_line = self.bottom_visible_line(idx);
909
910 let records_mode = idx.records_mode();
911 let (rec_top, rec_bottom, rec_total) = if records_mode {
912 let rt = idx.line_to_record(self.top_line) + 1;
913 let rb_raw = idx.line_to_record(bottom_line) + 1;
914 let rb = if rb_raw < rt { rt } else { rb_raw };
915 (rt, rb, idx.record_count())
916 } else {
917 (0, 0, 0)
918 };
919
920 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
921 let line_rows = if total > 0 {
922 let bytes = self.line_display_bytes(src, idx, self.top_line);
923 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
924 } else { 1 };
925 format!("+{}/{}", self.top_row, line_rows)
926 } else {
927 String::new()
928 };
929
930 let format_tag = self.format_label.as_ref()
931 .map(|n| format!(" [{}]", n))
932 .unwrap_or_default();
933 let filter_tag = self.filter.as_ref()
934 .map(|f| format!(" [{}]", f.format_name))
935 .unwrap_or_default();
936 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
937 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
938 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
939 } else {
940 String::new()
941 };
942 let search_tag = self.search.as_ref()
943 .map(|s| {
944 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
945 format!(" [{}{}]", p, s.raw)
946 })
947 .unwrap_or_default();
948 let pretty_tag = self.prettify_label.as_ref()
949 .map(|l| format!(" [pretty:{l}]"))
950 .unwrap_or_default();
951 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
952 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
953 let preprocess_failed_tag = self.preprocess_failure.as_ref()
954 .map(|msg| {
955 let first_line = msg.lines().next().unwrap_or("");
956 format!(" [preprocess-failed: {}]", first_line)
957 })
958 .unwrap_or_default();
959
960 let file_index_tag = match self.file_index {
961 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
962 None => String::new(),
963 };
964
965 let tag_tag = match &self.tag_active {
966 Some((name, cur, total)) if *total > 1 => {
967 format!(" [tag: {name} ({cur}/{total})]")
968 }
969 _ => String::new(),
970 };
971
972 PromptContext {
973 label: self.source_label.clone(),
974 top,
975 bottom,
976 total,
977 pct: pct.min(100) as u8,
978 rec_top,
979 rec_bottom,
980 rec_total,
981 records_mode,
982 wrap_offset,
983 format_tag,
984 filter_tag,
985 grep_tag,
986 hide_tag,
987 search_tag,
988 pretty_tag,
989 live_tag,
990 follow_tag,
991 preprocess_failed_tag,
992 file_index_tag,
993 tag_tag,
994 }
995 }
996
997 fn frame_hex(&self, src: &dyn Source) -> Frame {
998 use crate::hex::format_hex_row;
999 use crate::render::{render_line, Cell, RenderOpts};
1000
1001 let body_rows = self.rows.saturating_sub(1) as usize;
1002 let total_bytes = src.len();
1003 let total_hex_rows = total_bytes.div_ceil(16);
1004
1005 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1006 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1007 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1008
1009 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
1010
1011 for row_idx in 0..body_rows {
1012 let hex_row = self.top_line + row_idx;
1013 if hex_row >= total_hex_rows {
1014 body.push(vec![Cell::Empty; self.cols as usize]);
1015 } else {
1016 let offset = hex_row * 16;
1017 let end = (offset + 16).min(total_bytes);
1018 let bytes_cow = src.bytes(offset..end);
1019 let text = format_hex_row(offset, &bytes_cow);
1020 let rows = render_line(text.as_bytes(), &opts, None);
1021 body.push(rows.into_iter().next().unwrap_or_else(|| {
1022 vec![Cell::Empty; self.cols as usize]
1023 }));
1024 }
1025 row_styles.push(RowStyle::Normal);
1026 highlights.push(Vec::new());
1027 }
1028
1029 let status = self.format_status_hex(src);
1030 Frame { body, row_styles, highlights, status }
1031 }
1032
1033 fn format_status_hex(&self, src: &dyn Source) -> String {
1034 let total_bytes = src.len();
1035 let body_rows = self.rows.saturating_sub(1) as usize;
1036 let top_byte = self.top_line * 16;
1038 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1041 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1042 let label_with_index = match self.file_index {
1043 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1044 None => self.source_label.clone(),
1045 };
1046 let tag_suffix = match &self.tag_active {
1047 Some((name, cur, total)) if *total > 1 => {
1048 format!(" [tag: {name} ({cur}/{total})]")
1049 }
1050 _ => String::new(),
1051 };
1052 format!(
1053 "{} off {}-{}/{} {}% [hex]{}",
1054 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1055 )
1056 }
1057
1058 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1063 if delta == 0 { return; }
1064 if self.hide_mode() {
1065 self.scroll_lines(delta, src, idx);
1066 return;
1067 }
1068 if delta > 0 {
1069 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1070 let total = idx.line_count();
1071 if total == 0 { return; }
1072 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1073 self.top_line = target;
1074 self.top_row = 0;
1075 } else {
1076 let back = (-delta) as usize;
1077 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1082 let extra_back = back.saturating_sub(consumed_for_snap);
1083 self.top_line = self.top_line.saturating_sub(extra_back);
1084 self.top_row = 0;
1085 }
1086 }
1087
1088 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1089 if delta == 0 { return; }
1090 if self.hide_mode() {
1091 self.extend_visible_lines(idx, src);
1095 let total = self.visible_lines.len();
1096 if total == 0 {
1097 self.top_line = 0;
1098 self.top_row = 0;
1099 return;
1100 }
1101 let cur = self
1102 .visible_lines
1103 .iter()
1104 .position(|&l| l >= self.top_line)
1105 .unwrap_or(total);
1106 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1107 self.top_line = self.visible_lines[new];
1108 self.top_row = 0;
1109 return;
1110 }
1111 if delta > 0 {
1112 let mut remaining = delta as usize;
1113 while remaining > 0 {
1114 idx.extend_to_line(self.top_line + 1, src);
1115 let total = idx.line_count();
1116 if total == 0 { break; }
1117 let bytes = self.line_display_bytes(src, idx, self.top_line);
1118 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1119 if self.top_row + 1 < line_rows {
1120 self.top_row += 1;
1121 } else if self.top_line + 1 < total {
1122 self.top_row = 0;
1123 self.top_line += 1;
1124 } else {
1125 break;
1126 }
1127 remaining -= 1;
1128 }
1129 } else {
1130 let mut remaining = (-delta) as usize;
1131 while remaining > 0 {
1132 if self.top_row > 0 {
1133 self.top_row -= 1;
1134 } else if self.top_line > 0 {
1135 self.top_line -= 1;
1136 let bytes = self.line_display_bytes(src, idx, self.top_line);
1137 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1138 self.top_row = line_rows.saturating_sub(1);
1139 } else {
1140 break;
1141 }
1142 remaining -= 1;
1143 }
1144 }
1145 }
1146
1147 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1148 let n = self.body_rows() as i64;
1149 self.scroll_lines(n, src, idx);
1150 }
1151
1152 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1153 let n = self.body_rows() as i64;
1154 self.scroll_lines(-n, src, idx);
1155 }
1156
1157 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1158 let n = (self.body_rows() / 2).max(1) as i64;
1159 self.scroll_lines(n, src, idx);
1160 }
1161
1162 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1163 let n = (self.body_rows() / 2).max(1) as i64;
1164 self.scroll_lines(-n, src, idx);
1165 }
1166
1167 pub fn goto_top(&mut self) {
1168 self.top_line = 0;
1169 self.top_row = 0;
1170 }
1171
1172 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1173 idx.extend_to_end(src);
1174 let body = self.body_rows() as usize;
1175 if self.hide_mode() {
1176 self.extend_visible_lines(idx, src);
1177 let total = self.visible_lines.len();
1178 let target_visible = total.saturating_sub(body);
1179 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1180 self.top_row = 0;
1181 } else {
1182 let total = idx.line_count();
1183 self.top_line = total.saturating_sub(body);
1184 self.top_row = 0;
1185 }
1186 }
1187
1188 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1190 idx.extend_to_line(n, src);
1191 let target = n.min(idx.line_count().saturating_sub(1));
1192 self.top_line = target;
1193 self.top_row = 0;
1194 }
1195
1196 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1198 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1202 idx.extend_to_end(src);
1203 }
1204 if idx.record_count() == 0 {
1205 return;
1206 }
1207 let target = n.min(idx.record_count().saturating_sub(1));
1208 let line_range = idx.record_line_range(target);
1209 self.top_line = line_range.start;
1210 self.top_row = 0;
1211 }
1212
1213 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1216 let p = p.min(100) as usize;
1217 let target_byte = src.len().saturating_mul(p) / 100;
1218 idx.extend_to_byte_for_query(src, target_byte);
1219 let line_n = idx.line_at_byte(target_byte)
1220 .or_else(|| {
1221 let lc = idx.line_count();
1223 if lc > 0 { Some(lc - 1) } else { None }
1224 })
1225 .unwrap_or(0);
1226 self.top_line = line_n;
1227 self.top_row = 0;
1228 }
1229
1230 pub fn top_line(&self) -> usize {
1232 self.top_line
1233 }
1234
1235 pub fn resize(&mut self, cols: u16, rows: u16) {
1236 self.cols = cols.max(1);
1237 self.rows = rows.max(2);
1238 self.opts.cols = self.cols;
1239 }
1240
1241 pub fn toggle_line_numbers(&mut self) {
1242 self.show_line_numbers = !self.show_line_numbers;
1243 }
1244
1245 pub fn toggle_chop(&mut self) {
1246 self.opts.wrap = !self.opts.wrap;
1247 }
1248
1249 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use crate::source::MockSource;
1259
1260 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1261 let m = MockSource::new();
1262 m.append(content);
1263 m.finish();
1264 let idx = LineIndex::new();
1265 (m, idx)
1266 }
1267
1268 #[test]
1269 fn frame_renders_body_height_rows() {
1270 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1271 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1273 assert_eq!(frame.body.len(), 4);
1274 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1275 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1276 }
1277
1278 #[test]
1279 fn scroll_down_advances_top_line() {
1280 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1281 let mut v = Viewport::new(10, 5, "test".into());
1282 v.scroll_lines(2, &m, &mut idx);
1283 assert_eq!(v.top_line, 2);
1284 assert_eq!(v.top_row, 0);
1285 }
1286
1287 #[test]
1288 fn scroll_up_clamps_at_zero() {
1289 let (m, mut idx) = setup(b"a\nb\nc\n");
1290 let mut v = Viewport::new(10, 5, "test".into());
1291 v.scroll_lines(-5, &m, &mut idx);
1292 assert_eq!(v.top_line, 0);
1293 assert_eq!(v.top_row, 0);
1294 }
1295
1296 #[test]
1297 fn scroll_down_clamps_at_last_line() {
1298 let (m, mut idx) = setup(b"a\nb\nc\n");
1299 let mut v = Viewport::new(10, 5, "test".into());
1300 v.scroll_lines(50, &m, &mut idx);
1301 assert_eq!(v.top_line, 2);
1302 }
1303
1304 #[test]
1305 fn scroll_logical_lines_skips_wrap_rows() {
1306 let mut content = vec![b'X'; 500];
1308 content.push(b'\n');
1309 content.extend_from_slice(b"second\n");
1310 content.extend_from_slice(b"third\n");
1311 let (m, mut idx) = setup(&content);
1312 let mut v = Viewport::new(10, 8, "f".into());
1313 v.scroll_logical_lines(1, &m, &mut idx);
1314 assert_eq!((v.top_line, v.top_row), (1, 0));
1315 v.scroll_logical_lines(1, &m, &mut idx);
1316 assert_eq!((v.top_line, v.top_row), (2, 0));
1317 }
1318
1319 #[test]
1320 fn scroll_logical_lines_back_snaps_to_line_start() {
1321 let mut content = vec![b'A'; 50];
1323 content.push(b'\n');
1324 content.extend_from_slice(&[b'B'; 50]);
1325 content.push(b'\n');
1326 let (m, mut idx) = setup(&content);
1327 let mut v = Viewport::new(10, 8, "f".into());
1328 v.scroll_lines(7, &m, &mut idx);
1329 assert_eq!(v.top_line, 1, "should be on line 1");
1330 assert!(v.top_row > 0, "should be inside line 1's wraps");
1331 v.scroll_logical_lines(-1, &m, &mut idx);
1332 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1333 v.scroll_logical_lines(-1, &m, &mut idx);
1334 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1335 }
1336
1337 #[test]
1338 fn scroll_down_walks_wraps_of_last_line() {
1339 let mut content = b"first\n".to_vec();
1341 content.extend_from_slice(&[b'X'; 30]);
1342 content.push(b'\n');
1343 let (m, mut idx) = setup(&content);
1344 let mut v = Viewport::new(10, 5, "f".into());
1345 v.scroll_lines(1, &m, &mut idx);
1346 assert_eq!((v.top_line, v.top_row), (1, 0));
1347 v.scroll_lines(1, &m, &mut idx);
1348 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1349 v.scroll_lines(1, &m, &mut idx);
1350 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1351 }
1352
1353 #[test]
1354 fn scroll_down_walks_wrap_rows_within_long_line() {
1355 let mut content = vec![b'X'; 30];
1357 content.push(b'\n');
1358 content.extend_from_slice(b"second\n");
1359 let (m, mut idx) = setup(&content);
1360 let mut v = Viewport::new(10, 5, "f".into());
1361 v.scroll_lines(1, &m, &mut idx);
1362 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1363 v.scroll_lines(1, &m, &mut idx);
1364 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1365 v.scroll_lines(1, &m, &mut idx);
1366 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1367 }
1368
1369 #[test]
1370 fn status_line_shows_range_and_pct() {
1371 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1372 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1374 assert!(frame.status.starts_with("f 1-4/10"));
1375 }
1376
1377 #[test]
1378 fn page_down_advances_by_body_rows() {
1379 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1380 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1382 assert_eq!(v.top_line, 4);
1383 }
1384
1385 #[test]
1386 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1387 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1388 let mut v = Viewport::new(10, 5, "f".into());
1389 v.page_down(&m, &mut idx);
1390 v.page_up(&m, &mut idx);
1391 assert_eq!(v.top_line, 0);
1392 assert_eq!(v.top_row, 0);
1393 }
1394
1395 #[test]
1396 fn half_page_down_advances_by_half_body() {
1397 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1398 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1400 assert_eq!(v.top_line, 3);
1401 }
1402
1403 #[test]
1404 fn goto_top_resets_position() {
1405 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1406 let mut v = Viewport::new(10, 5, "f".into());
1407 v.scroll_lines(2, &m, &mut idx);
1408 v.goto_top();
1409 assert_eq!(v.top_line, 0);
1410 assert_eq!(v.top_row, 0);
1411 }
1412
1413 #[test]
1414 fn goto_bottom_scrolls_to_last_page() {
1415 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1416 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1418 assert_eq!(v.top_line, 6);
1420 }
1421
1422 #[test]
1423 fn goto_line_positions_top_line() {
1424 let m = MockSource::new();
1425 m.append(b"a\nb\nc\nd\ne\n");
1426 let mut idx = LineIndex::new();
1427 idx.extend_to_end(&m);
1428 let mut v = Viewport::new(20, 5, "f".into());
1429 v.goto_line(3, &m, &mut idx);
1430 assert_eq!(v.top_line(), 3);
1431 }
1432
1433 #[test]
1434 fn goto_line_clamps_to_last_line() {
1435 let m = MockSource::new();
1436 m.append(b"a\nb\n");
1437 let mut idx = LineIndex::new();
1438 idx.extend_to_end(&m);
1439 let mut v = Viewport::new(20, 5, "f".into());
1440 v.goto_line(999, &m, &mut idx);
1441 assert_eq!(v.top_line(), 1);
1442 }
1443
1444 #[test]
1445 fn goto_record_positions_at_record_start_line() {
1446 let m = MockSource::new();
1447 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1448 let mut idx = LineIndex::new();
1449 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1450 idx.extend_to_end(&m);
1451 let mut v = Viewport::new(20, 5, "f".into());
1452 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1454 }
1455
1456 #[test]
1457 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1458 let m = MockSource::new();
1459 m.append(b"a\nb\nc\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_record(2, &m, &mut idx);
1464 assert_eq!(v.top_line(), 2);
1465 }
1466
1467 #[test]
1468 fn goto_percent_50_lands_in_middle() {
1469 let m = MockSource::new();
1470 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1472 idx.extend_to_end(&m);
1473 let mut v = Viewport::new(20, 5, "f".into());
1474 v.goto_percent(50, &m, &mut idx);
1475 assert_eq!(v.top_line(), 2); }
1477
1478 #[test]
1479 fn goto_percent_100_lands_at_last_line() {
1480 let m = MockSource::new();
1481 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1483 idx.extend_to_end(&m);
1484 let mut v = Viewport::new(20, 5, "f".into());
1485 v.goto_percent(100, &m, &mut idx);
1486 assert_eq!(v.top_line(), 2);
1487 }
1488
1489 #[test]
1490 fn goto_percent_0_lands_at_first_line() {
1491 let m = MockSource::new();
1492 m.append(b"a\nb\nc\n");
1493 let mut idx = LineIndex::new();
1494 idx.extend_to_end(&m);
1495 let mut v = Viewport::new(20, 5, "f".into());
1496 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1498 v.goto_percent(0, &m, &mut idx);
1499 assert_eq!(v.top_line(), 0);
1500 }
1501
1502 #[test]
1503 fn resize_updates_dimensions_and_render_opts() {
1504 let (m, mut idx) = setup(b"1\n2\n");
1505 let mut v = Viewport::new(10, 5, "f".into());
1506 v.resize(40, 12);
1507 assert_eq!(v.cols, 40);
1508 assert_eq!(v.rows, 12);
1509 assert_eq!(v.opts.cols, 40);
1510 let _ = v.frame(&m, &mut idx);
1511 }
1512
1513 #[test]
1514 fn toggle_line_numbers_changes_gutter() {
1515 let (m, mut idx) = setup(b"a\nb\nc\n");
1516 let mut v = Viewport::new(10, 5, "f".into());
1517 let frame_off = v.frame(&m, &mut idx);
1518 v.toggle_line_numbers();
1519 let frame_on = v.frame(&m, &mut idx);
1520 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1522 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1523 }
1524
1525 #[test]
1526 fn toggle_chop_changes_wrap_mode() {
1527 let (m, mut idx) = setup(b"abcdefghij\n");
1528 let mut v = Viewport::new(4, 5, "f".into());
1529 v.toggle_chop();
1530 let frame = v.frame(&m, &mut idx);
1531 assert_eq!(frame.body[0][..4],
1534 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1535 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1536 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1537 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1538 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1540 }
1541
1542 #[test]
1545 fn is_at_bottom_initially_only_when_source_fits() {
1546 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1549 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1550 }
1551
1552 #[test]
1553 fn is_at_bottom_false_when_top_and_more_lines_below() {
1554 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);
1557 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1558 }
1559
1560 #[test]
1561 fn is_at_bottom_true_after_goto_bottom() {
1562 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1563 let mut v = Viewport::new(10, 5, "f".into());
1564 v.goto_bottom(&m, &mut idx);
1565 assert!(v.is_at_bottom(&idx));
1566 }
1567
1568 #[test]
1569 fn status_shows_follow_suffix_when_follow_mode_on() {
1570 let (m, mut idx) = setup(b"a\nb\n");
1571 let mut v = Viewport::new(20, 5, "f".into());
1572 let frame_off = v.frame(&m, &mut idx);
1573 assert!(!frame_off.status.contains("(F)"));
1574 v.set_follow_mode(true);
1575 let frame_on = v.frame(&m, &mut idx);
1576 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1577 }
1578
1579 #[test]
1580 fn toggle_follow_flips_state() {
1581 let mut v = Viewport::new(10, 5, "f".into());
1582 assert!(!v.follow_mode());
1583 v.toggle_follow();
1584 assert!(v.follow_mode());
1585 v.toggle_follow();
1586 assert!(!v.follow_mode());
1587 }
1588
1589 #[test]
1590 fn status_shows_prettify_label_when_set() {
1591 let (m, mut idx) = setup(b"a\n");
1592 let mut v = Viewport::new(40, 5, "f".into());
1593 let frame_off = v.frame(&m, &mut idx);
1594 assert!(!frame_off.status.contains("[pretty"));
1595 v.set_prettify_label(Some("json".into()));
1596 let frame_on = v.frame(&m, &mut idx);
1597 assert!(frame_on.status.contains("[pretty:json]"),
1598 "expected [pretty:json] in status, got: {}", frame_on.status);
1599 v.set_prettify_label(Some("json:err".into()));
1600 let frame_err = v.frame(&m, &mut idx);
1601 assert!(frame_err.status.contains("[pretty:json:err]"),
1602 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1603 }
1604
1605 #[test]
1606 fn status_shows_l_suffix_when_live_mode_on() {
1607 let (m, mut idx) = setup(b"a\nb\n");
1608 let mut v = Viewport::new(20, 5, "f".into());
1609 let frame_off = v.frame(&m, &mut idx);
1610 assert!(!frame_off.status.contains("(L)"));
1611 v.set_live_mode(true);
1612 let frame_on = v.frame(&m, &mut idx);
1613 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1614 }
1615
1616 #[test]
1617 fn clamp_top_line_pulls_back_when_total_shrinks() {
1618 let mut v = Viewport::new(20, 5, "f".into());
1619 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1628 let (m, mut idx) = setup(b"only\n");
1630 let _ = v.frame(&m, &mut idx);
1631 }
1632
1633 fn simulate_growth_tick(
1636 v: &mut Viewport,
1637 src: &MockSource,
1638 idx: &mut LineIndex,
1639 ) {
1640 if !v.follow_mode() { return; }
1641 let was_at_bottom = v.is_at_bottom(idx);
1642 let lines_before = idx.line_count();
1643 idx.notice_new_bytes(src);
1644 if idx.line_count() != lines_before && was_at_bottom {
1645 v.goto_bottom(src, idx);
1646 }
1647 }
1648
1649 #[test]
1650 fn auto_scroll_engages_when_at_bottom() {
1651 let m = MockSource::new();
1652 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1654 let mut v = Viewport::new(10, 5, "f".into());
1655 v.set_follow_mode(true);
1656 idx.extend_to_end(&m);
1657 assert!(v.is_at_bottom(&idx));
1658 let top_before = {
1659 let f = v.frame(&m, &mut idx);
1660 f.status.clone() };
1662 let _ = top_before;
1663 m.append(b"5\n6\n7\n8\n");
1665 simulate_growth_tick(&mut v, &m, &mut idx);
1666 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1668 let frame = v.frame(&m, &mut idx);
1669 let last_row = &frame.body[frame.body.len() - 1];
1672 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1673 }
1674
1675 #[test]
1676 fn auto_scroll_suppressed_when_scrolled_up() {
1677 let m = MockSource::new();
1678 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1680 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1682 idx.extend_to_end(&m);
1683 v.goto_bottom(&m, &mut idx);
1684 v.scroll_lines(-2, &m, &mut idx);
1686 assert!(!v.is_at_bottom(&idx));
1687 let frame_before = v.frame(&m, &mut idx);
1688 let top_first_cell_before = frame_before.body[0][0].clone();
1689 m.append(b"9\n10\n");
1691 simulate_growth_tick(&mut v, &m, &mut idx);
1692 let frame_after = v.frame(&m, &mut idx);
1694 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1695 }
1696
1697 #[test]
1700 fn set_search_compiles_regex() {
1701 let mut v = Viewport::new(10, 5, "f".into());
1702 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1703 assert!(v.search_active());
1704 }
1705
1706 #[test]
1707 fn set_search_rejects_bad_regex() {
1708 let mut v = Viewport::new(10, 5, "f".into());
1709 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1710 assert!(!err.is_empty());
1711 assert!(!v.search_active(), "no search should be set on error");
1712 }
1713
1714 #[test]
1715 fn search_step_forward_finds_match_after_top() {
1716 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1717 let mut v = Viewport::new(20, 5, "f".into());
1718 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1719 let found = v.search_repeat(&m, &mut idx, false);
1720 assert!(found);
1721 assert_eq!(v.top_line, 2);
1723 }
1724
1725 #[test]
1726 fn search_step_backward_finds_match_before_top() {
1727 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1728 let mut v = Viewport::new(20, 5, "f".into());
1729 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1731 let found = v.search_repeat(&m, &mut idx, false);
1732 assert!(found);
1733 assert_eq!(v.top_line, 0);
1734 }
1735
1736 #[test]
1737 fn search_wraps_at_end() {
1738 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1739 let mut v = Viewport::new(20, 5, "f".into());
1740 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1742 let found = v.search_repeat(&m, &mut idx, false);
1743 assert!(found, "search should wrap forward past EOF");
1744 assert_eq!(v.top_line, 0);
1745 }
1746
1747 #[test]
1748 fn search_no_match_returns_false_and_does_not_move() {
1749 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1750 let mut v = Viewport::new(20, 5, "f".into());
1751 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1752 let found = v.search_repeat(&m, &mut idx, false);
1753 assert!(!found);
1754 assert_eq!(v.top_line, 0);
1755 }
1756
1757 #[test]
1758 fn frame_records_highlight_ranges_for_matches() {
1759 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1760 let mut v = Viewport::new(20, 5, "f".into());
1761 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1762 let frame = v.frame(&m, &mut idx);
1763 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1765 assert!(frame.highlights[0].is_empty());
1766 assert!(frame.highlights[1].is_empty());
1767 assert_eq!(frame.highlights[2], vec![0..5]);
1768 assert!(frame.highlights[3].is_empty());
1769 }
1770
1771 #[test]
1772 fn frame_highlights_substring_inside_a_row() {
1773 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1774 let mut v = Viewport::new(40, 5, "f".into());
1775 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1776 let frame = v.frame(&m, &mut idx);
1777 assert_eq!(frame.highlights[0], vec![18..22]);
1779 assert!(frame.highlights[1].is_empty());
1780 }
1781
1782 #[test]
1783 fn search_highlight_with_filter_dim_keeps_row_dim() {
1784 let (m, mut idx) = setup(b"alpha\nbeta\n");
1787 let mut v = Viewport::new(20, 5, "f".into());
1788 let fmt = crate::format::LogFormat::compile(
1789 "simple",
1790 r"^(?P<line>.+)$",
1791 )
1792 .unwrap();
1793 let f = crate::filter::CompiledFilter::compile(
1794 &fmt,
1795 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1796 )
1797 .unwrap();
1798 v.set_filter(Some(f));
1799 v.set_dim_mode(true);
1800 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1801 let frame = v.frame(&m, &mut idx);
1802 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1803 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1804 assert_eq!(frame.highlights[1], vec![0..4]);
1805 }
1806
1807 #[test]
1808 fn grep_only_hides_non_matching_lines() {
1809 use crate::grep::GrepPredicate;
1810 let src = crate::source::MockSource::new();
1811 src.append(b"keep this error\n");
1812 src.append(b"drop this one\n");
1813 src.append(b"another error line\n");
1814 src.finish();
1815 let mut idx = crate::line_index::LineIndex::new();
1816 idx.extend_to_end(&src);
1817
1818 let mut v = Viewport::new(40, 5, "test".into());
1819 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1820 v.extend_visible_lines(&idx, &src);
1821
1822 let frame = v.frame(&src, &mut idx);
1824 let body_text: Vec<String> = frame.body.iter()
1825 .map(|row| row.iter().filter_map(|c| match c {
1826 crate::render::Cell::Char { ch, .. } => Some(*ch),
1827 _ => None,
1828 }).collect())
1829 .collect();
1830 assert!(body_text[0].contains("keep this error"));
1831 assert!(body_text[1].contains("another error line"));
1832 assert!(frame.status.contains("[grep]"));
1833 }
1834
1835 #[test]
1836 fn filter_and_grep_combine_with_and() {
1837 use crate::grep::GrepPredicate;
1838 let fmt = crate::format::LogFormat::compile(
1839 "simple",
1840 r"^(?P<level>\w+) (?P<msg>.+)$",
1841 ).unwrap();
1842 let f = crate::filter::CompiledFilter::compile(
1843 &fmt,
1844 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1845 ).unwrap();
1846 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1847
1848 let src = crate::source::MockSource::new();
1849 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();
1854 let mut idx = crate::line_index::LineIndex::new();
1855 idx.extend_to_end(&src);
1856
1857 let mut v = Viewport::new(80, 5, "test".into());
1858 v.set_filter(Some(f));
1859 v.set_grep(Some(g));
1860 v.extend_visible_lines(&idx, &src);
1861 assert_eq!(v.visible_lines(), &[0usize]);
1862 }
1863
1864 #[test]
1865 fn search_status_shows_pattern() {
1866 let (m, mut idx) = setup(b"x\n");
1867 let mut v = Viewport::new(20, 5, "f".into());
1868 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1869 let frame = v.frame(&m, &mut idx);
1870 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1871 }
1872
1873 #[test]
1874 fn repeat_search_after_first_match_advances() {
1875 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1876 let mut v = Viewport::new(40, 5, "f".into());
1877 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1878 assert!(v.search_repeat(&m, &mut idx, false));
1879 assert_eq!(v.top_line, 1, "first foo");
1880 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1881 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1882 assert_eq!(v.top_line, 3, "should advance to next foo");
1883 }
1884
1885 #[test]
1886 fn auto_scroll_paused_when_follow_off() {
1887 let m = MockSource::new();
1888 m.append(b"1\n2\n3\n4\n");
1889 let mut idx = LineIndex::new();
1890 let mut v = Viewport::new(10, 5, "f".into());
1891 idx.extend_to_end(&m);
1893 let frame_before = v.frame(&m, &mut idx);
1894 let top_first_cell = frame_before.body[0][0].clone();
1895 m.append(b"5\n6\n7\n8\n");
1896 simulate_growth_tick(&mut v, &m, &mut idx);
1897 let frame_after = v.frame(&m, &mut idx);
1898 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1899 }
1900
1901 #[test]
1904 fn search_jumps_to_next_matching_record() {
1905 let m = MockSource::new();
1906 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1907 let mut idx = LineIndex::new();
1908 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1909 idx.extend_to_end(&m);
1910 let mut v = Viewport::new(40, 10, "f".into());
1911 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1912 let hit = v.search_repeat(&m, &mut idx, false);
1913 assert!(hit, "should find 'charlie' in record 2");
1914 assert_eq!(v.top_line(), 3); }
1916
1917 #[test]
1918 fn search_finds_cross_line_match_in_record_with_s_flag() {
1919 let m = MockSource::new();
1920 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1921 let mut idx = LineIndex::new();
1922 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1923 idx.extend_to_end(&m);
1924 let mut v = Viewport::new(40, 10, "f".into());
1925 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1926 let hit = v.search_repeat(&m, &mut idx, false);
1927 assert!(hit, "should match across \\n inside record 0 with (?s)");
1928 assert_eq!(v.top_line(), 0);
1929 }
1930
1931 #[test]
1932 fn search_repeat_with_no_match_returns_false() {
1933 let m = MockSource::new();
1934 m.append(b"[1] alpha\n[2] bravo\n");
1935 let mut idx = LineIndex::new();
1936 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1937 idx.extend_to_end(&m);
1938 let mut v = Viewport::new(40, 10, "f".into());
1939 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1940 let hit = v.search_repeat(&m, &mut idx, false);
1941 assert!(!hit);
1942 }
1943
1944 #[test]
1947 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1948 let m = MockSource::new();
1951 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1952 let mut idx = LineIndex::new();
1953 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1954 idx.extend_to_end(&m);
1955 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1956 let mut v = Viewport::new(40, 10, "f".into());
1957 v.set_grep(Some(grep));
1958 v.extend_visible_lines(&idx, &m);
1959 assert_eq!(v.visible_lines(), &[0usize, 1]);
1962 }
1963
1964 #[test]
1965 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
1966 let m = MockSource::new();
1972 m.append(
1973 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
1974 );
1975 let mut idx = LineIndex::new();
1976 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1977 idx.extend_to_end(&m);
1978 let fmt = crate::format::LogFormat::compile(
1979 "rec",
1980 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
1981 )
1982 .unwrap();
1983 let f = crate::filter::CompiledFilter::compile(
1984 &fmt,
1985 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
1986 )
1987 .unwrap();
1988 let mut v = Viewport::new(40, 10, "f".into());
1989 v.set_filter(Some(f));
1990 v.extend_visible_lines(&idx, &m);
1991 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
1993 }
1994
1995 #[test]
1996 fn grep_matches_across_record_newlines_in_records_mode() {
1997 let m = MockSource::new();
1999 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2000 let mut idx = LineIndex::new();
2001 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2002 idx.extend_to_end(&m);
2003 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
2004 let mut v = Viewport::new(40, 10, "f".into());
2005 v.set_grep(Some(grep));
2006 v.extend_visible_lines(&idx, &m);
2007 assert_eq!(v.visible_lines(), &[0usize, 1]);
2009 }
2010
2011 #[test]
2012 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2013 let m = MockSource::new();
2016 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2017 let mut idx = LineIndex::new();
2018 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2019 idx.extend_to_end(&m);
2020 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
2021 let mut v = Viewport::new(40, 10, "f".into());
2022 v.set_grep(Some(grep));
2023 v.set_dim_mode(true);
2024 v.extend_visible_lines(&idx, &m);
2025 assert_eq!(v.visible_lines(), &[] as &[usize]);
2027 assert!(!v.should_dim_line(0, &idx, &m));
2029 assert!(!v.should_dim_line(1, &idx, &m));
2030 assert!(v.should_dim_line(2, &idx, &m));
2032 assert!(v.should_dim_line(3, &idx, &m));
2033 }
2034
2035 #[test]
2036 fn status_unchanged_when_records_inactive() {
2037 let (m, mut idx) = setup(b"a\nb\nc\n");
2038 let mut v = Viewport::new(20, 5, "f".into());
2039 let frame = v.frame(&m, &mut idx);
2040 let status = &frame.status;
2041 assert!(status.contains("1-3/3"), "got: {status}");
2043 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2044 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2045 }
2046
2047 #[test]
2048 fn status_r_block_uses_real_lines_in_hide_mode() {
2049 let m = MockSource::new();
2058 let mut buf = Vec::new();
2061 for n in 0..10 {
2062 let kind = if n >= 8 { "B" } else { "A" };
2063 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
2064 }
2065 m.append(&buf);
2066 m.finish();
2067
2068 let mut idx = LineIndex::new();
2069 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2070 idx.extend_to_end(&m);
2071
2072 let fmt = crate::format::LogFormat::compile(
2073 "rec",
2074 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2075 )
2076 .unwrap();
2077 let f = crate::filter::CompiledFilter::compile(
2078 &fmt,
2079 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2080 )
2081 .unwrap();
2082
2083 let mut v = Viewport::new(80, 5, "f".into());
2086 v.set_filter(Some(f));
2087 v.extend_visible_lines(&idx, &m);
2088
2089 v.goto_record(8, &m, &mut idx);
2091
2092 let frame = v.frame(&m, &mut idx);
2093 assert!(
2095 frame.status.contains("R9-10/10"),
2096 "expected R9-10/10 in status, got: {}",
2097 frame.status,
2098 );
2099 }
2100
2101 #[test]
2102 fn status_dual_readout_when_records_active() {
2103 let m = MockSource::new();
2104 m.append(b"[1] a\n cont\n[2] b\n");
2105 m.finish();
2106 let mut idx = LineIndex::new();
2107 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2108 idx.extend_to_end(&m);
2109 let mut v = Viewport::new(20, 5, "f".into());
2110 let frame = v.frame(&m, &mut idx);
2111 let status = &frame.status;
2112 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2113 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2114 }
2115
2116 #[test]
2117 fn format_status_uses_custom_template_when_set() {
2118 let m = MockSource::new();
2119 m.append(b"a\nb\nc\n");
2120 m.finish();
2121 let mut idx = LineIndex::new();
2122 idx.extend_to_end(&m);
2123 let mut v = Viewport::new(20, 5, "f".into());
2124 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2125 v.set_prompt(Some(prompt));
2126 let frame = v.frame(&m, &mut idx);
2127 assert_eq!(frame.status, "f 100%");
2128 }
2129
2130 #[test]
2131 fn status_shows_preprocess_failed_tag_when_set() {
2132 let m = MockSource::new();
2133 m.append(b"a\n");
2134 let mut idx = LineIndex::new();
2135 idx.extend_to_end(&m);
2136 let mut v = Viewport::new(40, 5, "f".into());
2137 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2138 let frame = v.frame(&m, &mut idx);
2139 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2140 "got: {}", frame.status);
2141 }
2142
2143 #[test]
2144 fn status_shows_file_index_when_multifile() {
2145 let m = MockSource::new();
2146 m.append(b"a\n");
2147 let mut idx = LineIndex::new();
2148 idx.extend_to_end(&m);
2149 let mut v = Viewport::new(60, 5, "f.log".into());
2150 v.set_file_index(0, 3);
2151 let frame = v.frame(&m, &mut idx);
2152 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2153 }
2154
2155 #[test]
2156 fn status_omits_file_index_when_single_file() {
2157 let m = MockSource::new();
2158 m.append(b"a\n");
2159 let mut idx = LineIndex::new();
2160 idx.extend_to_end(&m);
2161 let mut v = Viewport::new(60, 5, "f.log".into());
2162 v.set_file_index(0, 1);
2163 let frame = v.frame(&m, &mut idx);
2164 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2165 }
2166
2167 #[test]
2168 fn status_shows_tag_active_when_multimatch() {
2169 let m = MockSource::new();
2170 m.append(b"a\n");
2171 let mut idx = LineIndex::new();
2172 idx.extend_to_end(&m);
2173 let mut v = Viewport::new(80, 5, "f.log".into());
2174 v.set_tag_active(Some(("foo".into(), 2, 3)));
2175 let frame = v.frame(&m, &mut idx);
2176 assert!(
2177 frame.status.contains("[tag: foo (2/3)]"),
2178 "got: {}",
2179 frame.status
2180 );
2181 }
2182
2183 #[test]
2184 fn status_omits_tag_active_when_single_match() {
2185 let m = MockSource::new();
2186 m.append(b"a\n");
2187 let mut idx = LineIndex::new();
2188 idx.extend_to_end(&m);
2189 let mut v = Viewport::new(80, 5, "f.log".into());
2190 v.set_tag_active(Some(("foo".into(), 1, 1)));
2191 let frame = v.frame(&m, &mut idx);
2192 assert!(
2193 !frame.status.contains("[tag:"),
2194 "should not show indicator for single match: {}",
2195 frame.status
2196 );
2197 }
2198
2199 #[test]
2202 fn reconstruct_picks_up_state_from_prior_lines() {
2203 let m = MockSource::new();
2204 m.append(b"\x1b[31mline 1\n");
2205 m.append(b"line 2 (still red, no reset)\n");
2206 m.append(b"line 3\n");
2207 let mut idx = LineIndex::new();
2208 idx.extend_to_end(&m);
2209 let state = reconstruct_render_state(&m, &idx, 2);
2210 assert_eq!(
2211 state.style.fg,
2212 Some(crate::ansi::Color::Ansi(1)),
2213 "red SGR from line 0 should persist to line 2"
2214 );
2215 }
2216
2217 #[test]
2218 fn reconstruct_respects_reset_between_lines() {
2219 let m = MockSource::new();
2220 m.append(b"\x1b[31mline 1\x1b[0m\n");
2221 m.append(b"line 2 (default)\n");
2222 let mut idx = LineIndex::new();
2223 idx.extend_to_end(&m);
2224 let state = reconstruct_render_state(&m, &idx, 1);
2225 assert_eq!(state.style.fg, None);
2226 }
2227
2228 #[test]
2229 fn reconstruct_caps_walkback_at_max_lines() {
2230 let m = MockSource::new();
2231 m.append(b"\x1b[31mvery early\n");
2232 for _ in 0..300 {
2233 m.append(b"line\n");
2234 }
2235 let mut idx = LineIndex::new();
2236 idx.extend_to_end(&m);
2237 let state = reconstruct_render_state(&m, &idx, 290);
2240 assert_eq!(state.style.fg, None);
2241 }
2242}