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 let bytes = idx.record_bytes_stripped(r, src);
493 if self.line_passes(&bytes) {
494 for line_n in idx.record_line_range(r) {
495 self.visible_lines.push(line_n);
496 }
497 }
498 }
499 }
500
501 fn line_passes(&self, line: &[u8]) -> bool {
507 let filter_ok = match self.filter.as_ref() {
508 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
509 None => true,
510 };
511 let grep_ok = match self.grep.as_ref() {
512 Some(g) => g.matches(line),
513 None => true,
514 };
515 filter_ok && grep_ok
516 }
517
518 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
522 if !self.dim_mode {
523 return false;
524 }
525 if idx.records_mode() {
526 let r = idx.line_to_record(line_n);
527 let bytes = idx.record_bytes_stripped(r, src);
528 !self.line_passes(&bytes)
529 } else {
530 let bytes = idx.line_bytes_stripped(line_n, src);
531 !self.line_passes(&bytes)
532 }
533 }
534
535 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
536
537 pub fn follow_mode(&self) -> bool { self.follow_mode }
538
539 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
540
541 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
542
543 pub fn live_mode(&self) -> bool { self.live_mode }
544
545 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
546
547 pub fn set_prettify_label(&mut self, label: Option<String>) {
550 self.prettify_label = label;
551 }
552
553 pub fn set_format_label(&mut self, label: Option<String>) {
556 self.format_label = label;
557 }
558
559 pub fn invalidate_filter_cache(&mut self) {
564 self.visible_lines.clear();
565 self.visible_scanned = 0;
566 }
567
568 pub fn clamp_top_line(&mut self, line_count: usize) {
571 if line_count == 0 {
572 self.top_line = 0;
573 self.top_row = 0;
574 } else if self.top_line >= line_count {
575 self.top_line = line_count - 1;
576 self.top_row = 0;
577 }
578 }
579
580 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
584 let body = self.body_rows() as usize;
585 if self.hide_mode() {
586 let pos = self
588 .visible_lines
589 .iter()
590 .position(|&l| l >= self.top_line)
591 .unwrap_or(self.visible_lines.len());
592 pos + body >= self.visible_lines.len()
593 } else {
594 self.top_line + body >= idx.line_count()
595 }
596 }
597
598 fn gutter_width(&self, idx: &LineIndex) -> u16 {
600 if !self.show_line_numbers { return 0; }
601 let n = idx.line_count().max(1);
602 let digits = (n as f64).log10().floor() as u16 + 1;
603 digits + 1
604 }
605
606 fn render_opts(&self, gutter: u16) -> RenderOpts {
607 let mut o = self.opts.clone();
608 o.cols = self.cols.saturating_sub(gutter);
609 o.mode = self.ansi_mode;
610 o
611 }
612
613 pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
614 if self.hex_mode {
615 return self.frame_hex(src);
616 }
617 let body_rows = self.body_rows() as usize;
618 idx.extend_to_line(self.top_line + body_rows + 1, src);
619
620 let gutter = self.gutter_width(idx);
621 let r_opts = self.render_opts(gutter);
622
623 let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
627 reconstruct_render_state(src, idx, self.top_line)
628 } else {
629 crate::render::RenderState::default()
630 };
631 self.render_state = render_state.clone();
633 self.render_state_for = self.top_line;
634
635 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
636 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
637 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
638 let hide = self.hide_mode();
640 let total_lines = idx.line_count();
641
642 let mut hide_pos = if hide {
644 self.visible_lines
645 .iter()
646 .position(|&l| l >= self.top_line)
647 .unwrap_or(self.visible_lines.len())
648 } else {
649 0
650 };
651 let mut line_n = if hide {
652 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
653 } else {
654 self.top_line
655 };
656 let mut skip = if hide { 0 } else { self.top_row };
657
658 while body.len() < body_rows {
659 if line_n >= total_lines {
660 let mut row = Vec::with_capacity(self.cols as usize);
661 if gutter > 0 {
662 for _ in 0..gutter { row.push(Cell::Empty); }
663 }
664 while row.len() < self.cols as usize { row.push(Cell::Empty); }
665 body.push(row);
666 row_styles.push(RowStyle::Normal);
667 highlights.push(Vec::new());
668 line_n += 1;
669 continue;
670 }
671 let raw = src.bytes(idx.line_range(line_n, src));
674 let display_bytes = if let Some(r) = self.display.as_ref() {
675 match r.render_line(&raw) {
676 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
677 None => raw.clone(),
678 }
679 } else {
680 raw.clone()
681 };
682 let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
683 Some(&mut render_state)
684 } else {
685 None
686 };
687 let rows = render_line(&display_bytes, &r_opts, state_arg);
688 let style = if self.filter.is_some() || self.grep.is_some() {
689 if self.dim_mode {
690 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
691 } else {
692 RowStyle::Normal
694 }
695 } else {
696 RowStyle::Normal
697 };
698
699 for (i, mut content_row) in rows.into_iter().enumerate() {
700 if i < skip { continue; }
701 if body.len() >= body_rows { break; }
702 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
703 if gutter > 0 {
704 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
705 for c in label.chars() {
706 full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
707 }
708 }
709 full.append(&mut content_row);
710 let row_highlights = if let Some(s) = self.search.as_ref() {
714 find_row_highlights(&full, &s.regex)
715 } else {
716 Vec::new()
717 };
718 body.push(full);
719 row_styles.push(style);
720 highlights.push(row_highlights);
721 }
722 skip = 0;
723 if hide {
725 hide_pos += 1;
726 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
727 } else {
728 line_n += 1;
729 }
730 }
731
732 self.render_state_for = usize::MAX;
735
736 let status = self.format_status(idx, src);
737 Frame { body, row_styles, highlights, status }
738 }
739
740 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
741 if let Some(p) = self.prompt.as_ref() {
742 let ctx = self.build_prompt_context(idx, src);
743 return p.render(&ctx);
744 }
745 let body_rows = self.body_rows() as usize;
746 let total = idx.line_count();
747 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
750 let visible_total = self.visible_lines.len();
751 let cur = self
753 .visible_lines
754 .iter()
755 .position(|&l| l >= self.top_line)
756 .unwrap_or(visible_total);
757 let top = cur + 1;
758 let bottom = (cur + body_rows).min(visible_total.max(1));
759 let total_str = if src.is_complete() {
760 format!("{visible_total}/{total}")
761 } else {
762 format!("{visible_total}/{total}+")
763 };
764 (top, bottom, visible_total, total_str)
765 } else {
766 let top = self.top_line + 1;
767 let bottom = (self.top_line + body_rows).min(total.max(1));
768 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
769 (top, bottom, total, total_str)
770 };
771 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
772 let (line_prefix, records_block) = if idx.records_mode() {
774 let line_total = idx.line_count();
775 let rec_total = idx.record_count();
776 let rec_block = if line_total == 0 || rec_total == 0 {
777 format!("R0-0/{}", rec_total)
778 } else {
779 let rec_top = idx.line_to_record(self.top_line) + 1;
780 let rec_bottom = idx.line_to_record(bottom.saturating_sub(1)) + 1;
781 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
782 };
783 ("L", Some(rec_block))
784 } else {
785 ("", None)
786 };
787 let middle = match records_block {
788 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
789 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
790 };
791 let label_with_index = match self.file_index {
792 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
793 None => self.source_label.clone(),
794 };
795 let mut s = format!("{} {}", label_with_index, middle);
796 if !self.hide_mode() && self.top_row > 0 {
801 let line_rows = if total > 0 {
802 let bytes = self.line_display_bytes(src, idx, self.top_line);
803 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
804 } else { 1 };
805 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
806 }
807 if let Some(f) = self.filter.as_ref() {
808 s.push_str(&format!(" [{}]", f.format_name));
809 }
810 if self.grep.is_some() {
811 s.push_str(" [grep]");
812 }
813 if self.filter.is_some() || self.grep.is_some() {
814 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
815 }
816 if let Some(sr) = self.search.as_ref() {
817 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
818 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
819 }
820 if let Some(label) = self.prettify_label.as_ref() {
821 s.push_str(&format!(" [pretty:{label}]"));
822 }
823 if self.live_mode { s.push_str(" (L)"); }
824 if self.follow_mode { s.push_str(" (F)"); }
825 if let Some(msg) = self.preprocess_failure.as_ref() {
826 let first_line = msg.lines().next().unwrap_or("");
827 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
828 }
829 let tag_suffix = match &self.tag_active {
830 Some((name, cur, total)) if *total > 1 => {
831 format!(" [tag: {name} ({cur}/{total})]")
832 }
833 _ => String::new(),
834 };
835 s.push_str(&tag_suffix);
836 s
837 }
838
839 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
840 use crate::prompt::PromptContext;
841
842 let body_rows = self.body_rows() as usize;
843 let total = idx.line_count();
844 let top = self.top_line + 1;
845 let bottom = (self.top_line + body_rows).min(total.max(1));
846 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
847
848 let records_mode = idx.records_mode();
849 let (rec_top, rec_bottom, rec_total) = if records_mode {
850 let rt = idx.line_to_record(self.top_line) + 1;
851 let rb = idx.line_to_record(bottom.saturating_sub(1)) + 1;
852 (rt, rb, idx.record_count())
853 } else {
854 (0, 0, 0)
855 };
856
857 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
858 let line_rows = if total > 0 {
859 let bytes = self.line_display_bytes(src, idx, self.top_line);
860 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
861 } else { 1 };
862 format!("+{}/{}", self.top_row, line_rows)
863 } else {
864 String::new()
865 };
866
867 let format_tag = self.format_label.as_ref()
868 .map(|n| format!(" [{}]", n))
869 .unwrap_or_default();
870 let filter_tag = self.filter.as_ref()
871 .map(|f| format!(" [{}]", f.format_name))
872 .unwrap_or_default();
873 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
874 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
875 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
876 } else {
877 String::new()
878 };
879 let search_tag = self.search.as_ref()
880 .map(|s| {
881 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
882 format!(" [{}{}]", p, s.raw)
883 })
884 .unwrap_or_default();
885 let pretty_tag = self.prettify_label.as_ref()
886 .map(|l| format!(" [pretty:{l}]"))
887 .unwrap_or_default();
888 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
889 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
890 let preprocess_failed_tag = self.preprocess_failure.as_ref()
891 .map(|msg| {
892 let first_line = msg.lines().next().unwrap_or("");
893 format!(" [preprocess-failed: {}]", first_line)
894 })
895 .unwrap_or_default();
896
897 let file_index_tag = match self.file_index {
898 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
899 None => String::new(),
900 };
901
902 let tag_tag = match &self.tag_active {
903 Some((name, cur, total)) if *total > 1 => {
904 format!(" [tag: {name} ({cur}/{total})]")
905 }
906 _ => String::new(),
907 };
908
909 PromptContext {
910 label: self.source_label.clone(),
911 top,
912 bottom,
913 total,
914 pct: pct.min(100) as u8,
915 rec_top,
916 rec_bottom,
917 rec_total,
918 records_mode,
919 wrap_offset,
920 format_tag,
921 filter_tag,
922 grep_tag,
923 hide_tag,
924 search_tag,
925 pretty_tag,
926 live_tag,
927 follow_tag,
928 preprocess_failed_tag,
929 file_index_tag,
930 tag_tag,
931 }
932 }
933
934 fn frame_hex(&self, src: &dyn Source) -> Frame {
935 use crate::hex::format_hex_row;
936 use crate::render::{render_line, Cell, RenderOpts};
937
938 let body_rows = self.rows.saturating_sub(1) as usize;
939 let total_bytes = src.len();
940 let total_hex_rows = total_bytes.div_ceil(16);
941
942 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
943 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
944 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
945
946 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
947
948 for row_idx in 0..body_rows {
949 let hex_row = self.top_line + row_idx;
950 if hex_row >= total_hex_rows {
951 body.push(vec![Cell::Empty; self.cols as usize]);
952 } else {
953 let offset = hex_row * 16;
954 let end = (offset + 16).min(total_bytes);
955 let bytes_cow = src.bytes(offset..end);
956 let text = format_hex_row(offset, &bytes_cow);
957 let rows = render_line(text.as_bytes(), &opts, None);
958 body.push(rows.into_iter().next().unwrap_or_else(|| {
959 vec![Cell::Empty; self.cols as usize]
960 }));
961 }
962 row_styles.push(RowStyle::Normal);
963 highlights.push(Vec::new());
964 }
965
966 let status = self.format_status_hex(src);
967 Frame { body, row_styles, highlights, status }
968 }
969
970 fn format_status_hex(&self, src: &dyn Source) -> String {
971 let total_bytes = src.len();
972 let body_rows = self.rows.saturating_sub(1) as usize;
973 let top_byte = self.top_line * 16;
975 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
978 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
979 let label_with_index = match self.file_index {
980 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
981 None => self.source_label.clone(),
982 };
983 let tag_suffix = match &self.tag_active {
984 Some((name, cur, total)) if *total > 1 => {
985 format!(" [tag: {name} ({cur}/{total})]")
986 }
987 _ => String::new(),
988 };
989 format!(
990 "{} off {}-{}/{} {}% [hex]{}",
991 label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
992 )
993 }
994
995 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1000 if delta == 0 { return; }
1001 if self.hide_mode() {
1002 self.scroll_lines(delta, src, idx);
1003 return;
1004 }
1005 if delta > 0 {
1006 idx.extend_to_line(self.top_line + delta as usize + 1, src);
1007 let total = idx.line_count();
1008 if total == 0 { return; }
1009 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1010 self.top_line = target;
1011 self.top_row = 0;
1012 } else {
1013 let back = (-delta) as usize;
1014 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1019 let extra_back = back.saturating_sub(consumed_for_snap);
1020 self.top_line = self.top_line.saturating_sub(extra_back);
1021 self.top_row = 0;
1022 }
1023 }
1024
1025 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1026 if delta == 0 { return; }
1027 if self.hide_mode() {
1028 self.extend_visible_lines(idx, src);
1032 let total = self.visible_lines.len();
1033 if total == 0 {
1034 self.top_line = 0;
1035 self.top_row = 0;
1036 return;
1037 }
1038 let cur = self
1039 .visible_lines
1040 .iter()
1041 .position(|&l| l >= self.top_line)
1042 .unwrap_or(total);
1043 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1044 self.top_line = self.visible_lines[new];
1045 self.top_row = 0;
1046 return;
1047 }
1048 if delta > 0 {
1049 let mut remaining = delta as usize;
1050 while remaining > 0 {
1051 idx.extend_to_line(self.top_line + 1, src);
1052 let total = idx.line_count();
1053 if total == 0 { break; }
1054 let bytes = self.line_display_bytes(src, idx, self.top_line);
1055 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1056 if self.top_row + 1 < line_rows {
1057 self.top_row += 1;
1058 } else if self.top_line + 1 < total {
1059 self.top_row = 0;
1060 self.top_line += 1;
1061 } else {
1062 break;
1063 }
1064 remaining -= 1;
1065 }
1066 } else {
1067 let mut remaining = (-delta) as usize;
1068 while remaining > 0 {
1069 if self.top_row > 0 {
1070 self.top_row -= 1;
1071 } else if self.top_line > 0 {
1072 self.top_line -= 1;
1073 let bytes = self.line_display_bytes(src, idx, self.top_line);
1074 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1075 self.top_row = line_rows.saturating_sub(1);
1076 } else {
1077 break;
1078 }
1079 remaining -= 1;
1080 }
1081 }
1082 }
1083
1084 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1085 let n = self.body_rows() as i64;
1086 self.scroll_lines(n, src, idx);
1087 }
1088
1089 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1090 let n = self.body_rows() as i64;
1091 self.scroll_lines(-n, src, idx);
1092 }
1093
1094 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1095 let n = (self.body_rows() / 2).max(1) as i64;
1096 self.scroll_lines(n, src, idx);
1097 }
1098
1099 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1100 let n = (self.body_rows() / 2).max(1) as i64;
1101 self.scroll_lines(-n, src, idx);
1102 }
1103
1104 pub fn goto_top(&mut self) {
1105 self.top_line = 0;
1106 self.top_row = 0;
1107 }
1108
1109 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1110 idx.extend_to_end(src);
1111 let body = self.body_rows() as usize;
1112 if self.hide_mode() {
1113 self.extend_visible_lines(idx, src);
1114 let total = self.visible_lines.len();
1115 let target_visible = total.saturating_sub(body);
1116 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1117 self.top_row = 0;
1118 } else {
1119 let total = idx.line_count();
1120 self.top_line = total.saturating_sub(body);
1121 self.top_row = 0;
1122 }
1123 }
1124
1125 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1127 idx.extend_to_line(n, src);
1128 let target = n.min(idx.line_count().saturating_sub(1));
1129 self.top_line = target;
1130 self.top_row = 0;
1131 }
1132
1133 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1135 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1139 idx.extend_to_end(src);
1140 }
1141 if idx.record_count() == 0 {
1142 return;
1143 }
1144 let target = n.min(idx.record_count().saturating_sub(1));
1145 let line_range = idx.record_line_range(target);
1146 self.top_line = line_range.start;
1147 self.top_row = 0;
1148 }
1149
1150 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1153 let p = p.min(100) as usize;
1154 let target_byte = src.len().saturating_mul(p) / 100;
1155 idx.extend_to_byte_for_query(src, target_byte);
1156 let line_n = idx.line_at_byte(target_byte)
1157 .or_else(|| {
1158 let lc = idx.line_count();
1160 if lc > 0 { Some(lc - 1) } else { None }
1161 })
1162 .unwrap_or(0);
1163 self.top_line = line_n;
1164 self.top_row = 0;
1165 }
1166
1167 pub fn top_line(&self) -> usize {
1169 self.top_line
1170 }
1171
1172 pub fn resize(&mut self, cols: u16, rows: u16) {
1173 self.cols = cols.max(1);
1174 self.rows = rows.max(2);
1175 self.opts.cols = self.cols;
1176 }
1177
1178 pub fn toggle_line_numbers(&mut self) {
1179 self.show_line_numbers = !self.show_line_numbers;
1180 }
1181
1182 pub fn toggle_chop(&mut self) {
1183 self.opts.wrap = !self.opts.wrap;
1184 }
1185
1186 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use super::*;
1195 use crate::source::MockSource;
1196
1197 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1198 let m = MockSource::new();
1199 m.append(content);
1200 m.finish();
1201 let idx = LineIndex::new();
1202 (m, idx)
1203 }
1204
1205 #[test]
1206 fn frame_renders_body_height_rows() {
1207 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1208 let mut v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1210 assert_eq!(frame.body.len(), 4);
1211 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1212 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1213 }
1214
1215 #[test]
1216 fn scroll_down_advances_top_line() {
1217 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1218 let mut v = Viewport::new(10, 5, "test".into());
1219 v.scroll_lines(2, &m, &mut idx);
1220 assert_eq!(v.top_line, 2);
1221 assert_eq!(v.top_row, 0);
1222 }
1223
1224 #[test]
1225 fn scroll_up_clamps_at_zero() {
1226 let (m, mut idx) = setup(b"a\nb\nc\n");
1227 let mut v = Viewport::new(10, 5, "test".into());
1228 v.scroll_lines(-5, &m, &mut idx);
1229 assert_eq!(v.top_line, 0);
1230 assert_eq!(v.top_row, 0);
1231 }
1232
1233 #[test]
1234 fn scroll_down_clamps_at_last_line() {
1235 let (m, mut idx) = setup(b"a\nb\nc\n");
1236 let mut v = Viewport::new(10, 5, "test".into());
1237 v.scroll_lines(50, &m, &mut idx);
1238 assert_eq!(v.top_line, 2);
1239 }
1240
1241 #[test]
1242 fn scroll_logical_lines_skips_wrap_rows() {
1243 let mut content = vec![b'X'; 500];
1245 content.push(b'\n');
1246 content.extend_from_slice(b"second\n");
1247 content.extend_from_slice(b"third\n");
1248 let (m, mut idx) = setup(&content);
1249 let mut v = Viewport::new(10, 8, "f".into());
1250 v.scroll_logical_lines(1, &m, &mut idx);
1251 assert_eq!((v.top_line, v.top_row), (1, 0));
1252 v.scroll_logical_lines(1, &m, &mut idx);
1253 assert_eq!((v.top_line, v.top_row), (2, 0));
1254 }
1255
1256 #[test]
1257 fn scroll_logical_lines_back_snaps_to_line_start() {
1258 let mut content = vec![b'A'; 50];
1260 content.push(b'\n');
1261 content.extend_from_slice(&[b'B'; 50]);
1262 content.push(b'\n');
1263 let (m, mut idx) = setup(&content);
1264 let mut v = Viewport::new(10, 8, "f".into());
1265 v.scroll_lines(7, &m, &mut idx);
1266 assert_eq!(v.top_line, 1, "should be on line 1");
1267 assert!(v.top_row > 0, "should be inside line 1's wraps");
1268 v.scroll_logical_lines(-1, &m, &mut idx);
1269 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1270 v.scroll_logical_lines(-1, &m, &mut idx);
1271 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1272 }
1273
1274 #[test]
1275 fn scroll_down_walks_wraps_of_last_line() {
1276 let mut content = b"first\n".to_vec();
1278 content.extend_from_slice(&[b'X'; 30]);
1279 content.push(b'\n');
1280 let (m, mut idx) = setup(&content);
1281 let mut v = Viewport::new(10, 5, "f".into());
1282 v.scroll_lines(1, &m, &mut idx);
1283 assert_eq!((v.top_line, v.top_row), (1, 0));
1284 v.scroll_lines(1, &m, &mut idx);
1285 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1286 v.scroll_lines(1, &m, &mut idx);
1287 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1288 }
1289
1290 #[test]
1291 fn scroll_down_walks_wrap_rows_within_long_line() {
1292 let mut content = vec![b'X'; 30];
1294 content.push(b'\n');
1295 content.extend_from_slice(b"second\n");
1296 let (m, mut idx) = setup(&content);
1297 let mut v = Viewport::new(10, 5, "f".into());
1298 v.scroll_lines(1, &m, &mut idx);
1299 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1300 v.scroll_lines(1, &m, &mut idx);
1301 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1302 v.scroll_lines(1, &m, &mut idx);
1303 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1304 }
1305
1306 #[test]
1307 fn status_line_shows_range_and_pct() {
1308 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1309 let mut v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1311 assert!(frame.status.starts_with("f 1-4/10"));
1312 }
1313
1314 #[test]
1315 fn page_down_advances_by_body_rows() {
1316 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1317 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1319 assert_eq!(v.top_line, 4);
1320 }
1321
1322 #[test]
1323 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1324 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1325 let mut v = Viewport::new(10, 5, "f".into());
1326 v.page_down(&m, &mut idx);
1327 v.page_up(&m, &mut idx);
1328 assert_eq!(v.top_line, 0);
1329 assert_eq!(v.top_row, 0);
1330 }
1331
1332 #[test]
1333 fn half_page_down_advances_by_half_body() {
1334 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1335 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1337 assert_eq!(v.top_line, 3);
1338 }
1339
1340 #[test]
1341 fn goto_top_resets_position() {
1342 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1343 let mut v = Viewport::new(10, 5, "f".into());
1344 v.scroll_lines(2, &m, &mut idx);
1345 v.goto_top();
1346 assert_eq!(v.top_line, 0);
1347 assert_eq!(v.top_row, 0);
1348 }
1349
1350 #[test]
1351 fn goto_bottom_scrolls_to_last_page() {
1352 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1353 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1355 assert_eq!(v.top_line, 6);
1357 }
1358
1359 #[test]
1360 fn goto_line_positions_top_line() {
1361 let m = MockSource::new();
1362 m.append(b"a\nb\nc\nd\ne\n");
1363 let mut idx = LineIndex::new();
1364 idx.extend_to_end(&m);
1365 let mut v = Viewport::new(20, 5, "f".into());
1366 v.goto_line(3, &m, &mut idx);
1367 assert_eq!(v.top_line(), 3);
1368 }
1369
1370 #[test]
1371 fn goto_line_clamps_to_last_line() {
1372 let m = MockSource::new();
1373 m.append(b"a\nb\n");
1374 let mut idx = LineIndex::new();
1375 idx.extend_to_end(&m);
1376 let mut v = Viewport::new(20, 5, "f".into());
1377 v.goto_line(999, &m, &mut idx);
1378 assert_eq!(v.top_line(), 1);
1379 }
1380
1381 #[test]
1382 fn goto_record_positions_at_record_start_line() {
1383 let m = MockSource::new();
1384 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1385 let mut idx = LineIndex::new();
1386 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1387 idx.extend_to_end(&m);
1388 let mut v = Viewport::new(20, 5, "f".into());
1389 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1391 }
1392
1393 #[test]
1394 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1395 let m = MockSource::new();
1396 m.append(b"a\nb\nc\n");
1397 let mut idx = LineIndex::new();
1398 idx.extend_to_end(&m);
1399 let mut v = Viewport::new(20, 5, "f".into());
1400 v.goto_record(2, &m, &mut idx);
1401 assert_eq!(v.top_line(), 2);
1402 }
1403
1404 #[test]
1405 fn goto_percent_50_lands_in_middle() {
1406 let m = MockSource::new();
1407 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1409 idx.extend_to_end(&m);
1410 let mut v = Viewport::new(20, 5, "f".into());
1411 v.goto_percent(50, &m, &mut idx);
1412 assert_eq!(v.top_line(), 2); }
1414
1415 #[test]
1416 fn goto_percent_100_lands_at_last_line() {
1417 let m = MockSource::new();
1418 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1420 idx.extend_to_end(&m);
1421 let mut v = Viewport::new(20, 5, "f".into());
1422 v.goto_percent(100, &m, &mut idx);
1423 assert_eq!(v.top_line(), 2);
1424 }
1425
1426 #[test]
1427 fn goto_percent_0_lands_at_first_line() {
1428 let m = MockSource::new();
1429 m.append(b"a\nb\nc\n");
1430 let mut idx = LineIndex::new();
1431 idx.extend_to_end(&m);
1432 let mut v = Viewport::new(20, 5, "f".into());
1433 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1435 v.goto_percent(0, &m, &mut idx);
1436 assert_eq!(v.top_line(), 0);
1437 }
1438
1439 #[test]
1440 fn resize_updates_dimensions_and_render_opts() {
1441 let (m, mut idx) = setup(b"1\n2\n");
1442 let mut v = Viewport::new(10, 5, "f".into());
1443 v.resize(40, 12);
1444 assert_eq!(v.cols, 40);
1445 assert_eq!(v.rows, 12);
1446 assert_eq!(v.opts.cols, 40);
1447 let _ = v.frame(&m, &mut idx);
1448 }
1449
1450 #[test]
1451 fn toggle_line_numbers_changes_gutter() {
1452 let (m, mut idx) = setup(b"a\nb\nc\n");
1453 let mut v = Viewport::new(10, 5, "f".into());
1454 let frame_off = v.frame(&m, &mut idx);
1455 v.toggle_line_numbers();
1456 let frame_on = v.frame(&m, &mut idx);
1457 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1459 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1460 }
1461
1462 #[test]
1463 fn toggle_chop_changes_wrap_mode() {
1464 let (m, mut idx) = setup(b"abcdefghij\n");
1465 let mut v = Viewport::new(4, 5, "f".into());
1466 v.toggle_chop();
1467 let frame = v.frame(&m, &mut idx);
1468 assert_eq!(frame.body[0][..4],
1471 [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1472 Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1473 Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1474 Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1475 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1477 }
1478
1479 #[test]
1482 fn is_at_bottom_initially_only_when_source_fits() {
1483 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1486 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1487 }
1488
1489 #[test]
1490 fn is_at_bottom_false_when_top_and_more_lines_below() {
1491 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);
1494 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1495 }
1496
1497 #[test]
1498 fn is_at_bottom_true_after_goto_bottom() {
1499 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1500 let mut v = Viewport::new(10, 5, "f".into());
1501 v.goto_bottom(&m, &mut idx);
1502 assert!(v.is_at_bottom(&idx));
1503 }
1504
1505 #[test]
1506 fn status_shows_follow_suffix_when_follow_mode_on() {
1507 let (m, mut idx) = setup(b"a\nb\n");
1508 let mut v = Viewport::new(20, 5, "f".into());
1509 let frame_off = v.frame(&m, &mut idx);
1510 assert!(!frame_off.status.contains("(F)"));
1511 v.set_follow_mode(true);
1512 let frame_on = v.frame(&m, &mut idx);
1513 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1514 }
1515
1516 #[test]
1517 fn toggle_follow_flips_state() {
1518 let mut v = Viewport::new(10, 5, "f".into());
1519 assert!(!v.follow_mode());
1520 v.toggle_follow();
1521 assert!(v.follow_mode());
1522 v.toggle_follow();
1523 assert!(!v.follow_mode());
1524 }
1525
1526 #[test]
1527 fn status_shows_prettify_label_when_set() {
1528 let (m, mut idx) = setup(b"a\n");
1529 let mut v = Viewport::new(40, 5, "f".into());
1530 let frame_off = v.frame(&m, &mut idx);
1531 assert!(!frame_off.status.contains("[pretty"));
1532 v.set_prettify_label(Some("json".into()));
1533 let frame_on = v.frame(&m, &mut idx);
1534 assert!(frame_on.status.contains("[pretty:json]"),
1535 "expected [pretty:json] in status, got: {}", frame_on.status);
1536 v.set_prettify_label(Some("json:err".into()));
1537 let frame_err = v.frame(&m, &mut idx);
1538 assert!(frame_err.status.contains("[pretty:json:err]"),
1539 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1540 }
1541
1542 #[test]
1543 fn status_shows_l_suffix_when_live_mode_on() {
1544 let (m, mut idx) = setup(b"a\nb\n");
1545 let mut v = Viewport::new(20, 5, "f".into());
1546 let frame_off = v.frame(&m, &mut idx);
1547 assert!(!frame_off.status.contains("(L)"));
1548 v.set_live_mode(true);
1549 let frame_on = v.frame(&m, &mut idx);
1550 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1551 }
1552
1553 #[test]
1554 fn clamp_top_line_pulls_back_when_total_shrinks() {
1555 let mut v = Viewport::new(20, 5, "f".into());
1556 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1565 let (m, mut idx) = setup(b"only\n");
1567 let _ = v.frame(&m, &mut idx);
1568 }
1569
1570 fn simulate_growth_tick(
1573 v: &mut Viewport,
1574 src: &MockSource,
1575 idx: &mut LineIndex,
1576 ) {
1577 if !v.follow_mode() { return; }
1578 let was_at_bottom = v.is_at_bottom(idx);
1579 let lines_before = idx.line_count();
1580 idx.notice_new_bytes(src);
1581 if idx.line_count() != lines_before && was_at_bottom {
1582 v.goto_bottom(src, idx);
1583 }
1584 }
1585
1586 #[test]
1587 fn auto_scroll_engages_when_at_bottom() {
1588 let m = MockSource::new();
1589 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1591 let mut v = Viewport::new(10, 5, "f".into());
1592 v.set_follow_mode(true);
1593 idx.extend_to_end(&m);
1594 assert!(v.is_at_bottom(&idx));
1595 let top_before = {
1596 let f = v.frame(&m, &mut idx);
1597 f.status.clone() };
1599 let _ = top_before;
1600 m.append(b"5\n6\n7\n8\n");
1602 simulate_growth_tick(&mut v, &m, &mut idx);
1603 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1605 let frame = v.frame(&m, &mut idx);
1606 let last_row = &frame.body[frame.body.len() - 1];
1609 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1610 }
1611
1612 #[test]
1613 fn auto_scroll_suppressed_when_scrolled_up() {
1614 let m = MockSource::new();
1615 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1617 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1619 idx.extend_to_end(&m);
1620 v.goto_bottom(&m, &mut idx);
1621 v.scroll_lines(-2, &m, &mut idx);
1623 assert!(!v.is_at_bottom(&idx));
1624 let frame_before = v.frame(&m, &mut idx);
1625 let top_first_cell_before = frame_before.body[0][0].clone();
1626 m.append(b"9\n10\n");
1628 simulate_growth_tick(&mut v, &m, &mut idx);
1629 let frame_after = v.frame(&m, &mut idx);
1631 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1632 }
1633
1634 #[test]
1637 fn set_search_compiles_regex() {
1638 let mut v = Viewport::new(10, 5, "f".into());
1639 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1640 assert!(v.search_active());
1641 }
1642
1643 #[test]
1644 fn set_search_rejects_bad_regex() {
1645 let mut v = Viewport::new(10, 5, "f".into());
1646 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1647 assert!(!err.is_empty());
1648 assert!(!v.search_active(), "no search should be set on error");
1649 }
1650
1651 #[test]
1652 fn search_step_forward_finds_match_after_top() {
1653 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1654 let mut v = Viewport::new(20, 5, "f".into());
1655 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1656 let found = v.search_repeat(&m, &mut idx, false);
1657 assert!(found);
1658 assert_eq!(v.top_line, 2);
1660 }
1661
1662 #[test]
1663 fn search_step_backward_finds_match_before_top() {
1664 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1665 let mut v = Viewport::new(20, 5, "f".into());
1666 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1668 let found = v.search_repeat(&m, &mut idx, false);
1669 assert!(found);
1670 assert_eq!(v.top_line, 0);
1671 }
1672
1673 #[test]
1674 fn search_wraps_at_end() {
1675 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1676 let mut v = Viewport::new(20, 5, "f".into());
1677 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1679 let found = v.search_repeat(&m, &mut idx, false);
1680 assert!(found, "search should wrap forward past EOF");
1681 assert_eq!(v.top_line, 0);
1682 }
1683
1684 #[test]
1685 fn search_no_match_returns_false_and_does_not_move() {
1686 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1687 let mut v = Viewport::new(20, 5, "f".into());
1688 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1689 let found = v.search_repeat(&m, &mut idx, false);
1690 assert!(!found);
1691 assert_eq!(v.top_line, 0);
1692 }
1693
1694 #[test]
1695 fn frame_records_highlight_ranges_for_matches() {
1696 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1697 let mut v = Viewport::new(20, 5, "f".into());
1698 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1699 let frame = v.frame(&m, &mut idx);
1700 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1702 assert!(frame.highlights[0].is_empty());
1703 assert!(frame.highlights[1].is_empty());
1704 assert_eq!(frame.highlights[2], vec![0..5]);
1705 assert!(frame.highlights[3].is_empty());
1706 }
1707
1708 #[test]
1709 fn frame_highlights_substring_inside_a_row() {
1710 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1711 let mut v = Viewport::new(40, 5, "f".into());
1712 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1713 let frame = v.frame(&m, &mut idx);
1714 assert_eq!(frame.highlights[0], vec![18..22]);
1716 assert!(frame.highlights[1].is_empty());
1717 }
1718
1719 #[test]
1720 fn search_highlight_with_filter_dim_keeps_row_dim() {
1721 let (m, mut idx) = setup(b"alpha\nbeta\n");
1724 let mut v = Viewport::new(20, 5, "f".into());
1725 let fmt = crate::format::LogFormat::compile(
1726 "simple",
1727 r"^(?P<line>.+)$",
1728 )
1729 .unwrap();
1730 let f = crate::filter::CompiledFilter::compile(
1731 &fmt,
1732 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1733 )
1734 .unwrap();
1735 v.set_filter(Some(f));
1736 v.set_dim_mode(true);
1737 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1738 let frame = v.frame(&m, &mut idx);
1739 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1740 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1741 assert_eq!(frame.highlights[1], vec![0..4]);
1742 }
1743
1744 #[test]
1745 fn grep_only_hides_non_matching_lines() {
1746 use crate::grep::GrepPredicate;
1747 let src = crate::source::MockSource::new();
1748 src.append(b"keep this error\n");
1749 src.append(b"drop this one\n");
1750 src.append(b"another error line\n");
1751 src.finish();
1752 let mut idx = crate::line_index::LineIndex::new();
1753 idx.extend_to_end(&src);
1754
1755 let mut v = Viewport::new(40, 5, "test".into());
1756 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1757 v.extend_visible_lines(&idx, &src);
1758
1759 let frame = v.frame(&src, &mut idx);
1761 let body_text: Vec<String> = frame.body.iter()
1762 .map(|row| row.iter().filter_map(|c| match c {
1763 crate::render::Cell::Char { ch, .. } => Some(*ch),
1764 _ => None,
1765 }).collect())
1766 .collect();
1767 assert!(body_text[0].contains("keep this error"));
1768 assert!(body_text[1].contains("another error line"));
1769 assert!(frame.status.contains("[grep]"));
1770 }
1771
1772 #[test]
1773 fn filter_and_grep_combine_with_and() {
1774 use crate::grep::GrepPredicate;
1775 let fmt = crate::format::LogFormat::compile(
1776 "simple",
1777 r"^(?P<level>\w+) (?P<msg>.+)$",
1778 ).unwrap();
1779 let f = crate::filter::CompiledFilter::compile(
1780 &fmt,
1781 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1782 ).unwrap();
1783 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1784
1785 let src = crate::source::MockSource::new();
1786 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();
1791 let mut idx = crate::line_index::LineIndex::new();
1792 idx.extend_to_end(&src);
1793
1794 let mut v = Viewport::new(80, 5, "test".into());
1795 v.set_filter(Some(f));
1796 v.set_grep(Some(g));
1797 v.extend_visible_lines(&idx, &src);
1798 assert_eq!(v.visible_lines(), &[0usize]);
1799 }
1800
1801 #[test]
1802 fn search_status_shows_pattern() {
1803 let (m, mut idx) = setup(b"x\n");
1804 let mut v = Viewport::new(20, 5, "f".into());
1805 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1806 let frame = v.frame(&m, &mut idx);
1807 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1808 }
1809
1810 #[test]
1811 fn repeat_search_after_first_match_advances() {
1812 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1813 let mut v = Viewport::new(40, 5, "f".into());
1814 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1815 assert!(v.search_repeat(&m, &mut idx, false));
1816 assert_eq!(v.top_line, 1, "first foo");
1817 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1818 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1819 assert_eq!(v.top_line, 3, "should advance to next foo");
1820 }
1821
1822 #[test]
1823 fn auto_scroll_paused_when_follow_off() {
1824 let m = MockSource::new();
1825 m.append(b"1\n2\n3\n4\n");
1826 let mut idx = LineIndex::new();
1827 let mut v = Viewport::new(10, 5, "f".into());
1828 idx.extend_to_end(&m);
1830 let frame_before = v.frame(&m, &mut idx);
1831 let top_first_cell = frame_before.body[0][0].clone();
1832 m.append(b"5\n6\n7\n8\n");
1833 simulate_growth_tick(&mut v, &m, &mut idx);
1834 let frame_after = v.frame(&m, &mut idx);
1835 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1836 }
1837
1838 #[test]
1841 fn search_jumps_to_next_matching_record() {
1842 let m = MockSource::new();
1843 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1844 let mut idx = LineIndex::new();
1845 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1846 idx.extend_to_end(&m);
1847 let mut v = Viewport::new(40, 10, "f".into());
1848 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1849 let hit = v.search_repeat(&m, &mut idx, false);
1850 assert!(hit, "should find 'charlie' in record 2");
1851 assert_eq!(v.top_line(), 3); }
1853
1854 #[test]
1855 fn search_finds_cross_line_match_in_record_with_s_flag() {
1856 let m = MockSource::new();
1857 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1858 let mut idx = LineIndex::new();
1859 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1860 idx.extend_to_end(&m);
1861 let mut v = Viewport::new(40, 10, "f".into());
1862 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1863 let hit = v.search_repeat(&m, &mut idx, false);
1864 assert!(hit, "should match across \\n inside record 0 with (?s)");
1865 assert_eq!(v.top_line(), 0);
1866 }
1867
1868 #[test]
1869 fn search_repeat_with_no_match_returns_false() {
1870 let m = MockSource::new();
1871 m.append(b"[1] alpha\n[2] bravo\n");
1872 let mut idx = LineIndex::new();
1873 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1874 idx.extend_to_end(&m);
1875 let mut v = Viewport::new(40, 10, "f".into());
1876 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1877 let hit = v.search_repeat(&m, &mut idx, false);
1878 assert!(!hit);
1879 }
1880
1881 #[test]
1884 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1885 let m = MockSource::new();
1888 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1889 let mut idx = LineIndex::new();
1890 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1891 idx.extend_to_end(&m);
1892 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1893 let mut v = Viewport::new(40, 10, "f".into());
1894 v.set_grep(Some(grep));
1895 v.extend_visible_lines(&idx, &m);
1896 assert_eq!(v.visible_lines(), &[0usize, 1]);
1899 }
1900
1901 #[test]
1902 fn grep_matches_across_record_newlines_in_records_mode() {
1903 let m = MockSource::new();
1905 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
1906 let mut idx = LineIndex::new();
1907 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1908 idx.extend_to_end(&m);
1909 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
1910 let mut v = Viewport::new(40, 10, "f".into());
1911 v.set_grep(Some(grep));
1912 v.extend_visible_lines(&idx, &m);
1913 assert_eq!(v.visible_lines(), &[0usize, 1]);
1915 }
1916
1917 #[test]
1918 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
1919 let m = MockSource::new();
1922 m.append(b"[1] head\n cont\n[2] other\n cont\n");
1923 let mut idx = LineIndex::new();
1924 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1925 idx.extend_to_end(&m);
1926 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
1927 let mut v = Viewport::new(40, 10, "f".into());
1928 v.set_grep(Some(grep));
1929 v.set_dim_mode(true);
1930 v.extend_visible_lines(&idx, &m);
1931 assert_eq!(v.visible_lines(), &[] as &[usize]);
1933 assert!(!v.should_dim_line(0, &idx, &m));
1935 assert!(!v.should_dim_line(1, &idx, &m));
1936 assert!(v.should_dim_line(2, &idx, &m));
1938 assert!(v.should_dim_line(3, &idx, &m));
1939 }
1940
1941 #[test]
1942 fn status_unchanged_when_records_inactive() {
1943 let (m, mut idx) = setup(b"a\nb\nc\n");
1944 let mut v = Viewport::new(20, 5, "f".into());
1945 let frame = v.frame(&m, &mut idx);
1946 let status = &frame.status;
1947 assert!(status.contains("1-3/3"), "got: {status}");
1949 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
1950 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
1951 }
1952
1953 #[test]
1954 fn status_dual_readout_when_records_active() {
1955 let m = MockSource::new();
1956 m.append(b"[1] a\n cont\n[2] b\n");
1957 m.finish();
1958 let mut idx = LineIndex::new();
1959 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1960 idx.extend_to_end(&m);
1961 let mut v = Viewport::new(20, 5, "f".into());
1962 let frame = v.frame(&m, &mut idx);
1963 let status = &frame.status;
1964 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
1965 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
1966 }
1967
1968 #[test]
1969 fn format_status_uses_custom_template_when_set() {
1970 let m = MockSource::new();
1971 m.append(b"a\nb\nc\n");
1972 m.finish();
1973 let mut idx = LineIndex::new();
1974 idx.extend_to_end(&m);
1975 let mut v = Viewport::new(20, 5, "f".into());
1976 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
1977 v.set_prompt(Some(prompt));
1978 let frame = v.frame(&m, &mut idx);
1979 assert_eq!(frame.status, "f 100%");
1980 }
1981
1982 #[test]
1983 fn status_shows_preprocess_failed_tag_when_set() {
1984 let m = MockSource::new();
1985 m.append(b"a\n");
1986 let mut idx = LineIndex::new();
1987 idx.extend_to_end(&m);
1988 let mut v = Viewport::new(40, 5, "f".into());
1989 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
1990 let frame = v.frame(&m, &mut idx);
1991 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
1992 "got: {}", frame.status);
1993 }
1994
1995 #[test]
1996 fn status_shows_file_index_when_multifile() {
1997 let m = MockSource::new();
1998 m.append(b"a\n");
1999 let mut idx = LineIndex::new();
2000 idx.extend_to_end(&m);
2001 let mut v = Viewport::new(60, 5, "f.log".into());
2002 v.set_file_index(0, 3);
2003 let frame = v.frame(&m, &mut idx);
2004 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
2005 }
2006
2007 #[test]
2008 fn status_omits_file_index_when_single_file() {
2009 let m = MockSource::new();
2010 m.append(b"a\n");
2011 let mut idx = LineIndex::new();
2012 idx.extend_to_end(&m);
2013 let mut v = Viewport::new(60, 5, "f.log".into());
2014 v.set_file_index(0, 1);
2015 let frame = v.frame(&m, &mut idx);
2016 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2017 }
2018
2019 #[test]
2020 fn status_shows_tag_active_when_multimatch() {
2021 let m = MockSource::new();
2022 m.append(b"a\n");
2023 let mut idx = LineIndex::new();
2024 idx.extend_to_end(&m);
2025 let mut v = Viewport::new(80, 5, "f.log".into());
2026 v.set_tag_active(Some(("foo".into(), 2, 3)));
2027 let frame = v.frame(&m, &mut idx);
2028 assert!(
2029 frame.status.contains("[tag: foo (2/3)]"),
2030 "got: {}",
2031 frame.status
2032 );
2033 }
2034
2035 #[test]
2036 fn status_omits_tag_active_when_single_match() {
2037 let m = MockSource::new();
2038 m.append(b"a\n");
2039 let mut idx = LineIndex::new();
2040 idx.extend_to_end(&m);
2041 let mut v = Viewport::new(80, 5, "f.log".into());
2042 v.set_tag_active(Some(("foo".into(), 1, 1)));
2043 let frame = v.frame(&m, &mut idx);
2044 assert!(
2045 !frame.status.contains("[tag:"),
2046 "should not show indicator for single match: {}",
2047 frame.status
2048 );
2049 }
2050
2051 #[test]
2054 fn reconstruct_picks_up_state_from_prior_lines() {
2055 let m = MockSource::new();
2056 m.append(b"\x1b[31mline 1\n");
2057 m.append(b"line 2 (still red, no reset)\n");
2058 m.append(b"line 3\n");
2059 let mut idx = LineIndex::new();
2060 idx.extend_to_end(&m);
2061 let state = reconstruct_render_state(&m, &idx, 2);
2062 assert_eq!(
2063 state.style.fg,
2064 Some(crate::ansi::Color::Ansi(1)),
2065 "red SGR from line 0 should persist to line 2"
2066 );
2067 }
2068
2069 #[test]
2070 fn reconstruct_respects_reset_between_lines() {
2071 let m = MockSource::new();
2072 m.append(b"\x1b[31mline 1\x1b[0m\n");
2073 m.append(b"line 2 (default)\n");
2074 let mut idx = LineIndex::new();
2075 idx.extend_to_end(&m);
2076 let state = reconstruct_render_state(&m, &idx, 1);
2077 assert_eq!(state.style.fg, None);
2078 }
2079
2080 #[test]
2081 fn reconstruct_caps_walkback_at_max_lines() {
2082 let m = MockSource::new();
2083 m.append(b"\x1b[31mvery early\n");
2084 for _ in 0..300 {
2085 m.append(b"line\n");
2086 }
2087 let mut idx = LineIndex::new();
2088 idx.extend_to_end(&m);
2089 let state = reconstruct_render_state(&m, &idx, 290);
2092 assert_eq!(state.style.fg, None);
2093 }
2094}