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 let used = s.chars().count();
900 let hint = ":help";
901 if (self.cols as usize) > used + 1 + hint.chars().count() {
902 let pad = self.cols as usize - used - hint.chars().count();
903 s.push_str(&" ".repeat(pad));
904 s.push_str(hint);
905 } else {
906 s.push(' ');
907 s.push_str(hint);
908 }
909 s
910 }
911
912 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
913 use crate::prompt::PromptContext;
914
915 let body_rows = self.body_rows() as usize;
916 let total = idx.line_count();
917 let top = self.top_line + 1;
918 let bottom = (self.top_line + body_rows).min(total.max(1));
919 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
920 let bottom_line = self.bottom_visible_line(idx);
921
922 let records_mode = idx.records_mode();
923 let (rec_top, rec_bottom, rec_total) = if records_mode {
924 let rt = idx.line_to_record(self.top_line) + 1;
925 let rb_raw = idx.line_to_record(bottom_line) + 1;
926 let rb = if rb_raw < rt { rt } else { rb_raw };
927 (rt, rb, idx.record_count())
928 } else {
929 (0, 0, 0)
930 };
931
932 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
933 let line_rows = if total > 0 {
934 let bytes = self.line_display_bytes(src, idx, self.top_line);
935 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
936 } else { 1 };
937 format!("+{}/{}", self.top_row, line_rows)
938 } else {
939 String::new()
940 };
941
942 let format_tag = self.format_label.as_ref()
943 .map(|n| format!(" [{}]", n))
944 .unwrap_or_default();
945 let filter_tag = self.filter.as_ref()
946 .map(|f| format!(" [{}]", f.format_name))
947 .unwrap_or_default();
948 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
949 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
950 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
951 } else {
952 String::new()
953 };
954 let search_tag = self.search.as_ref()
955 .map(|s| {
956 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
957 format!(" [{}{}]", p, s.raw)
958 })
959 .unwrap_or_default();
960 let pretty_tag = self.prettify_label.as_ref()
961 .map(|l| format!(" [pretty:{l}]"))
962 .unwrap_or_default();
963 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
964 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
965 let preprocess_failed_tag = self.preprocess_failure.as_ref()
966 .map(|msg| {
967 let first_line = msg.lines().next().unwrap_or("");
968 format!(" [preprocess-failed: {}]", first_line)
969 })
970 .unwrap_or_default();
971
972 let file_index_tag = match self.file_index {
973 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
974 None => String::new(),
975 };
976
977 let tag_tag = match &self.tag_active {
978 Some((name, cur, total)) if *total > 1 => {
979 format!(" [tag: {name} ({cur}/{total})]")
980 }
981 _ => String::new(),
982 };
983
984 PromptContext {
985 label: self.source_label.clone(),
986 top,
987 bottom,
988 total,
989 pct: pct.min(100) as u8,
990 rec_top,
991 rec_bottom,
992 rec_total,
993 records_mode,
994 wrap_offset,
995 format_tag,
996 filter_tag,
997 grep_tag,
998 hide_tag,
999 search_tag,
1000 pretty_tag,
1001 live_tag,
1002 follow_tag,
1003 preprocess_failed_tag,
1004 file_index_tag,
1005 tag_tag,
1006 }
1007 }
1008
1009 fn frame_hex(&self, src: &dyn Source) -> Frame {
1010 use crate::hex::format_hex_row;
1011 use crate::render::{render_line, Cell, RenderOpts};
1012
1013 let body_rows = self.rows.saturating_sub(1) as usize;
1014 let total_bytes = src.len();
1015 let total_hex_rows = total_bytes.div_ceil(16);
1016
1017 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1018 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1019 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1020
1021 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
1022
1023 for row_idx in 0..body_rows {
1024 let hex_row = self.top_line + row_idx;
1025 if hex_row >= total_hex_rows {
1026 body.push(vec![Cell::Empty; self.cols as usize]);
1027 } else {
1028 let offset = hex_row * 16;
1029 let end = (offset + 16).min(total_bytes);
1030 let bytes_cow = src.bytes(offset..end);
1031 let text = format_hex_row(offset, &bytes_cow);
1032 let rows = render_line(text.as_bytes(), &opts, None);
1033 body.push(rows.into_iter().next().unwrap_or_else(|| {
1034 vec![Cell::Empty; self.cols as usize]
1035 }));
1036 }
1037 row_styles.push(RowStyle::Normal);
1038 highlights.push(Vec::new());
1039 }
1040
1041 let status = self.format_status_hex(src);
1042 Frame { body, row_styles, highlights, status }
1043 }
1044
1045 fn format_status_hex(&self, src: &dyn Source) -> String {
1046 let total_bytes = src.len();
1047 let body_rows = self.rows.saturating_sub(1) as usize;
1048 let top_byte = self.top_line * 16;
1050 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1053 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1054 let label_with_index = match self.file_index {
1055 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
1056 None => self.source_label.clone(),
1057 };
1058 let tag_suffix = match &self.tag_active {
1059 Some((name, cur, total)) if *total > 1 => {
1060 format!(" [tag: {name} ({cur}/{total})]")
1061 }
1062 _ => String::new(),
1063 };
1064 format!(
1065 "{} off {}-{}/{} {}% [hex]{}",
1066 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1067 )
1068 }
1069
1070 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1075 if delta == 0 { return; }
1076 if self.hide_mode() {
1077 self.scroll_lines(delta, src, idx);
1078 return;
1079 }
1080 if delta > 0 {
1081 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1082 let total = idx.line_count();
1083 if total == 0 { return; }
1084 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1085 self.top_line = target;
1086 self.top_row = 0;
1087 } else {
1088 let back = (-delta) as usize;
1089 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1094 let extra_back = back.saturating_sub(consumed_for_snap);
1095 self.top_line = self.top_line.saturating_sub(extra_back);
1096 self.top_row = 0;
1097 }
1098 }
1099
1100 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1101 if delta == 0 { return; }
1102 if self.hide_mode() {
1103 self.extend_visible_lines(idx, src);
1107 let total = self.visible_lines.len();
1108 if total == 0 {
1109 self.top_line = 0;
1110 self.top_row = 0;
1111 return;
1112 }
1113 let cur = self
1114 .visible_lines
1115 .iter()
1116 .position(|&l| l >= self.top_line)
1117 .unwrap_or(total);
1118 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1119 self.top_line = self.visible_lines[new];
1120 self.top_row = 0;
1121 return;
1122 }
1123 if delta > 0 {
1124 let mut remaining = delta as usize;
1125 while remaining > 0 {
1126 idx.extend_to_line(self.top_line + 1, src);
1127 let total = idx.line_count();
1128 if total == 0 { break; }
1129 let bytes = self.line_display_bytes(src, idx, self.top_line);
1130 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1131 if self.top_row + 1 < line_rows {
1132 self.top_row += 1;
1133 } else if self.top_line + 1 < total {
1134 self.top_row = 0;
1135 self.top_line += 1;
1136 } else {
1137 break;
1138 }
1139 remaining -= 1;
1140 }
1141 } else {
1142 let mut remaining = (-delta) as usize;
1143 while remaining > 0 {
1144 if self.top_row > 0 {
1145 self.top_row -= 1;
1146 } else if self.top_line > 0 {
1147 self.top_line -= 1;
1148 let bytes = self.line_display_bytes(src, idx, self.top_line);
1149 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1150 self.top_row = line_rows.saturating_sub(1);
1151 } else {
1152 break;
1153 }
1154 remaining -= 1;
1155 }
1156 }
1157 }
1158
1159 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1160 let n = self.body_rows() as i64;
1161 self.scroll_lines(n, src, idx);
1162 }
1163
1164 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1165 let n = self.body_rows() as i64;
1166 self.scroll_lines(-n, src, idx);
1167 }
1168
1169 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1170 let n = (self.body_rows() / 2).max(1) as i64;
1171 self.scroll_lines(n, src, idx);
1172 }
1173
1174 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1175 let n = (self.body_rows() / 2).max(1) as i64;
1176 self.scroll_lines(-n, src, idx);
1177 }
1178
1179 pub fn goto_top(&mut self) {
1180 self.top_line = 0;
1181 self.top_row = 0;
1182 }
1183
1184 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1185 idx.extend_to_end(src);
1186 let body = self.body_rows() as usize;
1187 if self.hide_mode() {
1188 self.extend_visible_lines(idx, src);
1189 let total = self.visible_lines.len();
1190 let target_visible = total.saturating_sub(body);
1191 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1192 self.top_row = 0;
1193 } else {
1194 let total = idx.line_count();
1195 self.top_line = total.saturating_sub(body);
1196 self.top_row = 0;
1197 }
1198 }
1199
1200 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1202 idx.extend_to_line(n, src);
1203 let target = n.min(idx.line_count().saturating_sub(1));
1204 self.top_line = target;
1205 self.top_row = 0;
1206 }
1207
1208 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1210 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1214 idx.extend_to_end(src);
1215 }
1216 if idx.record_count() == 0 {
1217 return;
1218 }
1219 let target = n.min(idx.record_count().saturating_sub(1));
1220 let line_range = idx.record_line_range(target);
1221 self.top_line = line_range.start;
1222 self.top_row = 0;
1223 }
1224
1225 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1228 let p = p.min(100) as usize;
1229 let target_byte = src.len().saturating_mul(p) / 100;
1230 idx.extend_to_byte_for_query(src, target_byte);
1231 let line_n = idx.line_at_byte(target_byte)
1232 .or_else(|| {
1233 let lc = idx.line_count();
1235 if lc > 0 { Some(lc - 1) } else { None }
1236 })
1237 .unwrap_or(0);
1238 self.top_line = line_n;
1239 self.top_row = 0;
1240 }
1241
1242 pub fn top_line(&self) -> usize {
1244 self.top_line
1245 }
1246
1247 pub fn resize(&mut self, cols: u16, rows: u16) {
1248 self.cols = cols.max(1);
1249 self.rows = rows.max(2);
1250 self.opts.cols = self.cols;
1251 }
1252
1253 pub fn toggle_line_numbers(&mut self) {
1254 self.show_line_numbers = !self.show_line_numbers;
1255 }
1256
1257 pub fn toggle_chop(&mut self) {
1258 self.opts.wrap = !self.opts.wrap;
1259 }
1260
1261 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use crate::source::MockSource;
1271
1272 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1273 let m = MockSource::new();
1274 m.append(content);
1275 m.finish();
1276 let idx = LineIndex::new();
1277 (m, idx)
1278 }
1279
1280 #[test]
1281 fn frame_renders_body_height_rows() {
1282 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1283 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1285 assert_eq!(frame.body.len(), 4);
1286 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1287 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1288 }
1289
1290 #[test]
1291 fn scroll_down_advances_top_line() {
1292 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1293 let mut v = Viewport::new(10, 5, "test".into());
1294 v.scroll_lines(2, &m, &mut idx);
1295 assert_eq!(v.top_line, 2);
1296 assert_eq!(v.top_row, 0);
1297 }
1298
1299 #[test]
1300 fn scroll_up_clamps_at_zero() {
1301 let (m, mut idx) = setup(b"a\nb\nc\n");
1302 let mut v = Viewport::new(10, 5, "test".into());
1303 v.scroll_lines(-5, &m, &mut idx);
1304 assert_eq!(v.top_line, 0);
1305 assert_eq!(v.top_row, 0);
1306 }
1307
1308 #[test]
1309 fn scroll_down_clamps_at_last_line() {
1310 let (m, mut idx) = setup(b"a\nb\nc\n");
1311 let mut v = Viewport::new(10, 5, "test".into());
1312 v.scroll_lines(50, &m, &mut idx);
1313 assert_eq!(v.top_line, 2);
1314 }
1315
1316 #[test]
1317 fn scroll_logical_lines_skips_wrap_rows() {
1318 let mut content = vec![b'X'; 500];
1320 content.push(b'\n');
1321 content.extend_from_slice(b"second\n");
1322 content.extend_from_slice(b"third\n");
1323 let (m, mut idx) = setup(&content);
1324 let mut v = Viewport::new(10, 8, "f".into());
1325 v.scroll_logical_lines(1, &m, &mut idx);
1326 assert_eq!((v.top_line, v.top_row), (1, 0));
1327 v.scroll_logical_lines(1, &m, &mut idx);
1328 assert_eq!((v.top_line, v.top_row), (2, 0));
1329 }
1330
1331 #[test]
1332 fn scroll_logical_lines_back_snaps_to_line_start() {
1333 let mut content = vec![b'A'; 50];
1335 content.push(b'\n');
1336 content.extend_from_slice(&[b'B'; 50]);
1337 content.push(b'\n');
1338 let (m, mut idx) = setup(&content);
1339 let mut v = Viewport::new(10, 8, "f".into());
1340 v.scroll_lines(7, &m, &mut idx);
1341 assert_eq!(v.top_line, 1, "should be on line 1");
1342 assert!(v.top_row > 0, "should be inside line 1's wraps");
1343 v.scroll_logical_lines(-1, &m, &mut idx);
1344 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1345 v.scroll_logical_lines(-1, &m, &mut idx);
1346 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1347 }
1348
1349 #[test]
1350 fn scroll_down_walks_wraps_of_last_line() {
1351 let mut content = b"first\n".to_vec();
1353 content.extend_from_slice(&[b'X'; 30]);
1354 content.push(b'\n');
1355 let (m, mut idx) = setup(&content);
1356 let mut v = Viewport::new(10, 5, "f".into());
1357 v.scroll_lines(1, &m, &mut idx);
1358 assert_eq!((v.top_line, v.top_row), (1, 0));
1359 v.scroll_lines(1, &m, &mut idx);
1360 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1361 v.scroll_lines(1, &m, &mut idx);
1362 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1363 }
1364
1365 #[test]
1366 fn scroll_down_walks_wrap_rows_within_long_line() {
1367 let mut content = vec![b'X'; 30];
1369 content.push(b'\n');
1370 content.extend_from_slice(b"second\n");
1371 let (m, mut idx) = setup(&content);
1372 let mut v = Viewport::new(10, 5, "f".into());
1373 v.scroll_lines(1, &m, &mut idx);
1374 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1375 v.scroll_lines(1, &m, &mut idx);
1376 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1377 v.scroll_lines(1, &m, &mut idx);
1378 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1379 }
1380
1381 #[test]
1382 fn status_line_shows_range_and_pct() {
1383 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1384 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1386 assert!(frame.status.starts_with("f 1-4/10"));
1387 }
1388
1389 #[test]
1390 fn page_down_advances_by_body_rows() {
1391 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1392 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1394 assert_eq!(v.top_line, 4);
1395 }
1396
1397 #[test]
1398 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1399 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1400 let mut v = Viewport::new(10, 5, "f".into());
1401 v.page_down(&m, &mut idx);
1402 v.page_up(&m, &mut idx);
1403 assert_eq!(v.top_line, 0);
1404 assert_eq!(v.top_row, 0);
1405 }
1406
1407 #[test]
1408 fn half_page_down_advances_by_half_body() {
1409 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1410 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1412 assert_eq!(v.top_line, 3);
1413 }
1414
1415 #[test]
1416 fn goto_top_resets_position() {
1417 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1418 let mut v = Viewport::new(10, 5, "f".into());
1419 v.scroll_lines(2, &m, &mut idx);
1420 v.goto_top();
1421 assert_eq!(v.top_line, 0);
1422 assert_eq!(v.top_row, 0);
1423 }
1424
1425 #[test]
1426 fn goto_bottom_scrolls_to_last_page() {
1427 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1428 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1430 assert_eq!(v.top_line, 6);
1432 }
1433
1434 #[test]
1435 fn goto_line_positions_top_line() {
1436 let m = MockSource::new();
1437 m.append(b"a\nb\nc\nd\ne\n");
1438 let mut idx = LineIndex::new();
1439 idx.extend_to_end(&m);
1440 let mut v = Viewport::new(20, 5, "f".into());
1441 v.goto_line(3, &m, &mut idx);
1442 assert_eq!(v.top_line(), 3);
1443 }
1444
1445 #[test]
1446 fn goto_line_clamps_to_last_line() {
1447 let m = MockSource::new();
1448 m.append(b"a\nb\n");
1449 let mut idx = LineIndex::new();
1450 idx.extend_to_end(&m);
1451 let mut v = Viewport::new(20, 5, "f".into());
1452 v.goto_line(999, &m, &mut idx);
1453 assert_eq!(v.top_line(), 1);
1454 }
1455
1456 #[test]
1457 fn goto_record_positions_at_record_start_line() {
1458 let m = MockSource::new();
1459 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1460 let mut idx = LineIndex::new();
1461 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1462 idx.extend_to_end(&m);
1463 let mut v = Viewport::new(20, 5, "f".into());
1464 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1466 }
1467
1468 #[test]
1469 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1470 let m = MockSource::new();
1471 m.append(b"a\nb\nc\n");
1472 let mut idx = LineIndex::new();
1473 idx.extend_to_end(&m);
1474 let mut v = Viewport::new(20, 5, "f".into());
1475 v.goto_record(2, &m, &mut idx);
1476 assert_eq!(v.top_line(), 2);
1477 }
1478
1479 #[test]
1480 fn goto_percent_50_lands_in_middle() {
1481 let m = MockSource::new();
1482 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1484 idx.extend_to_end(&m);
1485 let mut v = Viewport::new(20, 5, "f".into());
1486 v.goto_percent(50, &m, &mut idx);
1487 assert_eq!(v.top_line(), 2); }
1489
1490 #[test]
1491 fn goto_percent_100_lands_at_last_line() {
1492 let m = MockSource::new();
1493 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1495 idx.extend_to_end(&m);
1496 let mut v = Viewport::new(20, 5, "f".into());
1497 v.goto_percent(100, &m, &mut idx);
1498 assert_eq!(v.top_line(), 2);
1499 }
1500
1501 #[test]
1502 fn goto_percent_0_lands_at_first_line() {
1503 let m = MockSource::new();
1504 m.append(b"a\nb\nc\n");
1505 let mut idx = LineIndex::new();
1506 idx.extend_to_end(&m);
1507 let mut v = Viewport::new(20, 5, "f".into());
1508 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1510 v.goto_percent(0, &m, &mut idx);
1511 assert_eq!(v.top_line(), 0);
1512 }
1513
1514 #[test]
1515 fn resize_updates_dimensions_and_render_opts() {
1516 let (m, mut idx) = setup(b"1\n2\n");
1517 let mut v = Viewport::new(10, 5, "f".into());
1518 v.resize(40, 12);
1519 assert_eq!(v.cols, 40);
1520 assert_eq!(v.rows, 12);
1521 assert_eq!(v.opts.cols, 40);
1522 let _ = v.frame(&m, &mut idx);
1523 }
1524
1525 #[test]
1526 fn toggle_line_numbers_changes_gutter() {
1527 let (m, mut idx) = setup(b"a\nb\nc\n");
1528 let mut v = Viewport::new(10, 5, "f".into());
1529 let frame_off = v.frame(&m, &mut idx);
1530 v.toggle_line_numbers();
1531 let frame_on = v.frame(&m, &mut idx);
1532 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1534 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1535 }
1536
1537 #[test]
1538 fn toggle_chop_changes_wrap_mode() {
1539 let (m, mut idx) = setup(b"abcdefghij\n");
1540 let mut v = Viewport::new(4, 5, "f".into());
1541 v.toggle_chop();
1542 let frame = v.frame(&m, &mut idx);
1543 assert_eq!(frame.body[0][..4],
1546 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1547 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1548 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1549 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1550 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1552 }
1553
1554 #[test]
1557 fn is_at_bottom_initially_only_when_source_fits() {
1558 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1561 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1562 }
1563
1564 #[test]
1565 fn is_at_bottom_false_when_top_and_more_lines_below() {
1566 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);
1569 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1570 }
1571
1572 #[test]
1573 fn is_at_bottom_true_after_goto_bottom() {
1574 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1575 let mut v = Viewport::new(10, 5, "f".into());
1576 v.goto_bottom(&m, &mut idx);
1577 assert!(v.is_at_bottom(&idx));
1578 }
1579
1580 #[test]
1581 fn status_shows_follow_suffix_when_follow_mode_on() {
1582 let (m, mut idx) = setup(b"a\nb\n");
1583 let mut v = Viewport::new(20, 5, "f".into());
1584 let frame_off = v.frame(&m, &mut idx);
1585 assert!(!frame_off.status.contains("(F)"));
1586 v.set_follow_mode(true);
1587 let frame_on = v.frame(&m, &mut idx);
1588 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1589 }
1590
1591 #[test]
1592 fn toggle_follow_flips_state() {
1593 let mut v = Viewport::new(10, 5, "f".into());
1594 assert!(!v.follow_mode());
1595 v.toggle_follow();
1596 assert!(v.follow_mode());
1597 v.toggle_follow();
1598 assert!(!v.follow_mode());
1599 }
1600
1601 #[test]
1602 fn status_shows_prettify_label_when_set() {
1603 let (m, mut idx) = setup(b"a\n");
1604 let mut v = Viewport::new(40, 5, "f".into());
1605 let frame_off = v.frame(&m, &mut idx);
1606 assert!(!frame_off.status.contains("[pretty"));
1607 v.set_prettify_label(Some("json".into()));
1608 let frame_on = v.frame(&m, &mut idx);
1609 assert!(frame_on.status.contains("[pretty:json]"),
1610 "expected [pretty:json] in status, got: {}", frame_on.status);
1611 v.set_prettify_label(Some("json:err".into()));
1612 let frame_err = v.frame(&m, &mut idx);
1613 assert!(frame_err.status.contains("[pretty:json:err]"),
1614 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1615 }
1616
1617 #[test]
1618 fn status_shows_l_suffix_when_live_mode_on() {
1619 let (m, mut idx) = setup(b"a\nb\n");
1620 let mut v = Viewport::new(20, 5, "f".into());
1621 let frame_off = v.frame(&m, &mut idx);
1622 assert!(!frame_off.status.contains("(L)"));
1623 v.set_live_mode(true);
1624 let frame_on = v.frame(&m, &mut idx);
1625 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1626 }
1627
1628 #[test]
1629 fn clamp_top_line_pulls_back_when_total_shrinks() {
1630 let mut v = Viewport::new(20, 5, "f".into());
1631 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1640 let (m, mut idx) = setup(b"only\n");
1642 let _ = v.frame(&m, &mut idx);
1643 }
1644
1645 fn simulate_growth_tick(
1648 v: &mut Viewport,
1649 src: &MockSource,
1650 idx: &mut LineIndex,
1651 ) {
1652 if !v.follow_mode() { return; }
1653 let was_at_bottom = v.is_at_bottom(idx);
1654 let lines_before = idx.line_count();
1655 idx.notice_new_bytes(src);
1656 if idx.line_count() != lines_before && was_at_bottom {
1657 v.goto_bottom(src, idx);
1658 }
1659 }
1660
1661 #[test]
1662 fn auto_scroll_engages_when_at_bottom() {
1663 let m = MockSource::new();
1664 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1666 let mut v = Viewport::new(10, 5, "f".into());
1667 v.set_follow_mode(true);
1668 idx.extend_to_end(&m);
1669 assert!(v.is_at_bottom(&idx));
1670 let top_before = {
1671 let f = v.frame(&m, &mut idx);
1672 f.status.clone() };
1674 let _ = top_before;
1675 m.append(b"5\n6\n7\n8\n");
1677 simulate_growth_tick(&mut v, &m, &mut idx);
1678 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1680 let frame = v.frame(&m, &mut idx);
1681 let last_row = &frame.body[frame.body.len() - 1];
1684 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1685 }
1686
1687 #[test]
1688 fn auto_scroll_suppressed_when_scrolled_up() {
1689 let m = MockSource::new();
1690 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1692 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1694 idx.extend_to_end(&m);
1695 v.goto_bottom(&m, &mut idx);
1696 v.scroll_lines(-2, &m, &mut idx);
1698 assert!(!v.is_at_bottom(&idx));
1699 let frame_before = v.frame(&m, &mut idx);
1700 let top_first_cell_before = frame_before.body[0][0].clone();
1701 m.append(b"9\n10\n");
1703 simulate_growth_tick(&mut v, &m, &mut idx);
1704 let frame_after = v.frame(&m, &mut idx);
1706 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1707 }
1708
1709 #[test]
1712 fn set_search_compiles_regex() {
1713 let mut v = Viewport::new(10, 5, "f".into());
1714 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1715 assert!(v.search_active());
1716 }
1717
1718 #[test]
1719 fn set_search_rejects_bad_regex() {
1720 let mut v = Viewport::new(10, 5, "f".into());
1721 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1722 assert!(!err.is_empty());
1723 assert!(!v.search_active(), "no search should be set on error");
1724 }
1725
1726 #[test]
1727 fn search_step_forward_finds_match_after_top() {
1728 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1729 let mut v = Viewport::new(20, 5, "f".into());
1730 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1731 let found = v.search_repeat(&m, &mut idx, false);
1732 assert!(found);
1733 assert_eq!(v.top_line, 2);
1735 }
1736
1737 #[test]
1738 fn search_step_backward_finds_match_before_top() {
1739 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1740 let mut v = Viewport::new(20, 5, "f".into());
1741 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1743 let found = v.search_repeat(&m, &mut idx, false);
1744 assert!(found);
1745 assert_eq!(v.top_line, 0);
1746 }
1747
1748 #[test]
1749 fn search_wraps_at_end() {
1750 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1751 let mut v = Viewport::new(20, 5, "f".into());
1752 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1754 let found = v.search_repeat(&m, &mut idx, false);
1755 assert!(found, "search should wrap forward past EOF");
1756 assert_eq!(v.top_line, 0);
1757 }
1758
1759 #[test]
1760 fn search_no_match_returns_false_and_does_not_move() {
1761 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1762 let mut v = Viewport::new(20, 5, "f".into());
1763 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1764 let found = v.search_repeat(&m, &mut idx, false);
1765 assert!(!found);
1766 assert_eq!(v.top_line, 0);
1767 }
1768
1769 #[test]
1770 fn frame_records_highlight_ranges_for_matches() {
1771 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1772 let mut v = Viewport::new(20, 5, "f".into());
1773 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1774 let frame = v.frame(&m, &mut idx);
1775 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1777 assert!(frame.highlights[0].is_empty());
1778 assert!(frame.highlights[1].is_empty());
1779 assert_eq!(frame.highlights[2], vec![0..5]);
1780 assert!(frame.highlights[3].is_empty());
1781 }
1782
1783 #[test]
1784 fn frame_highlights_substring_inside_a_row() {
1785 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1786 let mut v = Viewport::new(40, 5, "f".into());
1787 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1788 let frame = v.frame(&m, &mut idx);
1789 assert_eq!(frame.highlights[0], vec![18..22]);
1791 assert!(frame.highlights[1].is_empty());
1792 }
1793
1794 #[test]
1795 fn search_highlight_with_filter_dim_keeps_row_dim() {
1796 let (m, mut idx) = setup(b"alpha\nbeta\n");
1799 let mut v = Viewport::new(20, 5, "f".into());
1800 let fmt = crate::format::LogFormat::compile(
1801 "simple",
1802 r"^(?P<line>.+)$",
1803 )
1804 .unwrap();
1805 let f = crate::filter::CompiledFilter::compile(
1806 &fmt,
1807 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1808 )
1809 .unwrap();
1810 v.set_filter(Some(f));
1811 v.set_dim_mode(true);
1812 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1813 let frame = v.frame(&m, &mut idx);
1814 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1815 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1816 assert_eq!(frame.highlights[1], vec![0..4]);
1817 }
1818
1819 #[test]
1820 fn grep_only_hides_non_matching_lines() {
1821 use crate::grep::GrepPredicate;
1822 let src = crate::source::MockSource::new();
1823 src.append(b"keep this error\n");
1824 src.append(b"drop this one\n");
1825 src.append(b"another error line\n");
1826 src.finish();
1827 let mut idx = crate::line_index::LineIndex::new();
1828 idx.extend_to_end(&src);
1829
1830 let mut v = Viewport::new(40, 5, "test".into());
1831 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1832 v.extend_visible_lines(&idx, &src);
1833
1834 let frame = v.frame(&src, &mut idx);
1836 let body_text: Vec<String> = frame.body.iter()
1837 .map(|row| row.iter().filter_map(|c| match c {
1838 crate::render::Cell::Char { ch, .. } => Some(*ch),
1839 _ => None,
1840 }).collect())
1841 .collect();
1842 assert!(body_text[0].contains("keep this error"));
1843 assert!(body_text[1].contains("another error line"));
1844 assert!(frame.status.contains("[grep]"));
1845 }
1846
1847 #[test]
1848 fn filter_and_grep_combine_with_and() {
1849 use crate::grep::GrepPredicate;
1850 let fmt = crate::format::LogFormat::compile(
1851 "simple",
1852 r"^(?P<level>\w+) (?P<msg>.+)$",
1853 ).unwrap();
1854 let f = crate::filter::CompiledFilter::compile(
1855 &fmt,
1856 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1857 ).unwrap();
1858 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1859
1860 let src = crate::source::MockSource::new();
1861 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();
1866 let mut idx = crate::line_index::LineIndex::new();
1867 idx.extend_to_end(&src);
1868
1869 let mut v = Viewport::new(80, 5, "test".into());
1870 v.set_filter(Some(f));
1871 v.set_grep(Some(g));
1872 v.extend_visible_lines(&idx, &src);
1873 assert_eq!(v.visible_lines(), &[0usize]);
1874 }
1875
1876 #[test]
1877 fn search_status_shows_pattern() {
1878 let (m, mut idx) = setup(b"x\n");
1879 let mut v = Viewport::new(20, 5, "f".into());
1880 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1881 let frame = v.frame(&m, &mut idx);
1882 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1883 }
1884
1885 #[test]
1886 fn repeat_search_after_first_match_advances() {
1887 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1888 let mut v = Viewport::new(40, 5, "f".into());
1889 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1890 assert!(v.search_repeat(&m, &mut idx, false));
1891 assert_eq!(v.top_line, 1, "first foo");
1892 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1893 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1894 assert_eq!(v.top_line, 3, "should advance to next foo");
1895 }
1896
1897 #[test]
1898 fn auto_scroll_paused_when_follow_off() {
1899 let m = MockSource::new();
1900 m.append(b"1\n2\n3\n4\n");
1901 let mut idx = LineIndex::new();
1902 let mut v = Viewport::new(10, 5, "f".into());
1903 idx.extend_to_end(&m);
1905 let frame_before = v.frame(&m, &mut idx);
1906 let top_first_cell = frame_before.body[0][0].clone();
1907 m.append(b"5\n6\n7\n8\n");
1908 simulate_growth_tick(&mut v, &m, &mut idx);
1909 let frame_after = v.frame(&m, &mut idx);
1910 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1911 }
1912
1913 #[test]
1916 fn search_jumps_to_next_matching_record() {
1917 let m = MockSource::new();
1918 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1919 let mut idx = LineIndex::new();
1920 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1921 idx.extend_to_end(&m);
1922 let mut v = Viewport::new(40, 10, "f".into());
1923 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1924 let hit = v.search_repeat(&m, &mut idx, false);
1925 assert!(hit, "should find 'charlie' in record 2");
1926 assert_eq!(v.top_line(), 3); }
1928
1929 #[test]
1930 fn search_finds_cross_line_match_in_record_with_s_flag() {
1931 let m = MockSource::new();
1932 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1933 let mut idx = LineIndex::new();
1934 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1935 idx.extend_to_end(&m);
1936 let mut v = Viewport::new(40, 10, "f".into());
1937 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1938 let hit = v.search_repeat(&m, &mut idx, false);
1939 assert!(hit, "should match across \\n inside record 0 with (?s)");
1940 assert_eq!(v.top_line(), 0);
1941 }
1942
1943 #[test]
1944 fn search_repeat_with_no_match_returns_false() {
1945 let m = MockSource::new();
1946 m.append(b"[1] alpha\n[2] bravo\n");
1947 let mut idx = LineIndex::new();
1948 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1949 idx.extend_to_end(&m);
1950 let mut v = Viewport::new(40, 10, "f".into());
1951 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1952 let hit = v.search_repeat(&m, &mut idx, false);
1953 assert!(!hit);
1954 }
1955
1956 #[test]
1959 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1960 let m = MockSource::new();
1963 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1964 let mut idx = LineIndex::new();
1965 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1966 idx.extend_to_end(&m);
1967 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1968 let mut v = Viewport::new(40, 10, "f".into());
1969 v.set_grep(Some(grep));
1970 v.extend_visible_lines(&idx, &m);
1971 assert_eq!(v.visible_lines(), &[0usize, 1]);
1974 }
1975
1976 #[test]
1977 fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
1978 let m = MockSource::new();
1984 m.append(
1985 b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
1986 );
1987 let mut idx = LineIndex::new();
1988 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1989 idx.extend_to_end(&m);
1990 let fmt = crate::format::LogFormat::compile(
1991 "rec",
1992 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
1993 )
1994 .unwrap();
1995 let f = crate::filter::CompiledFilter::compile(
1996 &fmt,
1997 vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
1998 )
1999 .unwrap();
2000 let mut v = Viewport::new(40, 10, "f".into());
2001 v.set_filter(Some(f));
2002 v.extend_visible_lines(&idx, &m);
2003 assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2005 }
2006
2007 #[test]
2008 fn grep_matches_across_record_newlines_in_records_mode() {
2009 let m = MockSource::new();
2011 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
2012 let mut idx = LineIndex::new();
2013 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2014 idx.extend_to_end(&m);
2015 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
2016 let mut v = Viewport::new(40, 10, "f".into());
2017 v.set_grep(Some(grep));
2018 v.extend_visible_lines(&idx, &m);
2019 assert_eq!(v.visible_lines(), &[0usize, 1]);
2021 }
2022
2023 #[test]
2024 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2025 let m = MockSource::new();
2028 m.append(b"[1] head\n cont\n[2] other\n cont\n");
2029 let mut idx = LineIndex::new();
2030 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2031 idx.extend_to_end(&m);
2032 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
2033 let mut v = Viewport::new(40, 10, "f".into());
2034 v.set_grep(Some(grep));
2035 v.set_dim_mode(true);
2036 v.extend_visible_lines(&idx, &m);
2037 assert_eq!(v.visible_lines(), &[] as &[usize]);
2039 assert!(!v.should_dim_line(0, &idx, &m));
2041 assert!(!v.should_dim_line(1, &idx, &m));
2042 assert!(v.should_dim_line(2, &idx, &m));
2044 assert!(v.should_dim_line(3, &idx, &m));
2045 }
2046
2047 #[test]
2048 fn status_unchanged_when_records_inactive() {
2049 let (m, mut idx) = setup(b"a\nb\nc\n");
2050 let mut v = Viewport::new(20, 5, "f".into());
2051 let frame = v.frame(&m, &mut idx);
2052 let status = &frame.status;
2053 assert!(status.contains("1-3/3"), "got: {status}");
2055 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2056 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2057 }
2058
2059 #[test]
2060 fn status_r_block_uses_real_lines_in_hide_mode() {
2061 let m = MockSource::new();
2070 let mut buf = Vec::new();
2073 for n in 0..10 {
2074 let kind = if n >= 8 { "B" } else { "A" };
2075 buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
2076 }
2077 m.append(&buf);
2078 m.finish();
2079
2080 let mut idx = LineIndex::new();
2081 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2082 idx.extend_to_end(&m);
2083
2084 let fmt = crate::format::LogFormat::compile(
2085 "rec",
2086 r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2087 )
2088 .unwrap();
2089 let f = crate::filter::CompiledFilter::compile(
2090 &fmt,
2091 vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2092 )
2093 .unwrap();
2094
2095 let mut v = Viewport::new(80, 5, "f".into());
2098 v.set_filter(Some(f));
2099 v.extend_visible_lines(&idx, &m);
2100
2101 v.goto_record(8, &m, &mut idx);
2103
2104 let frame = v.frame(&m, &mut idx);
2105 assert!(
2107 frame.status.contains("R9-10/10"),
2108 "expected R9-10/10 in status, got: {}",
2109 frame.status,
2110 );
2111 }
2112
2113 #[test]
2114 fn status_dual_readout_when_records_active() {
2115 let m = MockSource::new();
2116 m.append(b"[1] a\n cont\n[2] b\n");
2117 m.finish();
2118 let mut idx = LineIndex::new();
2119 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2120 idx.extend_to_end(&m);
2121 let mut v = Viewport::new(20, 5, "f".into());
2122 let frame = v.frame(&m, &mut idx);
2123 let status = &frame.status;
2124 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2125 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2126 }
2127
2128 #[test]
2129 fn format_status_uses_custom_template_when_set() {
2130 let m = MockSource::new();
2131 m.append(b"a\nb\nc\n");
2132 m.finish();
2133 let mut idx = LineIndex::new();
2134 idx.extend_to_end(&m);
2135 let mut v = Viewport::new(20, 5, "f".into());
2136 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2137 v.set_prompt(Some(prompt));
2138 let frame = v.frame(&m, &mut idx);
2139 assert_eq!(frame.status, "f 100%");
2140 }
2141
2142 #[test]
2143 fn status_shows_preprocess_failed_tag_when_set() {
2144 let m = MockSource::new();
2145 m.append(b"a\n");
2146 let mut idx = LineIndex::new();
2147 idx.extend_to_end(&m);
2148 let mut v = Viewport::new(40, 5, "f".into());
2149 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2150 let frame = v.frame(&m, &mut idx);
2151 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2152 "got: {}", frame.status);
2153 }
2154
2155 #[test]
2156 fn default_status_includes_help_hint() {
2157 let (m, mut idx) = setup(b"a\nb\nc\n");
2158 let mut v = Viewport::new(80, 5, "f".into());
2159 let frame = v.frame(&m, &mut idx);
2160 assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2161 }
2162
2163 #[test]
2164 fn custom_prompt_does_not_get_help_hint() {
2165 let (m, mut idx) = setup(b"a\nb\nc\n");
2166 let mut v = Viewport::new(80, 5, "f".into());
2167 v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2168 let frame = v.frame(&m, &mut idx);
2169 assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2170 }
2171
2172 #[test]
2173 fn status_shows_file_index_when_multifile() {
2174 let m = MockSource::new();
2175 m.append(b"a\n");
2176 let mut idx = LineIndex::new();
2177 idx.extend_to_end(&m);
2178 let mut v = Viewport::new(60, 5, "f.log".into());
2179 v.set_file_index(0, 3);
2180 let frame = v.frame(&m, &mut idx);
2181 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2182 }
2183
2184 #[test]
2185 fn status_omits_file_index_when_single_file() {
2186 let m = MockSource::new();
2187 m.append(b"a\n");
2188 let mut idx = LineIndex::new();
2189 idx.extend_to_end(&m);
2190 let mut v = Viewport::new(60, 5, "f.log".into());
2191 v.set_file_index(0, 1);
2192 let frame = v.frame(&m, &mut idx);
2193 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2194 }
2195
2196 #[test]
2197 fn status_shows_tag_active_when_multimatch() {
2198 let m = MockSource::new();
2199 m.append(b"a\n");
2200 let mut idx = LineIndex::new();
2201 idx.extend_to_end(&m);
2202 let mut v = Viewport::new(80, 5, "f.log".into());
2203 v.set_tag_active(Some(("foo".into(), 2, 3)));
2204 let frame = v.frame(&m, &mut idx);
2205 assert!(
2206 frame.status.contains("[tag: foo (2/3)]"),
2207 "got: {}",
2208 frame.status
2209 );
2210 }
2211
2212 #[test]
2213 fn status_omits_tag_active_when_single_match() {
2214 let m = MockSource::new();
2215 m.append(b"a\n");
2216 let mut idx = LineIndex::new();
2217 idx.extend_to_end(&m);
2218 let mut v = Viewport::new(80, 5, "f.log".into());
2219 v.set_tag_active(Some(("foo".into(), 1, 1)));
2220 let frame = v.frame(&m, &mut idx);
2221 assert!(
2222 !frame.status.contains("[tag:"),
2223 "should not show indicator for single match: {}",
2224 frame.status
2225 );
2226 }
2227
2228 #[test]
2231 fn reconstruct_picks_up_state_from_prior_lines() {
2232 let m = MockSource::new();
2233 m.append(b"\x1b[31mline 1\n");
2234 m.append(b"line 2 (still red, no reset)\n");
2235 m.append(b"line 3\n");
2236 let mut idx = LineIndex::new();
2237 idx.extend_to_end(&m);
2238 let state = reconstruct_render_state(&m, &idx, 2);
2239 assert_eq!(
2240 state.style.fg,
2241 Some(crate::ansi::Color::Ansi(1)),
2242 "red SGR from line 0 should persist to line 2"
2243 );
2244 }
2245
2246 #[test]
2247 fn reconstruct_respects_reset_between_lines() {
2248 let m = MockSource::new();
2249 m.append(b"\x1b[31mline 1\x1b[0m\n");
2250 m.append(b"line 2 (default)\n");
2251 let mut idx = LineIndex::new();
2252 idx.extend_to_end(&m);
2253 let state = reconstruct_render_state(&m, &idx, 1);
2254 assert_eq!(state.style.fg, None);
2255 }
2256
2257 #[test]
2258 fn reconstruct_caps_walkback_at_max_lines() {
2259 let m = MockSource::new();
2260 m.append(b"\x1b[31mvery early\n");
2261 for _ in 0..300 {
2262 m.append(b"line\n");
2263 }
2264 let mut idx = LineIndex::new();
2265 idx.extend_to_end(&m);
2266 let state = reconstruct_render_state(&m, &idx, 290);
2269 assert_eq!(state.style.fg, None);
2270 }
2271}