Skip to main content

tess/
viewport.rs

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
11/// Maximum number of lines to walk backwards when reconstructing SGR state
12/// for a scroll-up. Picked to comfortably cover a screen-height plus
13/// headroom; bounds cost so that scrolling in huge files stays snappy.
14const MAX_RECONSTRUCT_LINES: usize = 256;
15
16/// Reconstruct the SGR state at the start of `target_line` by walking up
17/// to MAX_RECONSTRUCT_LINES lines back and replaying byte-by-byte through
18/// the ANSI parser. Lines beyond the cap are skipped: if there's an
19/// unclosed SGR more than 256 lines above the top, the reconstruction starts
20/// from default — first visible lines may render in default colors until a
21/// reset appears (rare for normal log files).
22fn 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
44/// Build the rendered text of a display row plus a `starts` table mapping
45/// each char index in that text back to its starting cell column. The last
46/// entry is a sentinel pointing one past the row's width, so a match's
47/// `[char_start, char_end)` translates to the cell range
48/// `starts[char_start]..starts[char_end]`.
49fn 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
69/// Find every regex match in the rendered text of a row, translating each
70/// True when the byte slice contains only whitespace (space, tab, CR, LF)
71/// or is empty. Used by `-s` / `--squeeze-blank-lines` to detect runs of
72/// blank lines at frame-composition time.
73fn line_is_blank(bytes: &[u8]) -> bool {
74    bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
75}
76
77/// to a cell column range. Empty matches are dropped. Trailing-padding
78/// spaces on a row would otherwise satisfy patterns like `\s+`; we trim
79/// those by clamping match ends to where actual content stops.
80fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
81    if row.is_empty() {
82        return Vec::new();
83    }
84    let last_content_col = row
85        .iter()
86        .enumerate()
87        .rev()
88        .find_map(|(c, cell)| match cell {
89            Cell::Char { width, .. } => Some(c + *width as usize),
90            Cell::Continuation => Some(c + 1),
91            Cell::Empty => None,
92        })
93        .unwrap_or(0);
94    if last_content_col == 0 {
95        return Vec::new();
96    }
97    let (text, starts) = row_text_and_starts(row);
98    let mut out = Vec::new();
99    for m in regex.find_iter(&text) {
100        if m.start() == m.end() {
101            continue;
102        }
103        let char_start = text[..m.start()].chars().count();
104        let char_end = text[..m.end()].chars().count();
105        if char_start >= starts.len() - 1 || char_end <= char_start {
106            continue;
107        }
108        let col_start = starts[char_start];
109        let col_end = starts[char_end].min(last_content_col);
110        if col_end > col_start {
111            out.push(col_start..col_end);
112        }
113    }
114    out
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RowStyle {
119    Normal,
120    /// Render with a reduced-emphasis terminal attribute. Used by `--dim` to
121    /// keep filtered-out lines visible as context.
122    Dim,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum SearchDirection {
127    Forward,
128    Backward,
129}
130
131/// How `--grep`, `--filter ~/!~`, `/`, `?`, and `:tag` patterns interpret
132/// case. `Smart` matches less / ripgrep / vim `smartcase`: a pattern with
133/// no uppercase characters is treated as case-insensitive; one with any
134/// uppercase character is case-sensitive.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum CaseMode {
137    Sensitive,
138    Smart,
139    Insensitive,
140}
141
142impl Default for CaseMode {
143    fn default() -> Self { CaseMode::Sensitive }
144}
145
146/// Controls auto-exit on end-of-file. `Off` (default) never quits.
147/// `Second` (less `-e`) quits on the second forward-motion that lands at
148/// EOF in a row. `First` (less `-E`) quits the moment a forward motion
149/// lands at EOF.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum QuitAtEof {
152    Off,
153    Second,
154    First,
155}
156
157impl Default for QuitAtEof {
158    fn default() -> Self { QuitAtEof::Off }
159}
160
161impl CaseMode {
162    /// Compile this case policy into a regex pattern by prepending the
163    /// `(?i)` inline flag when case-insensitive matching is desired.
164    pub fn apply_to_pattern(self, pattern: &str) -> String {
165        match self {
166            CaseMode::Sensitive => pattern.to_string(),
167            CaseMode::Insensitive => format!("(?i){pattern}"),
168            CaseMode::Smart => {
169                if pattern.chars().any(|c| c.is_uppercase()) {
170                    pattern.to_string()
171                } else {
172                    format!("(?i){pattern}")
173                }
174            }
175        }
176    }
177}
178
179#[derive(Debug, Clone)]
180pub struct SearchState {
181    pub raw: String,
182    pub regex: Regex,
183    pub direction: SearchDirection,
184}
185
186#[derive(Debug, Clone)]
187pub struct Frame {
188    pub body: Vec<Vec<Cell>>,        // exactly (rows-1) entries
189    pub row_styles: Vec<RowStyle>,   // parallel to body
190    /// Per-row column ranges to render with reverse-video. Used by `/`
191    /// search to highlight just the matched phrase rather than the whole row.
192    /// Indexed parallel to `body`; each inner Vec holds column ranges in
193    /// `[start, end)` form (cell columns).
194    pub highlights: Vec<Vec<std::ops::Range<usize>>>,
195    pub status: String,
196    /// Style applied to the status row by the writer.
197    pub status_style: crate::ansi::Style,
198    /// `AnsiMode::Raw` passthrough hints — parallel to `body`. `Some(bytes)`
199    /// on a row instructs the writer to emit those original source bytes
200    /// instead of rendering the cell grid (lets escape sequences pass to
201    /// the terminal verbatim). `Some(empty)` skips emission (continuation
202    /// row of a wrapped line whose first row already wrote the bytes).
203    /// `None` means "render cells normally". Only populated when the
204    /// viewport's ansi_mode is Raw.
205    pub raw_rows: Vec<Option<Vec<u8>>>,
206}
207
208pub struct Viewport {
209    top_line: usize,
210    top_row: usize,
211    cols: u16,
212    rows: u16,
213    pub opts: RenderOpts,
214    pub show_line_numbers: bool,
215    pub source_label: String,
216    follow_mode: bool,
217    live_mode: bool,
218    prettify_label: Option<String>,
219    format_label: Option<String>,
220    filter: Option<CompiledFilter>,
221    grep: Option<GrepPredicate>,
222    dim_mode: bool,
223    /// In hide mode (filter active, !dim), maps visible position → logical line
224    /// index. Empty otherwise.
225    visible_lines: Vec<usize>,
226    /// How many logical lines we've evaluated for filter membership. Used by
227    /// `extend_visible_lines` to avoid re-scanning lines on every tick.
228    visible_scanned: usize,
229    search: Option<SearchState>,
230    /// Active display template + format regex. When set, lines are rendered
231    /// through the template before being shown, searched, or counted for wraps.
232    /// Filtering still operates on the raw line (it uses captures, not text).
233    display: Option<crate::format::DisplayRenderer>,
234    hex_mode: bool,
235    /// Bytes per hex group in `--hex` mode. One of 1, 2, 4, 8, 16.
236    /// Default 2 (matches the historical `xxd` 2-byte / 4-char grouping).
237    hex_group_size: usize,
238    /// Custom status-line prompt template. When set, replaces the built-in
239    /// format_status output with the template rendered against PromptContext.
240    prompt: Option<crate::prompt::ParsedPrompt>,
241    /// Error message from a failed preprocessor run. When set, surfaces
242    /// a `[preprocess-failed: ...]` tag in the status line.
243    preprocess_failure: Option<String>,
244    /// When `count > 1`, status line shows `<label>  [current+1/count]`.
245    file_index: Option<(usize, usize)>,
246    /// When set, status line and prompt context include `[tag: <name> (N/M)]`.
247    tag_active: Option<(String, usize, usize)>,  // (name, cursor+1, total)
248    /// ANSI interpretation mode, resolved from --no-color / -r / env at startup.
249    ansi_mode: crate::render::AnsiMode,
250    /// Style applied to the status row at the writer level. Default
251    /// `reverse` for backwards-compat. Overridden by --status-style /
252    /// --prompt-style / per-format prompt_style.
253    status_style: crate::ansi::Style,
254    /// Transient status message shown for a few ticks (e.g. "(F reopened)"
255    /// after a file rotation). The `u32` is the remaining tick count; the
256    /// app loop decrements via `tick_flash` and the formatter renders the
257    /// message as long as it's non-empty.
258    status_flash: Option<(String, u32)>,
259    /// Ticks since the line index last grew. Used to render `(F idle)`
260    /// instead of `(F)` after a few seconds with no new bytes. Reset to
261    /// 0 in `note_growth`, incremented in `tick_idle`. 20 ticks ≈ 5s at
262    /// the 250 ms poll cadence.
263    ticks_since_growth: u32,
264    /// Case-sensitivity policy for search / filter / grep regex compile.
265    /// Resolved from -i / -I CLI flags at startup; mutated by the `:case`
266    /// colon command at runtime.
267    case_mode: CaseMode,
268    /// When false, search-match highlighting is suppressed in frame
269    /// composition (but search navigation still works). Toggled by
270    /// `-G` / `--no-hilite-search` and `:hlsearch` / `:nohlsearch`.
271    hilite_search: bool,
272    /// Auto-exit-on-EOF policy resolved from `-e` / `-E` at startup.
273    quit_at_eof: QuitAtEof,
274    /// Counter for `QuitAtEof::Second`: number of consecutive forward
275    /// motions that landed at EOF. Reset by any backward motion.
276    eof_hits: u8,
277    /// `-s` / `--squeeze-blank-lines`: collapse runs of blank lines to
278    /// a single blank line at display time. Real line numbers / counts
279    /// in `idx` are preserved.
280    squeeze_blanks: bool,
281    /// `--header=L,C`: pin the top `L` source lines at the top of the
282    /// viewport and the left `C` columns at the left. The cols dimension
283    /// is currently inert (no horizontal scroll yet); wired so future
284    /// horizontal-scroll support can opt into it without re-plumbing.
285    header_lines: usize,
286    header_cols: usize,
287    /// `-z` / `--window=N`: PageDown / PageUp step size in lines. `None`
288    /// (default) means "use body_rows" — full-screen page step. Half-page
289    /// commands always use body_rows/2 regardless.
290    page_size: Option<u16>,
291    /// Cached SGR/hyperlink state at the start of `render_state_for`.
292    /// Invalidated when top_line changes or source grows; reconstructed
293    /// by walking up to MAX_RECONSTRUCT_LINES lines back.
294    render_state: crate::render::RenderState,
295    /// Line number that `render_state` matches the start of. Sentinel
296    /// `usize::MAX` means "invalid, must reconstruct".
297    render_state_for: usize,
298}
299
300impl Viewport {
301    pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
302        let opts = RenderOpts { cols, ..RenderOpts::default() };
303        Self {
304            top_line: 0,
305            top_row: 0,
306            cols,
307            rows,
308            opts,
309            show_line_numbers: false,
310            source_label,
311            follow_mode: false,
312            live_mode: false,
313            prettify_label: None,
314            format_label: None,
315            filter: None,
316            grep: None,
317            dim_mode: false,
318            visible_lines: Vec::new(),
319            visible_scanned: 0,
320            search: None,
321            display: None,
322            hex_mode: false,
323            hex_group_size: 2,
324            prompt: None,
325            preprocess_failure: None,
326            file_index: None,
327            tag_active: None,
328            ansi_mode: crate::render::AnsiMode::Strict,
329            status_style: crate::ansi::Style { reverse: true, ..Default::default() },
330            status_flash: None,
331            ticks_since_growth: 0,
332            case_mode: CaseMode::default(),
333            hilite_search: true,
334            quit_at_eof: QuitAtEof::default(),
335            eof_hits: 0,
336            squeeze_blanks: false,
337            header_lines: 0,
338            header_cols: 0,
339            page_size: None,
340            render_state: crate::render::RenderState::default(),
341            render_state_for: usize::MAX,
342        }
343    }
344
345    pub fn case_mode(&self) -> CaseMode { self.case_mode }
346
347    pub fn hilite_search(&self) -> bool { self.hilite_search }
348
349    pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
350
351    pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
352        self.quit_at_eof = mode;
353        self.eof_hits = 0;
354    }
355
356    pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
357    pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
358
359    pub fn set_header(&mut self, lines: usize, cols: usize) {
360        self.header_lines = lines;
361        self.header_cols = cols;
362        // Don't let top_line land inside the pinned region — the scrolling
363        // window starts at line `header_lines` once the feature is on.
364        if self.top_line < self.header_lines {
365            self.top_line = self.header_lines;
366        }
367    }
368    pub fn header_lines(&self) -> usize { self.header_lines }
369    pub fn header_cols(&self) -> usize { self.header_cols }
370
371    pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
372    pub fn page_size(&self) -> Option<u16> { self.page_size }
373
374    /// Notify the EOF state machine of a motion. Returns `true` when the
375    /// caller should quit. `forward = true` for any motion that could
376    /// advance past EOF; `false` for backward motions (which reset the
377    /// hit counter under `QuitAtEof::Second`).
378    pub fn note_motion_for_eof(&mut self, forward: bool, idx: &LineIndex) -> bool {
379        match self.quit_at_eof {
380            QuitAtEof::Off => false,
381            QuitAtEof::First if forward && self.is_at_bottom(idx) => true,
382            QuitAtEof::Second if forward && self.is_at_bottom(idx) => {
383                self.eof_hits = self.eof_hits.saturating_add(1);
384                self.eof_hits >= 2
385            }
386            _ => {
387                if !forward { self.eof_hits = 0; }
388                false
389            }
390        }
391    }
392
393    /// Switch the case-mode policy. Re-compiles any active search so the
394    /// new policy takes effect on the next frame without the user having
395    /// to retype the pattern.
396    pub fn set_case_mode(&mut self, mode: CaseMode) {
397        self.case_mode = mode;
398        if let Some(s) = self.search.clone() {
399            let _ = self.set_search(s.raw, s.direction);
400        }
401    }
402
403    pub fn set_status_style(&mut self, style: crate::ansi::Style) {
404        self.status_style = style;
405    }
406
407    pub fn status_style(&self) -> crate::ansi::Style {
408        self.status_style
409    }
410
411    /// Show `msg` in the status row for the next `ticks` calls to the
412    /// timeout branch (~250 ms each). Overrides the normal status during
413    /// that window.
414    pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
415        self.status_flash = Some((msg.into(), ticks));
416    }
417
418    /// Decrement the flash countdown by one tick. Clears the flash when
419    /// it reaches zero.
420    pub fn tick_flash(&mut self) {
421        if let Some((_, n)) = &mut self.status_flash {
422            *n = n.saturating_sub(1);
423            if *n == 0 {
424                self.status_flash = None;
425            }
426        }
427    }
428
429    /// Reset the idle counter; the source just produced fresh bytes.
430    pub fn note_growth(&mut self) {
431        self.ticks_since_growth = 0;
432    }
433
434    /// Increment the idle counter. Called in the timeout branch when the
435    /// line index didn't grow.
436    pub fn tick_idle(&mut self) {
437        self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
438    }
439
440    /// True when the source has been quiet long enough to surface
441    /// `(F idle)` instead of `(F)`. Threshold: 20 ticks ≈ 5s.
442    pub fn is_idle(&self) -> bool {
443        self.ticks_since_growth >= 20
444    }
445
446    pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
447        self.display = renderer;
448    }
449
450    pub fn set_hex_mode(&mut self, on: bool) {
451        self.hex_mode = on;
452    }
453
454    /// Returns whether `--hex` rendering is active.
455    pub fn hex_mode(&self) -> bool {
456        self.hex_mode
457    }
458
459    /// Set bytes-per-group for `--hex` rendering. Accepts 1, 2, 4, 8, or 16.
460    /// Invalid values are ignored.
461    pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
462        if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
463            self.hex_group_size = bytes_per_group;
464        }
465    }
466
467    /// Current bytes-per-group for `--hex` rendering.
468    pub fn hex_group_size(&self) -> usize {
469        self.hex_group_size
470    }
471
472    pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
473        self.prompt = prompt;
474    }
475
476    pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
477        self.preprocess_failure = msg;
478    }
479
480    pub fn set_file_index(&mut self, current: usize, total: usize) {
481        self.file_index = if total > 1 {
482            Some((current, total))
483        } else {
484            None
485        };
486    }
487
488    pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
489        self.tag_active = info;
490    }
491
492    pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
493        self.ansi_mode = mode;
494    }
495
496    pub fn ansi_mode(&self) -> crate::render::AnsiMode {
497        self.ansi_mode
498    }
499
500    pub fn set_source_label(&mut self, label: String) {
501        self.source_label = label;
502    }
503
504    pub fn source_label_clone(&self) -> String {
505        self.source_label.clone()
506    }
507
508    /// Fetch a logical line's display bytes — rendered through the active
509    /// display template if one is set and the line parses against the format
510    /// regex, otherwise the raw bytes. Used everywhere the *visible* form of
511    /// the line matters: rendering, search, wrap-row counting.
512    fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
513        let range = idx.line_range(line_n, src);
514        let raw = src.bytes(range);
515        if let Some(r) = self.display.as_ref() {
516            if let Some(rendered) = r.render_line(&raw) {
517                return std::borrow::Cow::Owned(rendered.into_bytes());
518            }
519        }
520        raw
521    }
522
523    /// Compile and store a search pattern. Returns the parse error from the
524    /// regex crate if the pattern is invalid; the previous search (if any)
525    /// is preserved on error.
526    pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
527        let compiled = self.case_mode.apply_to_pattern(&raw);
528        let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
529        self.search = Some(SearchState { raw, regex, direction });
530        Ok(())
531    }
532
533    pub fn clear_search(&mut self) { self.search = None; }
534
535    pub fn search_active(&self) -> bool { self.search.is_some() }
536
537    pub fn search_direction(&self) -> SearchDirection {
538        self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
539    }
540
541    /// Jump to the next match of the active search, in `direction` (or its
542    /// reverse if `reverse` is true). Wraps at the end of the source.
543    /// Returns true iff a match was found and the viewport moved.
544    pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
545        if idx.records_mode() {
546            self.search_repeat_records(src, idx, reverse)
547        } else {
548            self.search_repeat_lines(src, idx, reverse)
549        }
550    }
551
552    /// Line-mode search: unchanged original logic.
553    fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
554        let Some(s) = self.search.as_ref() else { return false; };
555        let forward = matches!(
556            (s.direction, reverse),
557            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
558        );
559        idx.extend_to_end(src);
560        let pattern = s.regex.clone();
561        if self.hide_mode() {
562            self.extend_visible_lines(idx, src);
563            self.search_step_in_visible(&pattern, src, idx, forward)
564        } else {
565            self.search_step_in_logical(&pattern, src, idx, forward)
566        }
567    }
568
569    /// Records-mode search: iterate records, match against UTF-8-lossy decoded
570    /// record bytes (which may contain embedded `\n`s), and jump the viewport
571    /// to the first line of the matching record.
572    fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
573        let Some(s) = self.search.as_ref() else { return false; };
574        let forward = matches!(
575            (s.direction, reverse),
576            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
577        );
578        let pattern = s.regex.clone();
579        idx.extend_to_end(src);
580
581        let total = idx.record_count();
582        if total == 0 { return false; }
583
584        let cur_record = idx.line_to_record(self.top_line);
585
586        let range: Box<dyn Iterator<Item = usize>> = if forward {
587            Box::new(((cur_record + 1)..total).chain(0..=cur_record))
588        } else {
589            let earlier: Vec<usize> = (0..cur_record).rev().collect();
590            let later: Vec<usize> = (cur_record..total).rev().collect();
591            Box::new(earlier.into_iter().chain(later))
592        };
593
594        for r in range {
595            let bytes = idx.record_bytes_stripped(r, src);
596            let text = String::from_utf8_lossy(&bytes);
597            if pattern.is_match(&text) {
598                let line_range = idx.record_line_range(r);
599                self.top_line = line_range.start;
600                self.top_row = 0;
601                return true;
602            }
603        }
604        false
605    }
606
607    fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
608        // Search runs against the *displayed* bytes so what the user sees is
609        // what they can find. With a template active, that's the rendered form;
610        // otherwise the raw line. ANSI color sequences are stripped so that
611        // `/error` finds a red `error` regardless of escape codes.
612        let display = self.line_display_bytes(src, idx, line_n);
613        let bytes = crate::ansi::strip_sgr(&display);
614        match std::str::from_utf8(&bytes) {
615            Ok(s) => pattern.is_match(s),
616            Err(_) => false,
617        }
618    }
619
620    fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
621        let total = idx.line_count();
622        if total == 0 { return false; }
623        let start = self.top_line;
624        // Walk every logical line once, starting from start+1 (or start-1)
625        // and wrapping at the end / beginning.
626        for offset in 1..=total {
627            let line_n = if forward {
628                (start + offset) % total
629            } else {
630                (start + total - offset) % total
631            };
632            if self.line_matches(pattern, src, idx, line_n) {
633                self.top_line = line_n;
634                self.top_row = 0;
635                return true;
636            }
637        }
638        false
639    }
640
641    fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
642        let total = self.visible_lines.len();
643        if total == 0 { return false; }
644        // Find current visible position for top_line.
645        let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
646        for offset in 1..=total {
647            let visible_idx = if forward {
648                (cur + offset) % total
649            } else {
650                (cur + total - offset) % total
651            };
652            let line_n = self.visible_lines[visible_idx];
653            if self.line_matches(pattern, src, idx, line_n) {
654                self.top_line = line_n;
655                self.top_row = 0;
656                return true;
657            }
658        }
659        false
660    }
661
662    pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
663        self.filter = filter;
664        self.visible_lines.clear();
665        self.visible_scanned = 0;
666        // Drop scroll state — line numbering may have changed under us.
667        self.top_line = 0;
668        self.top_row = 0;
669    }
670
671    pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
672        self.grep = grep;
673        self.visible_lines.clear();
674        self.visible_scanned = 0;
675        self.top_line = 0;
676        self.top_row = 0;
677    }
678
679    pub fn grep_active(&self) -> bool { self.grep.is_some() }
680
681    pub fn set_dim_mode(&mut self, on: bool) {
682        self.dim_mode = on;
683        // Hide mode is the only mode that needs visible_lines; clear when
684        // turning dim ON, and re-derive from scratch when turning dim OFF
685        // (next extend_visible_lines call rebuilds it).
686        self.visible_lines.clear();
687        self.visible_scanned = 0;
688    }
689
690    pub fn filter_active(&self) -> bool { self.filter.is_some() }
691
692    pub fn dim_mode(&self) -> bool { self.dim_mode }
693
694    fn hide_mode(&self) -> bool {
695        (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
696    }
697
698    /// Walk any newly indexed logical lines and append matching ones to
699    /// `visible_lines` if we're in hide mode. No-op otherwise. Cheap to call
700    /// every loop tick — keeps a `visible_scanned` cursor (line mode only;
701    /// records mode rebuilds from scratch each call).
702    pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
703        if !self.hide_mode() {
704            return;
705        }
706        if idx.records_mode() {
707            self.extend_visible_lines_records(idx, src);
708        } else {
709            self.extend_visible_lines_per_line(idx, src);
710        }
711    }
712
713    /// Line-mode: incrementally append newly indexed matching lines.
714    fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
715        let total = idx.line_count();
716        while self.visible_scanned < total {
717            let line_n = self.visible_scanned;
718            let bytes = idx.line_bytes_stripped(line_n, src);
719            if self.line_passes(&bytes) {
720                self.visible_lines.push(line_n);
721            }
722            self.visible_scanned += 1;
723        }
724    }
725
726    /// Records-mode: evaluate predicates once per record on the full record
727    /// bytes (which include embedded `\n`s). All physical lines of a matching
728    /// record are pushed to `visible_lines`; non-matching records are dropped
729    /// entirely (hide mode). Rebuilds from scratch on each call — O(records)
730    /// per frame but acceptable for current workloads; avoids the complexity
731    /// of tracking a records-scanned cursor alongside `visible_scanned`.
732    fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
733        self.visible_lines.clear();
734        self.visible_scanned = 0; // not used by records path; reset for clarity
735        let total_records = idx.record_count();
736        for r in 0..total_records {
737            if self.record_passes(idx, src, r) {
738                for line_n in idx.record_line_range(r) {
739                    self.visible_lines.push(line_n);
740                }
741            }
742        }
743    }
744
745    /// Combined predicate: bytes pass iff the (optional) filter matches AND
746    /// the (optional) grep matches. Missing predicates vacuously pass.
747    /// `bytes` is always a single logical line — records-mode callers go
748    /// through `record_passes` instead because the two predicates have
749    /// different granularity (filter = header line, grep = whole record).
750    fn line_passes(&self, line: &[u8]) -> bool {
751        let filter_ok = match self.filter.as_ref() {
752            Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
753            None => true,
754        };
755        let grep_ok = match self.grep.as_ref() {
756            Some(g) => g.matches(line),
757            None => true,
758        };
759        filter_ok && grep_ok
760    }
761
762    /// Records-mode predicate. Both filter and grep are evaluated against
763    /// the full multi-line record bytes. Filter uses the format regex with
764    /// dotall + multi-line semantics so greedy captures like
765    /// `(?P<message>.*)$` span the whole record body — `--filter
766    /// message~foo` matches when `foo` appears anywhere in the record, not
767    /// only on the header. Grep matches anywhere in the record bytes too,
768    /// so `(?s)foo.*bar` keeps working across continuation lines.
769    fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
770        let bytes = if self.filter.is_some() || self.grep.is_some() {
771            Some(idx.record_bytes_stripped(r, src))
772        } else {
773            None
774        };
775        let filter_ok = match self.filter.as_ref() {
776            Some(f) => matches!(
777                f.evaluate_record(bytes.as_deref().unwrap()),
778                FilterMatch::Matched,
779            ),
780            None => true,
781        };
782        let grep_ok = match self.grep.as_ref() {
783            Some(g) => g.matches(bytes.as_deref().unwrap()),
784            None => true,
785        };
786        filter_ok && grep_ok
787    }
788
789    /// Return true iff line `line_n` should be rendered dim. In records mode,
790    /// the match decision is made once per record and applied to all its
791    /// physical lines. In line mode, the decision is made per line.
792    fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
793        if !self.dim_mode {
794            return false;
795        }
796        if idx.records_mode() {
797            let r = idx.line_to_record(line_n);
798            !self.record_passes(idx, src, r)
799        } else {
800            let bytes = idx.line_bytes_stripped(line_n, src);
801            !self.line_passes(&bytes)
802        }
803    }
804
805    /// Logical line index of the *last* row drawn in the body, given the
806    /// current `top_line` and `body_rows`. In line mode this is just
807    /// `top_line + body_rows - 1` clamped to the indexed line count. In hide
808    /// mode it's the logical line that sits at the bottom of the visible
809    /// slice — i.e. `visible_lines[cur + body_rows - 1]`. Always returns a
810    /// value `>= self.top_line`, so callers passing it to `line_to_record`
811    /// never get a "bottom record < top record" inversion.
812    fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
813        let body_rows = self.body_rows() as usize;
814        if self.hide_mode() && !self.visible_lines.is_empty() {
815            let cur = self
816                .visible_lines
817                .iter()
818                .position(|&l| l >= self.top_line)
819                .unwrap_or(self.visible_lines.len().saturating_sub(1));
820            let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
821            return self.visible_lines[last_pos];
822        }
823        let total = idx.line_count();
824        if total == 0 {
825            return self.top_line;
826        }
827        (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
828    }
829
830    pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
831
832    pub fn follow_mode(&self) -> bool { self.follow_mode }
833
834    /// Conditionally turn follow mode off. Used by motion handlers when
835    /// `--follow-suspend-on-motion` is in effect — any motion (scroll,
836    /// page, goto-line) suspends following until the user re-engages
837    /// with Shift-F.
838    pub fn suspend_follow_if(&mut self, flag: bool) {
839        if flag {
840            self.follow_mode = false;
841        }
842    }
843
844    pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
845
846    pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
847
848    pub fn live_mode(&self) -> bool { self.live_mode }
849
850    pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
851
852    /// Status-line label for active pretty-print state, e.g. `"json"` or
853    /// `"json:err"`. `None` means no indicator is shown.
854    pub fn set_prettify_label(&mut self, label: Option<String>) {
855        self.prettify_label = label;
856    }
857
858    /// Active --format name shown in <format-tag>. Set from main when a named
859    /// format is resolved; independent of whether --filter is also active.
860    pub fn set_format_label(&mut self, label: Option<String>) {
861        self.format_label = label;
862    }
863
864    /// Drop the per-line filter-membership cache without disturbing the filter
865    /// itself or scroll position. Used after a `--live` rebuild: line numbering
866    /// may have changed, so cached `visible_lines` is stale, but we want to
867    /// keep the same filter applied and let the user stay where they were.
868    pub fn invalidate_filter_cache(&mut self) {
869        self.visible_lines.clear();
870        self.visible_scanned = 0;
871    }
872
873    /// Clamp `top_line` so it doesn't fall past the new end of the source.
874    /// Pairs with `invalidate_filter_cache` after a content rewrite.
875    pub fn clamp_top_line(&mut self, line_count: usize) {
876        if line_count == 0 {
877            self.top_line = 0;
878            self.top_row = 0;
879        } else if self.top_line >= line_count {
880            self.top_line = line_count - 1;
881            self.top_row = 0;
882        }
883    }
884
885    /// True when the viewport's body window already covers the last line of
886    /// the source. New content added past this point should auto-scroll if
887    /// follow mode is on.
888    pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
889        let body = self.body_rows() as usize;
890        if self.hide_mode() {
891            // top_line is a logical line; find its position in visible_lines.
892            let pos = self
893                .visible_lines
894                .iter()
895                .position(|&l| l >= self.top_line)
896                .unwrap_or(self.visible_lines.len());
897            pos + body >= self.visible_lines.len()
898        } else {
899            self.top_line + body >= idx.line_count()
900        }
901    }
902
903    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
904    fn gutter_width(&self, idx: &LineIndex) -> u16 {
905        if !self.show_line_numbers { return 0; }
906        let n = idx.line_count().max(1);
907        let digits = (n as f64).log10().floor() as u16 + 1;
908        digits + 1
909    }
910
911    fn render_opts(&self, gutter: u16) -> RenderOpts {
912        let mut o = self.opts.clone();
913        o.cols = self.cols.saturating_sub(gutter);
914        o.mode = self.ansi_mode;
915        o
916    }
917
918    pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
919        if self.hex_mode {
920            return self.frame_hex(src);
921        }
922        let body_rows = self.body_rows() as usize;
923        idx.extend_to_line(self.top_line + body_rows + 1, src);
924
925        let gutter = self.gutter_width(idx);
926        let r_opts = self.render_opts(gutter);
927
928        // Reconstruct per-line SGR state for the start of the visible window so
929        // that unclosed SGR sequences on lines above top_line carry through.
930        // Only meaningful in Interpret mode; harmless (and cheap) to skip otherwise.
931        let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
932            reconstruct_render_state(src, idx, self.top_line)
933        } else {
934            crate::render::RenderState::default()
935        };
936        // Store in the struct field for future cache use; mark current top_line.
937        self.render_state = render_state.clone();
938        self.render_state_for = self.top_line;
939
940        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
941        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
942        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
943        let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
944        let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
945        // In hide mode we walk visible_lines; otherwise we walk logical lines.
946        let hide = self.hide_mode();
947        let total_lines = idx.line_count();
948
949        // `--header=L`: pin the first L source lines as the top L rows of
950        // the body. Renders only the first cell-row of each pinned line
951        // (matches less semantics; long pinned lines truncate). Skipped in
952        // hide mode where "first L lines" might not be visible. Skipped in
953        // raw passthrough since the user's intent there is byte-faithful
954        // emission, not pinned headers.
955        let header_rows = if !hide && !raw_passthrough {
956            self.header_lines.min(body_rows).min(total_lines)
957        } else {
958            0
959        };
960        if header_rows > 0 {
961            for hl in 0..header_rows {
962                let raw = src.bytes(idx.line_range(hl, src));
963                let display_bytes = if let Some(r) = self.display.as_ref() {
964                    match r.render_line(&raw) {
965                        Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
966                        None => raw.clone(),
967                    }
968                } else {
969                    raw.clone()
970                };
971                let rows = render_line(&display_bytes, &r_opts, None);
972                let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
973                    let mut v = Vec::with_capacity(self.cols as usize);
974                    while v.len() < self.cols as usize { v.push(Cell::Empty); }
975                    v
976                });
977                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
978                if gutter > 0 {
979                    let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
980                    for c in label.chars() {
981                        full.push(Cell::Char {
982                            ch: c,
983                            width: 1,
984                            style: crate::ansi::Style::default(),
985                            hyperlink: None,
986                        });
987                    }
988                }
989                full.append(&mut content_row);
990                body.push(full);
991                row_styles.push(RowStyle::Normal);
992                highlights.push(Vec::new());
993                raw_rows.push(None);
994            }
995        }
996
997        // For hide mode, find where the viewport starts in visible_lines.
998        let mut hide_pos = if hide {
999            self.visible_lines
1000                .iter()
1001                .position(|&l| l >= self.top_line)
1002                .unwrap_or(self.visible_lines.len())
1003        } else {
1004            0
1005        };
1006        let mut line_n = if hide {
1007            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1008        } else {
1009            // When header pinning is on, skip past the pinned region so the
1010            // scrolling window doesn't show those lines a second time.
1011            self.top_line.max(self.header_lines)
1012        };
1013        let mut skip = if hide || header_rows > 0 { 0 } else { self.top_row };
1014
1015        while body.len() < body_rows {
1016            if line_n >= total_lines {
1017                let mut row = Vec::with_capacity(self.cols as usize);
1018                if gutter > 0 {
1019                    for _ in 0..gutter { row.push(Cell::Empty); }
1020                }
1021                while row.len() < self.cols as usize { row.push(Cell::Empty); }
1022                body.push(row);
1023                row_styles.push(RowStyle::Normal);
1024                highlights.push(Vec::new());
1025                raw_rows.push(None);
1026                line_n += 1;
1027                continue;
1028            }
1029            // Filter evaluation runs on the raw line (it uses captures, not
1030            // text), but rendering goes through the template if one is set.
1031            let raw = src.bytes(idx.line_range(line_n, src));
1032            // `-s` / --squeeze-blank-lines: skip a blank line if its
1033            // immediate predecessor (in logical-line space) was also blank.
1034            // Real line numbers / counts in `idx` stay accurate — this is a
1035            // display-layer filter only.
1036            if self.squeeze_blanks && line_is_blank(&raw) {
1037                let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1038                    let prev = src.bytes(idx.line_range(p, src));
1039                    line_is_blank(&prev)
1040                });
1041                if prev_blank {
1042                    line_n += 1;
1043                    continue;
1044                }
1045            }
1046            let display_bytes = if let Some(r) = self.display.as_ref() {
1047                match r.render_line(&raw) {
1048                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1049                    None => raw.clone(),
1050                }
1051            } else {
1052                raw.clone()
1053            };
1054            let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1055                Some(&mut render_state)
1056            } else {
1057                None
1058            };
1059            let rows = render_line(&display_bytes, &r_opts, state_arg);
1060            let style = if self.filter.is_some() || self.grep.is_some() {
1061                if self.dim_mode {
1062                    if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1063                } else {
1064                    // hide mode: only matching lines reach here
1065                    RowStyle::Normal
1066                }
1067            } else {
1068                RowStyle::Normal
1069            };
1070
1071            let mut first_emitted_for_this_line = true;
1072            for (i, mut content_row) in rows.into_iter().enumerate() {
1073                if i < skip { continue; }
1074                if body.len() >= body_rows { break; }
1075                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1076                if gutter > 0 {
1077                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1078                    for c in label.chars() {
1079                        full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1080                    }
1081                }
1082                full.append(&mut content_row);
1083                // Compute search highlights for this display row by running
1084                // the regex against the row's rendered text. Each match's
1085                // char range maps to a cell column range via `starts`.
1086                let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1087                    find_row_highlights(&full, &s.regex)
1088                } else {
1089                    Vec::new()
1090                };
1091                body.push(full);
1092                row_styles.push(style);
1093                highlights.push(row_highlights);
1094                if raw_passthrough {
1095                    if first_emitted_for_this_line {
1096                        // Emit the original line bytes verbatim once. Sub-rows
1097                        // (mid-line wrap continuations) are no-ops — the
1098                        // terminal will have already consumed enough columns
1099                        // from the line's full byte stream to fill them.
1100                        raw_rows.push(Some(raw.to_vec()));
1101                        first_emitted_for_this_line = false;
1102                    } else {
1103                        raw_rows.push(Some(Vec::new()));
1104                    }
1105                } else {
1106                    raw_rows.push(None);
1107                }
1108            }
1109            skip = 0;
1110            // Advance to next line — visible-space if hiding, logical-space otherwise.
1111            if hide {
1112                hide_pos += 1;
1113                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1114            } else {
1115                line_n += 1;
1116            }
1117        }
1118
1119        // After walking through the frame, render_state has been advanced past
1120        // top_line. Invalidate the cached sentinel so next frame re-reconstructs.
1121        self.render_state_for = usize::MAX;
1122
1123        let status = self.format_status(idx, src);
1124        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1125    }
1126
1127    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1128        if let Some(p) = self.prompt.as_ref() {
1129            let ctx = self.build_prompt_context(idx, src);
1130            return p.render(&ctx);
1131        }
1132        let body_rows = self.body_rows() as usize;
1133        let total = idx.line_count();
1134        // In hide mode, the line range and percentage refer to visible (matched)
1135        // lines, not the underlying logical line count.
1136        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1137            let visible_total = self.visible_lines.len();
1138            // top_line is a logical line; find its visible index.
1139            let cur = self
1140                .visible_lines
1141                .iter()
1142                .position(|&l| l >= self.top_line)
1143                .unwrap_or(visible_total);
1144            let top = cur + 1;
1145            let bottom = (cur + body_rows).min(visible_total.max(1));
1146            let total_str = if src.is_complete() {
1147                format!("{visible_total}/{total}")
1148            } else {
1149                format!("{visible_total}/{total}+")
1150            };
1151            (top, bottom, visible_total, total_str)
1152        } else {
1153            let top = self.top_line + 1;
1154            let bottom = (self.top_line + body_rows).min(total.max(1));
1155            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1156            (top, bottom, total, total_str)
1157        };
1158        let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1159        // In records mode, prefix line numbers with 'L' and append an 'R' record block.
1160        // The R block always refers to logical lines on screen, which in hide
1161        // mode is *not* the same as `bottom` (which counts visible matches).
1162        let bottom_line = self.bottom_visible_line(idx);
1163        let (line_prefix, records_block) = if idx.records_mode() {
1164            let line_total = idx.line_count();
1165            let rec_total = idx.record_count();
1166            let rec_block = if line_total == 0 || rec_total == 0 {
1167                format!("R0-0/{}", rec_total)
1168            } else {
1169                let rec_top = idx.line_to_record(self.top_line) + 1;
1170                let rec_bottom = idx.line_to_record(bottom_line) + 1;
1171                let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1172                    // Defensive: should be unreachable given `bottom_visible_line`
1173                    // is always `>= self.top_line`, but guard against future
1174                    // regressions producing nonsense like `R290-8/...`.
1175                    (rec_top, rec_top)
1176                } else {
1177                    (rec_top, rec_bottom)
1178                };
1179                format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1180            };
1181            ("L", Some(rec_block))
1182        } else {
1183            ("", None)
1184        };
1185        let middle = match records_block {
1186            Some(ref rb) => format!("{}{}-{}/{}  {}  {}%", line_prefix, top, bottom, total_str, rb, pct),
1187            None         => format!("{}-{}/{}  {}%", top, bottom, total_str, pct),
1188        };
1189        let label_with_index = match self.file_index {
1190            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1191            None => self.source_label.clone(),
1192        };
1193        let mut s = format!("{}  {}", label_with_index, middle);
1194        // Wrap-row offset: when scrolled inside a long wrapping line, surface
1195        // the offset so the user knows scrolling is happening at sub-line
1196        // granularity. Without this the line range above stays static while
1197        // pressing `j` and the scroll is invisible on repeating content.
1198        if !self.hide_mode() && self.top_row > 0 {
1199            let line_rows = if total > 0 {
1200                let bytes = self.line_display_bytes(src, idx, self.top_line);
1201                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1202            } else { 1 };
1203            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
1204        }
1205        if let Some(f) = self.filter.as_ref() {
1206            s.push_str(&format!("  [{}]", f.format_name));
1207        }
1208        if self.grep.is_some() {
1209            s.push_str("  [grep]");
1210        }
1211        if self.filter.is_some() || self.grep.is_some() {
1212            s.push_str(if self.dim_mode { "  [dim]" } else { "  [hide]" });
1213        }
1214        if let Some(sr) = self.search.as_ref() {
1215            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1216            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
1217        }
1218        if let Some(label) = self.prettify_label.as_ref() {
1219            s.push_str(&format!("  [pretty:{label}]"));
1220        }
1221        if self.live_mode { s.push_str("  (L)"); }
1222        if self.follow_mode {
1223            if let Some((msg, _)) = self.status_flash.as_ref() {
1224                s.push_str("  ");
1225                s.push_str(msg);
1226            } else if self.is_idle() {
1227                s.push_str("  (F idle)");
1228            } else {
1229                s.push_str("  (F)");
1230            }
1231        }
1232        if let Some(msg) = self.preprocess_failure.as_ref() {
1233            let first_line = msg.lines().next().unwrap_or("");
1234            s.push_str(&format!("  [preprocess-failed: {}]", first_line));
1235        }
1236        let tag_suffix = match &self.tag_active {
1237            Some((name, cur, total)) if *total > 1 => {
1238                format!("  [tag: {name} ({cur}/{total})]")
1239            }
1240            _ => String::new(),
1241        };
1242        s.push_str(&tag_suffix);
1243        // Right-aligned :help hint. If the existing status already overshoots
1244        // the width, no pad — the renderer will clip on draw.
1245        let used = s.chars().count();
1246        let hint = ":help";
1247        if (self.cols as usize) > used + 1 + hint.chars().count() {
1248            let pad = self.cols as usize - used - hint.chars().count();
1249            s.push_str(&" ".repeat(pad));
1250            s.push_str(hint);
1251        } else {
1252            s.push(' ');
1253            s.push_str(hint);
1254        }
1255        s
1256    }
1257
1258    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1259        use crate::prompt::PromptContext;
1260
1261        let body_rows = self.body_rows() as usize;
1262        let total = idx.line_count();
1263        let top = self.top_line + 1;
1264        let bottom = (self.top_line + body_rows).min(total.max(1));
1265        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1266        let bottom_line = self.bottom_visible_line(idx);
1267
1268        let records_mode = idx.records_mode();
1269        let (rec_top, rec_bottom, rec_total) = if records_mode {
1270            let rt = idx.line_to_record(self.top_line) + 1;
1271            let rb_raw = idx.line_to_record(bottom_line) + 1;
1272            let rb = if rb_raw < rt { rt } else { rb_raw };
1273            (rt, rb, idx.record_count())
1274        } else {
1275            (0, 0, 0)
1276        };
1277
1278        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1279            let line_rows = if total > 0 {
1280                let bytes = self.line_display_bytes(src, idx, self.top_line);
1281                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1282            } else { 1 };
1283            format!("+{}/{}", self.top_row, line_rows)
1284        } else {
1285            String::new()
1286        };
1287
1288        let format_tag = self.format_label.as_ref()
1289            .map(|n| format!("  [{}]", n))
1290            .unwrap_or_default();
1291        let filter_tag = self.filter.as_ref()
1292            .map(|f| format!("  [{}]", f.format_name))
1293            .unwrap_or_default();
1294        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
1295        let hide_tag = if self.filter.is_some() || self.grep.is_some() {
1296            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
1297        } else {
1298            String::new()
1299        };
1300        let search_tag = self.search.as_ref()
1301            .map(|s| {
1302                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1303                format!("  [{}{}]", p, s.raw)
1304            })
1305            .unwrap_or_default();
1306        let pretty_tag = self.prettify_label.as_ref()
1307            .map(|l| format!("  [pretty:{l}]"))
1308            .unwrap_or_default();
1309        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
1310        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
1311        let preprocess_failed_tag = self.preprocess_failure.as_ref()
1312            .map(|msg| {
1313                let first_line = msg.lines().next().unwrap_or("");
1314                format!("  [preprocess-failed: {}]", first_line)
1315            })
1316            .unwrap_or_default();
1317
1318        let file_index_tag = match self.file_index {
1319            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
1320            None => String::new(),
1321        };
1322
1323        let tag_tag = match &self.tag_active {
1324            Some((name, cur, total)) if *total > 1 => {
1325                format!("  [tag: {name} ({cur}/{total})]")
1326            }
1327            _ => String::new(),
1328        };
1329
1330        PromptContext {
1331            label: self.source_label.clone(),
1332            top,
1333            bottom,
1334            total,
1335            pct: pct.min(100) as u8,
1336            rec_top,
1337            rec_bottom,
1338            rec_total,
1339            records_mode,
1340            wrap_offset,
1341            format_tag,
1342            filter_tag,
1343            grep_tag,
1344            hide_tag,
1345            search_tag,
1346            pretty_tag,
1347            live_tag,
1348            follow_tag,
1349            preprocess_failed_tag,
1350            file_index_tag,
1351            tag_tag,
1352        }
1353    }
1354
1355    fn frame_hex(&self, src: &dyn Source) -> Frame {
1356        use crate::hex::format_hex_row;
1357        use crate::render::{render_line, Cell, RenderOpts};
1358
1359        let body_rows = self.rows.saturating_sub(1) as usize;
1360        let total_bytes = src.len();
1361        let total_hex_rows = total_bytes.div_ceil(16);
1362
1363        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1364        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1365        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1366
1367        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
1368
1369        for row_idx in 0..body_rows {
1370            let hex_row = self.top_line + row_idx;
1371            if hex_row >= total_hex_rows {
1372                body.push(vec![Cell::Empty; self.cols as usize]);
1373            } else {
1374                let offset = hex_row * 16;
1375                let end = (offset + 16).min(total_bytes);
1376                let bytes_cow = src.bytes(offset..end);
1377                let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1378                let rows = render_line(text.as_bytes(), &opts, None);
1379                body.push(rows.into_iter().next().unwrap_or_else(|| {
1380                    vec![Cell::Empty; self.cols as usize]
1381                }));
1382            }
1383            row_styles.push(RowStyle::Normal);
1384            highlights.push(Vec::new());
1385        }
1386
1387        let status = self.format_status_hex(src);
1388        let raw_rows = vec![None; body.len()];
1389        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1390    }
1391
1392    fn format_status_hex(&self, src: &dyn Source) -> String {
1393        let total_bytes = src.len();
1394        let body_rows = self.rows.saturating_sub(1) as usize;
1395        // Byte offset of the first visible byte (start of the top hex row).
1396        let top_byte = self.top_line * 16;
1397        // Byte offset just past the last visible byte. Clamped to total_bytes
1398        // so we never show a value past EOF.
1399        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1400        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1401        let label_with_index = match self.file_index {
1402            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1403            None => self.source_label.clone(),
1404        };
1405        let tag_suffix = match &self.tag_active {
1406            Some((name, cur, total)) if *total > 1 => {
1407                format!("  [tag: {name} ({cur}/{total})]")
1408            }
1409            _ => String::new(),
1410        };
1411        format!(
1412            "{}  off {}-{}/{}  {}%  [hex]{}",
1413            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1414        )
1415    }
1416
1417    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
1418    /// reset to 0 so the start of the destination line is at the top of
1419    /// the viewport. In hide mode this is equivalent to `scroll_lines`
1420    /// (which already moves by visible/logical lines).
1421    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1422        if delta == 0 { return; }
1423        if self.hide_mode() {
1424            self.scroll_lines(delta, src, idx);
1425            return;
1426        }
1427        if delta > 0 {
1428            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1429            let total = idx.line_count();
1430            if total == 0 { return; }
1431            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1432            self.top_line = target;
1433            self.top_row = 0;
1434        } else {
1435            let back = (-delta) as usize;
1436            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1437            // the start of the current line; only the remaining count goes to
1438            // previous lines. This matches the user's mental model of "jump
1439            // to the start of the previous line".
1440            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1441            let extra_back = back.saturating_sub(consumed_for_snap);
1442            self.top_line = self.top_line.saturating_sub(extra_back);
1443            self.top_row = 0;
1444        }
1445    }
1446
1447    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1448        if delta == 0 { return; }
1449        if self.hide_mode() {
1450            // Scroll by visible (matching) lines. We don't honor wrap rows in
1451            // hide mode — top_row stays 0. Each unit of `delta` advances or
1452            // retreats one visible line.
1453            self.extend_visible_lines(idx, src);
1454            let total = self.visible_lines.len();
1455            if total == 0 {
1456                self.top_line = 0;
1457                self.top_row = 0;
1458                return;
1459            }
1460            let cur = self
1461                .visible_lines
1462                .iter()
1463                .position(|&l| l >= self.top_line)
1464                .unwrap_or(total);
1465            let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1466            self.top_line = self.visible_lines[new];
1467            self.top_row = 0;
1468            return;
1469        }
1470        if delta > 0 {
1471            let mut remaining = delta as usize;
1472            while remaining > 0 {
1473                idx.extend_to_line(self.top_line + 1, src);
1474                let total = idx.line_count();
1475                if total == 0 { break; }
1476                let bytes = self.line_display_bytes(src, idx, self.top_line);
1477                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1478                if self.top_row + 1 < line_rows {
1479                    self.top_row += 1;
1480                } else if self.top_line + 1 < total {
1481                    self.top_row = 0;
1482                    self.top_line += 1;
1483                } else {
1484                    break;
1485                }
1486                remaining -= 1;
1487            }
1488        } else {
1489            let mut remaining = (-delta) as usize;
1490            while remaining > 0 {
1491                if self.top_row > 0 {
1492                    self.top_row -= 1;
1493                } else if self.top_line > 0 {
1494                    self.top_line -= 1;
1495                    let bytes = self.line_display_bytes(src, idx, self.top_line);
1496                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1497                    self.top_row = line_rows.saturating_sub(1);
1498                } else {
1499                    break;
1500                }
1501                remaining -= 1;
1502            }
1503        }
1504    }
1505
1506    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1507        let n = self.page_size
1508            .map(|p| p as i64)
1509            .unwrap_or_else(|| self.body_rows() as i64);
1510        self.scroll_lines(n, src, idx);
1511    }
1512
1513    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1514        let n = self.page_size
1515            .map(|p| p as i64)
1516            .unwrap_or_else(|| self.body_rows() as i64);
1517        self.scroll_lines(-n, src, idx);
1518    }
1519
1520    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1521        let n = (self.body_rows() / 2).max(1) as i64;
1522        self.scroll_lines(n, src, idx);
1523    }
1524
1525    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1526        let n = (self.body_rows() / 2).max(1) as i64;
1527        self.scroll_lines(-n, src, idx);
1528    }
1529
1530    pub fn goto_top(&mut self) {
1531        self.top_line = 0;
1532        self.top_row = 0;
1533    }
1534
1535    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1536        idx.extend_to_end(src);
1537        let body = self.body_rows() as usize;
1538        if self.hide_mode() {
1539            self.extend_visible_lines(idx, src);
1540            let total = self.visible_lines.len();
1541            let target_visible = total.saturating_sub(body);
1542            self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1543            self.top_row = 0;
1544        } else {
1545            let total = idx.line_count();
1546            self.top_line = total.saturating_sub(body);
1547            self.top_row = 0;
1548        }
1549    }
1550
1551    /// Position the viewport so line `n` (0-indexed) is the top visible line.
1552    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1553        idx.extend_to_line(n, src);
1554        let target = n.min(idx.line_count().saturating_sub(1));
1555        self.top_line = target;
1556        self.top_row = 0;
1557    }
1558
1559    /// Position the viewport at the start of record `n` (0-indexed).
1560    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1561        // Ensure the record exists by extending the index. Records can only
1562        // appear after their constituent lines are scanned; extend repeatedly
1563        // until the record exists or we hit EOF.
1564        while idx.record_count() <= n && idx.scanned_through() < src.len() {
1565            idx.extend_to_end(src);
1566        }
1567        if idx.record_count() == 0 {
1568            return;
1569        }
1570        let target = n.min(idx.record_count().saturating_sub(1));
1571        let line_range = idx.record_line_range(target);
1572        self.top_line = line_range.start;
1573        self.top_row = 0;
1574    }
1575
1576    /// Position the viewport at `p` percent through the file by bytes.
1577    /// `p` is clamped to 0..=100. p=100 lands at the last line.
1578    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1579        let p = p.min(100) as usize;
1580        let target_byte = src.len().saturating_mul(p) / 100;
1581        idx.extend_to_byte_for_query(src, target_byte);
1582        let line_n = idx.line_at_byte(target_byte)
1583            .or_else(|| {
1584                // target_byte at or past EOF: fall through to the last line.
1585                let lc = idx.line_count();
1586                if lc > 0 { Some(lc - 1) } else { None }
1587            })
1588            .unwrap_or(0);
1589        self.top_line = line_n;
1590        self.top_row = 0;
1591    }
1592
1593    /// Get the currently top-displayed physical line index.
1594    pub fn top_line(&self) -> usize {
1595        self.top_line
1596    }
1597
1598    pub fn resize(&mut self, cols: u16, rows: u16) {
1599        self.cols = cols.max(1);
1600        self.rows = rows.max(2);
1601        self.opts.cols = self.cols;
1602    }
1603
1604    pub fn toggle_line_numbers(&mut self) {
1605        self.show_line_numbers = !self.show_line_numbers;
1606    }
1607
1608    pub fn toggle_chop(&mut self) {
1609        self.opts.wrap = !self.opts.wrap;
1610    }
1611
1612    /// Return the current set of visible (matched) line indices. Non-empty only
1613    /// in hide mode (filter or grep active without --dim). Stable public accessor
1614    /// so integration tests and external tooling can inspect filter results.
1615    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620    use super::*;
1621    use crate::source::MockSource;
1622
1623    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1624        let m = MockSource::new();
1625        m.append(content);
1626        m.finish();
1627        let idx = LineIndex::new();
1628        (m, idx)
1629    }
1630
1631    #[test]
1632    fn frame_renders_body_height_rows() {
1633        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1634        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
1635        let frame = v.frame(&m, &mut idx);
1636        assert_eq!(frame.body.len(), 4);
1637        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1638        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1639    }
1640
1641    #[test]
1642    fn scroll_down_advances_top_line() {
1643        let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1644        let mut v = Viewport::new(10, 5, "test".into());
1645        v.scroll_lines(2, &m, &mut idx);
1646        assert_eq!(v.top_line, 2);
1647        assert_eq!(v.top_row, 0);
1648    }
1649
1650    #[test]
1651    fn scroll_up_clamps_at_zero() {
1652        let (m, mut idx) = setup(b"a\nb\nc\n");
1653        let mut v = Viewport::new(10, 5, "test".into());
1654        v.scroll_lines(-5, &m, &mut idx);
1655        assert_eq!(v.top_line, 0);
1656        assert_eq!(v.top_row, 0);
1657    }
1658
1659    #[test]
1660    fn scroll_down_clamps_at_last_line() {
1661        let (m, mut idx) = setup(b"a\nb\nc\n");
1662        let mut v = Viewport::new(10, 5, "test".into());
1663        v.scroll_lines(50, &m, &mut idx);
1664        assert_eq!(v.top_line, 2);
1665    }
1666
1667    #[test]
1668    fn scroll_logical_lines_skips_wrap_rows() {
1669        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
1670        let mut content = vec![b'X'; 500];
1671        content.push(b'\n');
1672        content.extend_from_slice(b"second\n");
1673        content.extend_from_slice(b"third\n");
1674        let (m, mut idx) = setup(&content);
1675        let mut v = Viewport::new(10, 8, "f".into());
1676        v.scroll_logical_lines(1, &m, &mut idx);
1677        assert_eq!((v.top_line, v.top_row), (1, 0));
1678        v.scroll_logical_lines(1, &m, &mut idx);
1679        assert_eq!((v.top_line, v.top_row), (2, 0));
1680    }
1681
1682    #[test]
1683    fn scroll_logical_lines_back_snaps_to_line_start() {
1684        // Mid-wrap K should snap to start of current line first, then go back.
1685        let mut content = vec![b'A'; 50];
1686        content.push(b'\n');
1687        content.extend_from_slice(&[b'B'; 50]);
1688        content.push(b'\n');
1689        let (m, mut idx) = setup(&content);
1690        let mut v = Viewport::new(10, 8, "f".into());
1691        v.scroll_lines(7, &m, &mut idx);
1692        assert_eq!(v.top_line, 1, "should be on line 1");
1693        assert!(v.top_row > 0, "should be inside line 1's wraps");
1694        v.scroll_logical_lines(-1, &m, &mut idx);
1695        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1696        v.scroll_logical_lines(-1, &m, &mut idx);
1697        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1698    }
1699
1700    #[test]
1701    fn scroll_down_walks_wraps_of_last_line() {
1702        // Last line is 30 chars in a 10-col viewport → 3 wrap rows.
1703        let mut content = b"first\n".to_vec();
1704        content.extend_from_slice(&[b'X'; 30]);
1705        content.push(b'\n');
1706        let (m, mut idx) = setup(&content);
1707        let mut v = Viewport::new(10, 5, "f".into());
1708        v.scroll_lines(1, &m, &mut idx);
1709        assert_eq!((v.top_line, v.top_row), (1, 0));
1710        v.scroll_lines(1, &m, &mut idx);
1711        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1712        v.scroll_lines(1, &m, &mut idx);
1713        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1714    }
1715
1716    #[test]
1717    fn scroll_down_walks_wrap_rows_within_long_line() {
1718        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
1719        let mut content = vec![b'X'; 30];
1720        content.push(b'\n');
1721        content.extend_from_slice(b"second\n");
1722        let (m, mut idx) = setup(&content);
1723        let mut v = Viewport::new(10, 5, "f".into());
1724        v.scroll_lines(1, &m, &mut idx);
1725        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1726        v.scroll_lines(1, &m, &mut idx);
1727        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1728        v.scroll_lines(1, &m, &mut idx);
1729        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1730    }
1731
1732    #[test]
1733    fn status_line_shows_range_and_pct() {
1734        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1735        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
1736        let frame = v.frame(&m, &mut idx);
1737        assert!(frame.status.starts_with("f  1-4/10"));
1738    }
1739
1740    #[test]
1741    fn page_down_advances_by_body_rows() {
1742        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1743        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1744        v.page_down(&m, &mut idx);
1745        assert_eq!(v.top_line, 4);
1746    }
1747
1748    #[test]
1749    fn page_up_then_page_down_returns_to_start_when_no_resize() {
1750        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1751        let mut v = Viewport::new(10, 5, "f".into());
1752        v.page_down(&m, &mut idx);
1753        v.page_up(&m, &mut idx);
1754        assert_eq!(v.top_line, 0);
1755        assert_eq!(v.top_row, 0);
1756    }
1757
1758    #[test]
1759    fn half_page_down_advances_by_half_body() {
1760        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1761        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
1762        v.half_page_down(&m, &mut idx);
1763        assert_eq!(v.top_line, 3);
1764    }
1765
1766    #[test]
1767    fn goto_top_resets_position() {
1768        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1769        let mut v = Viewport::new(10, 5, "f".into());
1770        v.scroll_lines(2, &m, &mut idx);
1771        v.goto_top();
1772        assert_eq!(v.top_line, 0);
1773        assert_eq!(v.top_row, 0);
1774    }
1775
1776    #[test]
1777    fn goto_bottom_scrolls_to_last_page() {
1778        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1779        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1780        v.goto_bottom(&m, &mut idx);
1781        // Last page should show lines 7..=10 → top_line = 6.
1782        assert_eq!(v.top_line, 6);
1783    }
1784
1785    #[test]
1786    fn goto_line_positions_top_line() {
1787        let m = MockSource::new();
1788        m.append(b"a\nb\nc\nd\ne\n");
1789        let mut idx = LineIndex::new();
1790        idx.extend_to_end(&m);
1791        let mut v = Viewport::new(20, 5, "f".into());
1792        v.goto_line(3, &m, &mut idx);
1793        assert_eq!(v.top_line(), 3);
1794    }
1795
1796    #[test]
1797    fn goto_line_clamps_to_last_line() {
1798        let m = MockSource::new();
1799        m.append(b"a\nb\n");
1800        let mut idx = LineIndex::new();
1801        idx.extend_to_end(&m);
1802        let mut v = Viewport::new(20, 5, "f".into());
1803        v.goto_line(999, &m, &mut idx);
1804        assert_eq!(v.top_line(), 1);
1805    }
1806
1807    #[test]
1808    fn goto_record_positions_at_record_start_line() {
1809        let m = MockSource::new();
1810        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
1811        let mut idx = LineIndex::new();
1812        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1813        idx.extend_to_end(&m);
1814        let mut v = Viewport::new(20, 5, "f".into());
1815        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
1816        assert_eq!(v.top_line(), 2);
1817    }
1818
1819    #[test]
1820    fn goto_record_in_line_per_record_mode_equals_goto_line() {
1821        let m = MockSource::new();
1822        m.append(b"a\nb\nc\n");
1823        let mut idx = LineIndex::new();
1824        idx.extend_to_end(&m);
1825        let mut v = Viewport::new(20, 5, "f".into());
1826        v.goto_record(2, &m, &mut idx);
1827        assert_eq!(v.top_line(), 2);
1828    }
1829
1830    #[test]
1831    fn goto_percent_50_lands_in_middle() {
1832        let m = MockSource::new();
1833        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
1834        let mut idx = LineIndex::new();
1835        idx.extend_to_end(&m);
1836        let mut v = Viewport::new(20, 5, "f".into());
1837        v.goto_percent(50, &m, &mut idx);
1838        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
1839    }
1840
1841    #[test]
1842    fn goto_percent_100_lands_at_last_line() {
1843        let m = MockSource::new();
1844        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
1845        let mut idx = LineIndex::new();
1846        idx.extend_to_end(&m);
1847        let mut v = Viewport::new(20, 5, "f".into());
1848        v.goto_percent(100, &m, &mut idx);
1849        assert_eq!(v.top_line(), 2);
1850    }
1851
1852    #[test]
1853    fn goto_percent_0_lands_at_first_line() {
1854        let m = MockSource::new();
1855        m.append(b"a\nb\nc\n");
1856        let mut idx = LineIndex::new();
1857        idx.extend_to_end(&m);
1858        let mut v = Viewport::new(20, 5, "f".into());
1859        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
1860        assert_eq!(v.top_line(), 2);
1861        v.goto_percent(0, &m, &mut idx);
1862        assert_eq!(v.top_line(), 0);
1863    }
1864
1865    #[test]
1866    fn resize_updates_dimensions_and_render_opts() {
1867        let (m, mut idx) = setup(b"1\n2\n");
1868        let mut v = Viewport::new(10, 5, "f".into());
1869        v.resize(40, 12);
1870        assert_eq!(v.cols, 40);
1871        assert_eq!(v.rows, 12);
1872        assert_eq!(v.opts.cols, 40);
1873        let _ = v.frame(&m, &mut idx);
1874    }
1875
1876    #[test]
1877    fn toggle_line_numbers_changes_gutter() {
1878        let (m, mut idx) = setup(b"a\nb\nc\n");
1879        let mut v = Viewport::new(10, 5, "f".into());
1880        let frame_off = v.frame(&m, &mut idx);
1881        v.toggle_line_numbers();
1882        let frame_on = v.frame(&m, &mut idx);
1883        // With gutter, first cell is a digit or space, not 'a'.
1884        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1885        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1886    }
1887
1888    #[test]
1889    fn toggle_chop_changes_wrap_mode() {
1890        let (m, mut idx) = setup(b"abcdefghij\n");
1891        let mut v = Viewport::new(4, 5, "f".into());
1892        v.toggle_chop();
1893        let frame = v.frame(&m, &mut idx);
1894        // After toggle_chop, the line is one row, not wrapped.
1895        // Body row 0 is "abcd"; rows 1..3 are blank fill.
1896        assert_eq!(frame.body[0][..4],
1897            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1898             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1899             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1900             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1901        // Row 1 should be all-empty (no wrap continuation).
1902        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1903    }
1904
1905    // ----- Follow mode -----
1906
1907    #[test]
1908    fn is_at_bottom_initially_only_when_source_fits() {
1909        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
1910        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
1911        idx.extend_to_end(&m);
1912        assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1913    }
1914
1915    #[test]
1916    fn is_at_bottom_false_when_top_and_more_lines_below() {
1917        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1918        let v = Viewport::new(10, 5, "f".into());  // body = 4
1919        idx.extend_to_end(&m);
1920        assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1921    }
1922
1923    #[test]
1924    fn is_at_bottom_true_after_goto_bottom() {
1925        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1926        let mut v = Viewport::new(10, 5, "f".into());
1927        v.goto_bottom(&m, &mut idx);
1928        assert!(v.is_at_bottom(&idx));
1929    }
1930
1931    #[test]
1932    fn status_shows_follow_suffix_when_follow_mode_on() {
1933        let (m, mut idx) = setup(b"a\nb\n");
1934        let mut v = Viewport::new(20, 5, "f".into());
1935        let frame_off = v.frame(&m, &mut idx);
1936        assert!(!frame_off.status.contains("(F)"));
1937        v.set_follow_mode(true);
1938        let frame_on = v.frame(&m, &mut idx);
1939        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1940    }
1941
1942    #[test]
1943    fn toggle_follow_flips_state() {
1944        let mut v = Viewport::new(10, 5, "f".into());
1945        assert!(!v.follow_mode());
1946        v.toggle_follow();
1947        assert!(v.follow_mode());
1948        v.toggle_follow();
1949        assert!(!v.follow_mode());
1950    }
1951
1952    #[test]
1953    fn idle_indicator_kicks_in_at_threshold() {
1954        let (m, mut idx) = setup(b"a\nb\n");
1955        let mut v = Viewport::new(20, 5, "f".into());
1956        v.set_follow_mode(true);
1957        // 19 idle ticks → still (F).
1958        for _ in 0..19 { v.tick_idle(); }
1959        let f1 = v.frame(&m, &mut idx);
1960        assert!(f1.status.contains("(F)"));
1961        assert!(!f1.status.contains("idle"));
1962        // 20th tick crosses the threshold.
1963        v.tick_idle();
1964        let f2 = v.frame(&m, &mut idx);
1965        assert!(f2.status.contains("(F idle)"), "{}", f2.status);
1966    }
1967
1968    #[test]
1969    fn note_growth_resets_idle() {
1970        let (m, mut idx) = setup(b"a\nb\n");
1971        let mut v = Viewport::new(20, 5, "f".into());
1972        v.set_follow_mode(true);
1973        for _ in 0..25 { v.tick_idle(); }
1974        assert!(v.is_idle());
1975        v.note_growth();
1976        assert!(!v.is_idle());
1977        let f = v.frame(&m, &mut idx);
1978        assert!(!f.status.contains("idle"));
1979    }
1980
1981    #[test]
1982    fn qae_off_never_quits_even_at_bottom() {
1983        let (m, mut idx) = setup(b"a\n");
1984        let mut v = Viewport::new(20, 5, "f".into());
1985        v.set_quit_at_eof(QuitAtEof::Off);
1986        v.goto_bottom(&m, &mut idx);
1987        assert!(!v.note_motion_for_eof(true, &idx));
1988    }
1989
1990    #[test]
1991    fn qae_first_quits_immediately_at_bottom() {
1992        let (m, mut idx) = setup(b"a\n");
1993        let mut v = Viewport::new(20, 5, "f".into());
1994        v.set_quit_at_eof(QuitAtEof::First);
1995        v.goto_bottom(&m, &mut idx);
1996        assert!(v.note_motion_for_eof(true, &idx));
1997    }
1998
1999    #[test]
2000    fn qae_first_only_quits_at_eof_not_mid_file() {
2001        let mut content = Vec::new();
2002        for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2003        let (m, mut idx) = setup(&content);
2004        idx.extend_to_end(&m);  // populate so is_at_bottom can see the 50 lines
2005        let mut v = Viewport::new(20, 5, "f".into());
2006        v.set_quit_at_eof(QuitAtEof::First);
2007        // top_line is 0; with 50 lines and a 5-row body, we're not at bottom.
2008        assert!(!v.is_at_bottom(&idx));
2009        assert!(!v.note_motion_for_eof(true, &idx));
2010    }
2011
2012    #[test]
2013    fn qae_second_quits_on_second_hit() {
2014        let (m, mut idx) = setup(b"a\n");
2015        let mut v = Viewport::new(20, 5, "f".into());
2016        v.set_quit_at_eof(QuitAtEof::Second);
2017        v.goto_bottom(&m, &mut idx);
2018        // 1st forward at EOF: count, don't quit.
2019        assert!(!v.note_motion_for_eof(true, &idx));
2020        // 2nd forward at EOF: quit.
2021        assert!(v.note_motion_for_eof(true, &idx));
2022    }
2023
2024    #[test]
2025    fn squeeze_collapses_consecutive_blanks() {
2026        // Source: a, blank, blank, blank, b.
2027        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2028        let mut v = Viewport::new(10, 8, "f".into());
2029        v.set_squeeze_blanks(true);
2030        let f = v.frame(&m, &mut idx);
2031        // First non-empty body row chars (trimmed).
2032        let stringify = |row: &Vec<Cell>| -> String {
2033            row.iter().filter_map(|c| match c {
2034                Cell::Char { ch, .. } => Some(*ch),
2035                _ => None,
2036            }).collect::<String>().trim().to_string()
2037        };
2038        let rows: Vec<String> = f.body.iter().map(stringify).collect();
2039        // With squeeze: a, blank, b. Then padding.
2040        assert_eq!(&rows[0], "a");
2041        assert_eq!(&rows[1], "");
2042        assert_eq!(&rows[2], "b");
2043    }
2044
2045    #[test]
2046    fn header_pins_top_rows_when_scrolling() {
2047        // 12 lines, 6-row terminal → body_rows = 5. header=2 pins lines 0,1.
2048        let mut content = Vec::new();
2049        for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2050        let (m, mut idx) = setup(&content);
2051        let mut v = Viewport::new(20, 6, "f".into());
2052        v.set_header(2, 0);
2053        // set_header floors top_line at header_lines, so we start showing
2054        // line2 in the scroll window. Scrolling down 5 advances by 5
2055        // logical lines from there.
2056        v.scroll_lines(5, &m, &mut idx);
2057        let f = v.frame(&m, &mut idx);
2058        let chs = |row: &Vec<Cell>| -> String {
2059            row.iter().filter_map(|c| match c {
2060                Cell::Char { ch, .. } => Some(*ch),
2061                _ => None,
2062            }).collect::<String>().trim().to_string()
2063        };
2064        // Rows 0 and 1 are the pinned header (line0, line1) regardless of scroll.
2065        assert_eq!(&chs(&f.body[0]), "line0");
2066        assert_eq!(&chs(&f.body[1]), "line1");
2067        // top_line is now 2 + 5 = 7; row 2 shows line7.
2068        assert_eq!(&chs(&f.body[2]), "line7");
2069    }
2070
2071    #[test]
2072    fn page_size_when_set_overrides_body_rows() {
2073        let mut content = Vec::new();
2074        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2075        let (m, mut idx) = setup(&content);
2076        let mut v = Viewport::new(20, 10, "f".into());
2077        v.set_page_size(Some(3));
2078        let before = v.top_line();
2079        v.page_down(&m, &mut idx);
2080        assert_eq!(v.top_line(), before + 3);
2081        v.page_up(&m, &mut idx);
2082        assert_eq!(v.top_line(), before);
2083    }
2084
2085    #[test]
2086    fn page_size_unset_uses_body_rows() {
2087        let mut content = Vec::new();
2088        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2089        let (m, mut idx) = setup(&content);
2090        let mut v = Viewport::new(20, 10, "f".into());
2091        // body_rows = rows - 1 = 9.
2092        v.page_down(&m, &mut idx);
2093        assert_eq!(v.top_line(), 9);
2094    }
2095
2096    #[test]
2097    fn header_zero_lines_renders_like_no_header() {
2098        let mut content = Vec::new();
2099        for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2100        let (m, mut idx) = setup(&content);
2101        let mut v = Viewport::new(20, 6, "f".into());
2102        v.set_header(0, 0);
2103        let f = v.frame(&m, &mut idx);
2104        let chs = |row: &Vec<Cell>| -> String {
2105            row.iter().filter_map(|c| match c {
2106                Cell::Char { ch, .. } => Some(*ch),
2107                _ => None,
2108            }).collect::<String>().trim().to_string()
2109        };
2110        assert_eq!(&chs(&f.body[0]), "line0");
2111        assert_eq!(&chs(&f.body[1]), "line1");
2112    }
2113
2114    #[test]
2115    fn squeeze_off_preserves_blanks() {
2116        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2117        let mut v = Viewport::new(10, 8, "f".into());
2118        // Default is off.
2119        let f = v.frame(&m, &mut idx);
2120        let stringify = |row: &Vec<Cell>| -> String {
2121            row.iter().filter_map(|c| match c {
2122                Cell::Char { ch, .. } => Some(*ch),
2123                _ => None,
2124            }).collect::<String>().trim().to_string()
2125        };
2126        let rows: Vec<String> = f.body.iter().map(stringify).collect();
2127        // Without squeeze: a, blank, blank, blank, b.
2128        assert_eq!(&rows[0], "a");
2129        assert_eq!(&rows[1], "");
2130        assert_eq!(&rows[2], "");
2131        assert_eq!(&rows[3], "");
2132        assert_eq!(&rows[4], "b");
2133    }
2134
2135    #[test]
2136    fn qae_second_resets_on_backward_motion() {
2137        let (m, mut idx) = setup(b"a\n");
2138        let mut v = Viewport::new(20, 5, "f".into());
2139        v.set_quit_at_eof(QuitAtEof::Second);
2140        v.goto_bottom(&m, &mut idx);
2141        assert!(!v.note_motion_for_eof(true, &idx));
2142        // Backward motion clears the counter.
2143        v.note_motion_for_eof(false, &idx);
2144        // Next forward starts fresh: counts, doesn't quit.
2145        assert!(!v.note_motion_for_eof(true, &idx));
2146        // Now the second consecutive forward triggers quit.
2147        assert!(v.note_motion_for_eof(true, &idx));
2148    }
2149
2150    #[test]
2151    fn flash_message_overrides_follow_suffix() {
2152        let (m, mut idx) = setup(b"a\nb\n");
2153        let mut v = Viewport::new(40, 5, "f".into());
2154        v.set_follow_mode(true);
2155        v.flash("(F reopened)", 3);
2156        let f = v.frame(&m, &mut idx);
2157        assert!(f.status.contains("(F reopened)"), "{}", f.status);
2158        assert!(!f.status.contains("(F idle)"));
2159    }
2160
2161    #[test]
2162    fn flash_countdown_clears() {
2163        let mut v = Viewport::new(10, 5, "f".into());
2164        v.flash("hello", 2);
2165        v.tick_flash();
2166        assert!(v.status_flash.is_some());
2167        v.tick_flash();
2168        assert!(v.status_flash.is_none());
2169    }
2170
2171    #[test]
2172    fn suspend_follow_if_off_is_noop() {
2173        let mut v = Viewport::new(10, 5, "f".into());
2174        v.set_follow_mode(true);
2175        v.suspend_follow_if(false);
2176        assert!(v.follow_mode());
2177    }
2178
2179    #[test]
2180    fn suspend_follow_if_on_flips_off() {
2181        let mut v = Viewport::new(10, 5, "f".into());
2182        v.set_follow_mode(true);
2183        v.suspend_follow_if(true);
2184        assert!(!v.follow_mode());
2185    }
2186
2187    #[test]
2188    fn case_mode_sensitive_returns_pattern_unchanged() {
2189        assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2190        assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2191    }
2192
2193    #[test]
2194    fn case_mode_insensitive_prepends_i_flag() {
2195        assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2196        assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2197    }
2198
2199    #[test]
2200    fn case_mode_smart_lowercase_is_insensitive() {
2201        assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2202    }
2203
2204    #[test]
2205    fn case_mode_smart_with_uppercase_is_sensitive() {
2206        assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2207        assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2208    }
2209
2210    #[test]
2211    fn set_case_mode_recompiles_active_search() {
2212        let (m, mut idx) = setup(b"hello WORLD\n");
2213        let mut v = Viewport::new(40, 5, "f".into());
2214        v.set_search("world".into(), SearchDirection::Forward).unwrap();
2215        // Sensitive: no match for lowercase against WORLD.
2216        assert!(!v.search_repeat(&m, &mut idx, false));
2217        // Switch to insensitive — should re-compile and now match.
2218        v.set_case_mode(CaseMode::Insensitive);
2219        assert!(v.search_repeat(&m, &mut idx, false));
2220    }
2221
2222    #[test]
2223    fn status_shows_prettify_label_when_set() {
2224        let (m, mut idx) = setup(b"a\n");
2225        let mut v = Viewport::new(40, 5, "f".into());
2226        let frame_off = v.frame(&m, &mut idx);
2227        assert!(!frame_off.status.contains("[pretty"));
2228        v.set_prettify_label(Some("json".into()));
2229        let frame_on = v.frame(&m, &mut idx);
2230        assert!(frame_on.status.contains("[pretty:json]"),
2231            "expected [pretty:json] in status, got: {}", frame_on.status);
2232        v.set_prettify_label(Some("json:err".into()));
2233        let frame_err = v.frame(&m, &mut idx);
2234        assert!(frame_err.status.contains("[pretty:json:err]"),
2235            "expected [pretty:json:err] in status, got: {}", frame_err.status);
2236    }
2237
2238    #[test]
2239    fn status_shows_l_suffix_when_live_mode_on() {
2240        let (m, mut idx) = setup(b"a\nb\n");
2241        let mut v = Viewport::new(20, 5, "f".into());
2242        let frame_off = v.frame(&m, &mut idx);
2243        assert!(!frame_off.status.contains("(L)"));
2244        v.set_live_mode(true);
2245        let frame_on = v.frame(&m, &mut idx);
2246        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2247    }
2248
2249    #[test]
2250    fn clamp_top_line_pulls_back_when_total_shrinks() {
2251        let mut v = Viewport::new(20, 5, "f".into());
2252        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
2253        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
2254        // Force top_line via a sequence; easiest: just call clamp directly.
2255        // We can't poke private state, but clamp works regardless of how we got there.
2256        v.clamp_top_line(100);  // total bigger than top_line=0, no change
2257        v.clamp_top_line(0);    // empty source: must reset
2258        // After clamp(0), line 0 is the floor.
2259        // (No public getter for top_line; we verify indirectly by going to top.)
2260        v.goto_top();
2261        // Just confirm no panic and no overflow on subsequent frame composition.
2262        let (m, mut idx) = setup(b"only\n");
2263        let _ = v.frame(&m, &mut idx);
2264    }
2265
2266    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
2267    /// when follow mode is on and the viewport is at the bottom.
2268    fn simulate_growth_tick(
2269        v: &mut Viewport,
2270        src: &MockSource,
2271        idx: &mut LineIndex,
2272    ) {
2273        if !v.follow_mode() { return; }
2274        let was_at_bottom = v.is_at_bottom(idx);
2275        let lines_before = idx.line_count();
2276        idx.notice_new_bytes(src);
2277        if idx.line_count() != lines_before && was_at_bottom {
2278            v.goto_bottom(src, idx);
2279        }
2280    }
2281
2282    #[test]
2283    fn auto_scroll_engages_when_at_bottom() {
2284        let m = MockSource::new();
2285        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
2286        let mut idx = LineIndex::new();
2287        let mut v = Viewport::new(10, 5, "f".into());
2288        v.set_follow_mode(true);
2289        idx.extend_to_end(&m);
2290        assert!(v.is_at_bottom(&idx));
2291        let top_before = {
2292            let f = v.frame(&m, &mut idx);
2293            f.status.clone()  // unused, just exercise frame
2294        };
2295        let _ = top_before;
2296        // Simulate growth: source gains 4 more lines.
2297        m.append(b"5\n6\n7\n8\n");
2298        simulate_growth_tick(&mut v, &m, &mut idx);
2299        // After auto-scroll, top_line should have advanced so the new last line is in view.
2300        assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
2301        let frame = v.frame(&m, &mut idx);
2302        // The bottom-most body row should now contain the last logical line ('8').
2303        // Find which row has '8'.
2304        let last_row = &frame.body[frame.body.len() - 1];
2305        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2306    }
2307
2308    #[test]
2309    fn auto_scroll_suppressed_when_scrolled_up() {
2310        let m = MockSource::new();
2311        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
2312        let mut idx = LineIndex::new();
2313        let mut v = Viewport::new(10, 5, "f".into());  // body=4
2314        v.set_follow_mode(true);
2315        idx.extend_to_end(&m);
2316        v.goto_bottom(&m, &mut idx);
2317        // Now scroll up off the bottom.
2318        v.scroll_lines(-2, &m, &mut idx);
2319        assert!(!v.is_at_bottom(&idx));
2320        let frame_before = v.frame(&m, &mut idx);
2321        let top_first_cell_before = frame_before.body[0][0].clone();
2322        // Simulate growth.
2323        m.append(b"9\n10\n");
2324        simulate_growth_tick(&mut v, &m, &mut idx);
2325        // Viewport should NOT have moved (auto-scroll suppressed).
2326        let frame_after = v.frame(&m, &mut idx);
2327        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2328    }
2329
2330    // ----- Search -----
2331
2332    #[test]
2333    fn set_search_compiles_regex() {
2334        let mut v = Viewport::new(10, 5, "f".into());
2335        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2336        assert!(v.search_active());
2337    }
2338
2339    #[test]
2340    fn set_search_rejects_bad_regex() {
2341        let mut v = Viewport::new(10, 5, "f".into());
2342        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2343        assert!(!err.is_empty());
2344        assert!(!v.search_active(), "no search should be set on error");
2345    }
2346
2347    #[test]
2348    fn search_step_forward_finds_match_after_top() {
2349        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2350        let mut v = Viewport::new(20, 5, "f".into());
2351        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2352        let found = v.search_repeat(&m, &mut idx, false);
2353        assert!(found);
2354        // gamma is line 2 (0-indexed)
2355        assert_eq!(v.top_line, 2);
2356    }
2357
2358    #[test]
2359    fn search_step_backward_finds_match_before_top() {
2360        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2361        let mut v = Viewport::new(20, 5, "f".into());
2362        v.scroll_lines(4, &m, &mut idx); // top_line = 4
2363        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2364        let found = v.search_repeat(&m, &mut idx, false);
2365        assert!(found);
2366        assert_eq!(v.top_line, 0);
2367    }
2368
2369    #[test]
2370    fn search_wraps_at_end() {
2371        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2372        let mut v = Viewport::new(20, 5, "f".into());
2373        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
2374        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2375        let found = v.search_repeat(&m, &mut idx, false);
2376        assert!(found, "search should wrap forward past EOF");
2377        assert_eq!(v.top_line, 0);
2378    }
2379
2380    #[test]
2381    fn search_no_match_returns_false_and_does_not_move() {
2382        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2383        let mut v = Viewport::new(20, 5, "f".into());
2384        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2385        let found = v.search_repeat(&m, &mut idx, false);
2386        assert!(!found);
2387        assert_eq!(v.top_line, 0);
2388    }
2389
2390    #[test]
2391    fn frame_records_highlight_ranges_for_matches() {
2392        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2393        let mut v = Viewport::new(20, 5, "f".into());
2394        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2395        let frame = v.frame(&m, &mut idx);
2396        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
2397        assert_eq!(frame.row_styles[0], RowStyle::Normal);
2398        assert!(frame.highlights[0].is_empty());
2399        assert!(frame.highlights[1].is_empty());
2400        assert_eq!(frame.highlights[2], vec![0..5]);
2401        assert!(frame.highlights[3].is_empty());
2402    }
2403
2404    #[test]
2405    fn frame_highlights_substring_inside_a_row() {
2406        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2407        let mut v = Viewport::new(40, 5, "f".into());
2408        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2409        let frame = v.frame(&m, &mut idx);
2410        // "beta" starts at column 18 in the first row.
2411        assert_eq!(frame.highlights[0], vec![18..22]);
2412        assert!(frame.highlights[1].is_empty());
2413    }
2414
2415    #[test]
2416    fn search_highlight_with_filter_dim_keeps_row_dim() {
2417        // alpha matches filter → Normal. beta doesn't → Dim. Search for
2418        // "beta" should leave row style Dim and mark the substring 0..4.
2419        let (m, mut idx) = setup(b"alpha\nbeta\n");
2420        let mut v = Viewport::new(20, 5, "f".into());
2421        let fmt = crate::format::LogFormat::compile(
2422            "simple",
2423            r"^(?P<line>.+)$",
2424        )
2425        .unwrap();
2426        let f = crate::filter::CompiledFilter::compile(
2427            &fmt,
2428            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2429            CaseMode::Sensitive,
2430        )
2431        .unwrap();
2432        v.set_filter(Some(f));
2433        v.set_dim_mode(true);
2434        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2435        let frame = v.frame(&m, &mut idx);
2436        assert_eq!(frame.row_styles[0], RowStyle::Normal);
2437        assert_eq!(frame.row_styles[1], RowStyle::Dim);
2438        assert_eq!(frame.highlights[1], vec![0..4]);
2439    }
2440
2441    #[test]
2442    fn grep_only_hides_non_matching_lines() {
2443        use crate::grep::GrepPredicate;
2444        let src = crate::source::MockSource::new();
2445        src.append(b"keep this error\n");
2446        src.append(b"drop this one\n");
2447        src.append(b"another error line\n");
2448        src.finish();
2449        let mut idx = crate::line_index::LineIndex::new();
2450        idx.extend_to_end(&src);
2451
2452        let mut v = Viewport::new(40, 5, "test".into());
2453        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2454        v.extend_visible_lines(&idx, &src);
2455
2456        // Only the two "error" lines should be visible.
2457        let frame = v.frame(&src, &mut idx);
2458        let body_text: Vec<String> = frame.body.iter()
2459            .map(|row| row.iter().filter_map(|c| match c {
2460                crate::render::Cell::Char { ch, .. } => Some(*ch),
2461                _ => None,
2462            }).collect())
2463            .collect();
2464        assert!(body_text[0].contains("keep this error"));
2465        assert!(body_text[1].contains("another error line"));
2466        assert!(frame.status.contains("[grep]"));
2467    }
2468
2469    #[test]
2470    fn filter_and_grep_combine_with_and() {
2471        use crate::grep::GrepPredicate;
2472        let fmt = crate::format::LogFormat::compile(
2473            "simple",
2474            r"^(?P<level>\w+) (?P<msg>.+)$",
2475        ).unwrap();
2476        let f = crate::filter::CompiledFilter::compile(
2477            &fmt,
2478            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2479            CaseMode::Sensitive,
2480        ).unwrap();
2481        let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2482
2483        let src = crate::source::MockSource::new();
2484        src.append(b"ERROR timeout connecting\n");      // matches both → keep
2485        src.append(b"ERROR file not found\n");          // matches filter only → drop
2486        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
2487        src.append(b"INFO all good\n");                 // matches neither → drop
2488        src.finish();
2489        let mut idx = crate::line_index::LineIndex::new();
2490        idx.extend_to_end(&src);
2491
2492        let mut v = Viewport::new(80, 5, "test".into());
2493        v.set_filter(Some(f));
2494        v.set_grep(Some(g));
2495        v.extend_visible_lines(&idx, &src);
2496        assert_eq!(v.visible_lines(), &[0usize]);
2497    }
2498
2499    #[test]
2500    fn search_status_shows_pattern() {
2501        let (m, mut idx) = setup(b"x\n");
2502        let mut v = Viewport::new(20, 5, "f".into());
2503        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2504        let frame = v.frame(&m, &mut idx);
2505        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
2506    }
2507
2508    #[test]
2509    fn repeat_search_after_first_match_advances() {
2510        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
2511        let mut v = Viewport::new(40, 5, "f".into());
2512        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2513        assert!(v.search_repeat(&m, &mut idx, false));
2514        assert_eq!(v.top_line, 1, "first foo");
2515        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2516        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
2517        assert_eq!(v.top_line, 3, "should advance to next foo");
2518    }
2519
2520    #[test]
2521    fn auto_scroll_paused_when_follow_off() {
2522        let m = MockSource::new();
2523        m.append(b"1\n2\n3\n4\n");
2524        let mut idx = LineIndex::new();
2525        let mut v = Viewport::new(10, 5, "f".into());
2526        // Follow is off; viewport at top.
2527        idx.extend_to_end(&m);
2528        let frame_before = v.frame(&m, &mut idx);
2529        let top_first_cell = frame_before.body[0][0].clone();
2530        m.append(b"5\n6\n7\n8\n");
2531        simulate_growth_tick(&mut v, &m, &mut idx);
2532        let frame_after = v.frame(&m, &mut idx);
2533        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
2534    }
2535
2536    // ----- Records-mode search -----
2537
2538    #[test]
2539    fn search_jumps_to_next_matching_record() {
2540        let m = MockSource::new();
2541        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
2542        let mut idx = LineIndex::new();
2543        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2544        idx.extend_to_end(&m);
2545        let mut v = Viewport::new(40, 10, "f".into());
2546        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
2547        let hit = v.search_repeat(&m, &mut idx, false);
2548        assert!(hit, "should find 'charlie' in record 2");
2549        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
2550    }
2551
2552    #[test]
2553    fn search_finds_cross_line_match_in_record_with_s_flag() {
2554        let m = MockSource::new();
2555        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
2556        let mut idx = LineIndex::new();
2557        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2558        idx.extend_to_end(&m);
2559        let mut v = Viewport::new(40, 10, "f".into());
2560        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
2561        let hit = v.search_repeat(&m, &mut idx, false);
2562        assert!(hit, "should match across \\n inside record 0 with (?s)");
2563        assert_eq!(v.top_line(), 0);
2564    }
2565
2566    #[test]
2567    fn search_repeat_with_no_match_returns_false() {
2568        let m = MockSource::new();
2569        m.append(b"[1] alpha\n[2] bravo\n");
2570        let mut idx = LineIndex::new();
2571        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2572        idx.extend_to_end(&m);
2573        let mut v = Viewport::new(40, 10, "f".into());
2574        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
2575        let hit = v.search_repeat(&m, &mut idx, false);
2576        assert!(!hit);
2577    }
2578
2579    // ----- Records-mode filter/grep -----
2580
2581    #[test]
2582    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
2583        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
2584        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
2585        let m = MockSource::new();
2586        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
2587        let mut idx = LineIndex::new();
2588        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2589        idx.extend_to_end(&m);
2590        let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2591        let mut v = Viewport::new(40, 10, "f".into());
2592        v.set_grep(Some(grep));
2593        v.extend_visible_lines(&idx, &m);
2594        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
2595        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
2596        assert_eq!(v.visible_lines(), &[0usize, 1]);
2597    }
2598
2599    #[test]
2600    fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2601        // The format regex is designed for the header line (it ends with `$`).
2602        // Applied to the full multi-line record bytes it would never match
2603        // because `$` doesn't match before a non-final `\n`. Records-mode
2604        // filter must evaluate against the first line of the record, then
2605        // include all of the record's lines when it matches.
2606        let m = MockSource::new();
2607        m.append(
2608            b"[1] kind=category\n  body a\n  body a2\n[2] kind=rule\n  body b\n",
2609        );
2610        let mut idx = LineIndex::new();
2611        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2612        idx.extend_to_end(&m);
2613        let fmt = crate::format::LogFormat::compile(
2614            "rec",
2615            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2616        )
2617        .unwrap();
2618        let f = crate::filter::CompiledFilter::compile(
2619            &fmt,
2620            vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2621            CaseMode::Sensitive,
2622        )
2623        .unwrap();
2624        let mut v = Viewport::new(40, 10, "f".into());
2625        v.set_filter(Some(f));
2626        v.extend_visible_lines(&idx, &m);
2627        // Record 0 (lines 0, 1, 2) matches; record 1 (lines 3, 4) does not.
2628        assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2629    }
2630
2631    #[test]
2632    fn grep_matches_across_record_newlines_in_records_mode() {
2633        // Pattern spans the record-header and a continuation line (needs (?s) for .).
2634        let m = MockSource::new();
2635        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
2636        let mut idx = LineIndex::new();
2637        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2638        idx.extend_to_end(&m);
2639        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2640        let mut v = Viewport::new(40, 10, "f".into());
2641        v.set_grep(Some(grep));
2642        v.extend_visible_lines(&idx, &m);
2643        // Record 0 matches (cross-line); record 1 does not.
2644        assert_eq!(v.visible_lines(), &[0usize, 1]);
2645    }
2646
2647    #[test]
2648    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2649        // All 4 lines stay in visible_lines (dim mode = no hiding).
2650        // Record 0 matches grep → Normal; record 1 does not → Dim.
2651        let m = MockSource::new();
2652        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
2653        let mut idx = LineIndex::new();
2654        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2655        idx.extend_to_end(&m);
2656        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
2657        let mut v = Viewport::new(40, 10, "f".into());
2658        v.set_grep(Some(grep));
2659        v.set_dim_mode(true);
2660        v.extend_visible_lines(&idx, &m);
2661        // Dim mode: visible_lines stays empty (hide_mode() is false).
2662        assert_eq!(v.visible_lines(), &[] as &[usize]);
2663        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
2664        assert!(!v.should_dim_line(0, &idx, &m));
2665        assert!(!v.should_dim_line(1, &idx, &m));
2666        // Lines 2 and 3 belong to non-matching record → Dim.
2667        assert!(v.should_dim_line(2, &idx, &m));
2668        assert!(v.should_dim_line(3, &idx, &m));
2669    }
2670
2671    #[test]
2672    fn status_unchanged_when_records_inactive() {
2673        let (m, mut idx) = setup(b"a\nb\nc\n");
2674        let mut v = Viewport::new(20, 5, "f".into());
2675        let frame = v.frame(&m, &mut idx);
2676        let status = &frame.status;
2677        // Default format: <label>  <top>-<bot>/<total>  <pct>%
2678        assert!(status.contains("1-3/3"), "got: {status}");
2679        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2680        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2681    }
2682
2683    #[test]
2684    fn status_r_block_uses_real_lines_in_hide_mode() {
2685        // Regression: in hide mode `bottom` is a position in visible_lines
2686        // (i.e. a count of *visible* matches), not a logical line index.
2687        // The R-block was passing that position into `line_to_record`, which
2688        // resolved to whatever record contained logical line `bottom-1` —
2689        // typically a very early record, producing nonsense like `R290-8`
2690        // where the bottom record is *before* the top record on screen.
2691        // Build a scenario: many records, only the last few match the filter,
2692        // and the viewport is scrolled to the matching tail.
2693        let m = MockSource::new();
2694        // 10 records, two physical lines each. Record N's header has `kind=A`
2695        // for N < 8 and `kind=B` for N >= 8 (so only records 8 and 9 match).
2696        let mut buf = Vec::new();
2697        for n in 0..10 {
2698            let kind = if n >= 8 { "B" } else { "A" };
2699            buf.extend_from_slice(format!("[{}] kind={}\n  body {}\n", n, kind, n).as_bytes());
2700        }
2701        m.append(&buf);
2702        m.finish();
2703
2704        let mut idx = LineIndex::new();
2705        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2706        idx.extend_to_end(&m);
2707
2708        let fmt = crate::format::LogFormat::compile(
2709            "rec",
2710            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2711        )
2712        .unwrap();
2713        let f = crate::filter::CompiledFilter::compile(
2714            &fmt,
2715            vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2716            CaseMode::Sensitive,
2717        )
2718        .unwrap();
2719
2720        // 5-row terminal: 4 body rows + 1 status row. With 4 visible-matches
2721        // rows of body and 4 visible lines, the whole filtered set fits.
2722        let mut v = Viewport::new(80, 5, "f".into());
2723        v.set_filter(Some(f));
2724        v.extend_visible_lines(&idx, &m);
2725
2726        // Jump to the first matching record (record 8, 0-indexed).
2727        v.goto_record(8, &m, &mut idx);
2728
2729        let frame = v.frame(&m, &mut idx);
2730        // Records 8 (rec_top=9) and 9 (rec_bottom=10) are on screen.
2731        assert!(
2732            frame.status.contains("R9-10/10"),
2733            "expected R9-10/10 in status, got: {}",
2734            frame.status,
2735        );
2736    }
2737
2738    #[test]
2739    fn status_dual_readout_when_records_active() {
2740        let m = MockSource::new();
2741        m.append(b"[1] a\n  cont\n[2] b\n");
2742        m.finish();
2743        let mut idx = LineIndex::new();
2744        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2745        idx.extend_to_end(&m);
2746        let mut v = Viewport::new(20, 5, "f".into());
2747        let frame = v.frame(&m, &mut idx);
2748        let status = &frame.status;
2749        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2750        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2751    }
2752
2753    #[test]
2754    fn format_status_uses_custom_template_when_set() {
2755        let m = MockSource::new();
2756        m.append(b"a\nb\nc\n");
2757        m.finish();
2758        let mut idx = LineIndex::new();
2759        idx.extend_to_end(&m);
2760        let mut v = Viewport::new(20, 5, "f".into());
2761        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2762        v.set_prompt(Some(prompt));
2763        let frame = v.frame(&m, &mut idx);
2764        assert_eq!(frame.status, "f 100%");
2765    }
2766
2767    #[test]
2768    fn status_shows_preprocess_failed_tag_when_set() {
2769        let m = MockSource::new();
2770        m.append(b"a\n");
2771        let mut idx = LineIndex::new();
2772        idx.extend_to_end(&m);
2773        let mut v = Viewport::new(40, 5, "f".into());
2774        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2775        let frame = v.frame(&m, &mut idx);
2776        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2777                "got: {}", frame.status);
2778    }
2779
2780    #[test]
2781    fn default_status_includes_help_hint() {
2782        let (m, mut idx) = setup(b"a\nb\nc\n");
2783        let mut v = Viewport::new(80, 5, "f".into());
2784        let frame = v.frame(&m, &mut idx);
2785        assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2786    }
2787
2788    #[test]
2789    fn custom_prompt_does_not_get_help_hint() {
2790        let (m, mut idx) = setup(b"a\nb\nc\n");
2791        let mut v = Viewport::new(80, 5, "f".into());
2792        v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2793        let frame = v.frame(&m, &mut idx);
2794        assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2795    }
2796
2797    #[test]
2798    fn status_shows_file_index_when_multifile() {
2799        let m = MockSource::new();
2800        m.append(b"a\n");
2801        let mut idx = LineIndex::new();
2802        idx.extend_to_end(&m);
2803        let mut v = Viewport::new(60, 5, "f.log".into());
2804        v.set_file_index(0, 3);
2805        let frame = v.frame(&m, &mut idx);
2806        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
2807    }
2808
2809    #[test]
2810    fn status_omits_file_index_when_single_file() {
2811        let m = MockSource::new();
2812        m.append(b"a\n");
2813        let mut idx = LineIndex::new();
2814        idx.extend_to_end(&m);
2815        let mut v = Viewport::new(60, 5, "f.log".into());
2816        v.set_file_index(0, 1);
2817        let frame = v.frame(&m, &mut idx);
2818        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2819    }
2820
2821    #[test]
2822    fn status_shows_tag_active_when_multimatch() {
2823        let m = MockSource::new();
2824        m.append(b"a\n");
2825        let mut idx = LineIndex::new();
2826        idx.extend_to_end(&m);
2827        let mut v = Viewport::new(80, 5, "f.log".into());
2828        v.set_tag_active(Some(("foo".into(), 2, 3)));
2829        let frame = v.frame(&m, &mut idx);
2830        assert!(
2831            frame.status.contains("[tag: foo (2/3)]"),
2832            "got: {}",
2833            frame.status
2834        );
2835    }
2836
2837    #[test]
2838    fn status_omits_tag_active_when_single_match() {
2839        let m = MockSource::new();
2840        m.append(b"a\n");
2841        let mut idx = LineIndex::new();
2842        idx.extend_to_end(&m);
2843        let mut v = Viewport::new(80, 5, "f.log".into());
2844        v.set_tag_active(Some(("foo".into(), 1, 1)));
2845        let frame = v.frame(&m, &mut idx);
2846        assert!(
2847            !frame.status.contains("[tag:"),
2848            "should not show indicator for single match: {}",
2849            frame.status
2850        );
2851    }
2852
2853    // ----- SGR state reconstruction tests -----
2854
2855    #[test]
2856    fn reconstruct_picks_up_state_from_prior_lines() {
2857        let m = MockSource::new();
2858        m.append(b"\x1b[31mline 1\n");
2859        m.append(b"line 2 (still red, no reset)\n");
2860        m.append(b"line 3\n");
2861        let mut idx = LineIndex::new();
2862        idx.extend_to_end(&m);
2863        let state = reconstruct_render_state(&m, &idx, 2);
2864        assert_eq!(
2865            state.style.fg,
2866            Some(crate::ansi::Color::Ansi(1)),
2867            "red SGR from line 0 should persist to line 2"
2868        );
2869    }
2870
2871    #[test]
2872    fn reconstruct_respects_reset_between_lines() {
2873        let m = MockSource::new();
2874        m.append(b"\x1b[31mline 1\x1b[0m\n");
2875        m.append(b"line 2 (default)\n");
2876        let mut idx = LineIndex::new();
2877        idx.extend_to_end(&m);
2878        let state = reconstruct_render_state(&m, &idx, 1);
2879        assert_eq!(state.style.fg, None);
2880    }
2881
2882    #[test]
2883    fn reconstruct_caps_walkback_at_max_lines() {
2884        let m = MockSource::new();
2885        m.append(b"\x1b[31mvery early\n");
2886        for _ in 0..300 {
2887            m.append(b"line\n");
2888        }
2889        let mut idx = LineIndex::new();
2890        idx.extend_to_end(&m);
2891        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
2892        // anchor we'd pick is line 34 (290 - 256), which is past the red.
2893        let state = reconstruct_render_state(&m, &idx, 290);
2894        assert_eq!(state.style.fg, None);
2895    }
2896}