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, src: &dyn Source, idx: &LineIndex) -> bool {
379        match self.quit_at_eof {
380            QuitAtEof::Off => false,
381            QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
382            QuitAtEof::Second if forward && self.is_at_bottom(src, 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, src: &dyn Source, idx: &LineIndex) -> bool {
889        if self.hide_mode() {
890            // Wrap-aware: at the bottom once (top_line, top_row) is at/after
891            // the visible-line anchor that puts the last match's tail on the
892            // last body row.
893            (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
894        } else {
895            // Compare in display-row units against the wrap-aware bottom
896            // anchor — `top_line + body >= line_count` would read true while
897            // a wrapped tail is still off-screen below.
898            (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
899        }
900    }
901
902    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
903    fn gutter_width(&self, idx: &LineIndex) -> u16 {
904        if !self.show_line_numbers { return 0; }
905        let n = idx.line_count().max(1);
906        let digits = (n as f64).log10().floor() as u16 + 1;
907        digits + 1
908    }
909
910    fn render_opts(&self, gutter: u16) -> RenderOpts {
911        let mut o = self.opts.clone();
912        o.cols = self.cols.saturating_sub(gutter);
913        o.mode = self.ansi_mode;
914        o
915    }
916
917    pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
918        if self.hex_mode {
919            return self.frame_hex(src);
920        }
921        let body_rows = self.body_rows() as usize;
922        idx.extend_to_line(self.top_line + body_rows + 1, src);
923
924        let gutter = self.gutter_width(idx);
925        let r_opts = self.render_opts(gutter);
926
927        // Reconstruct per-line SGR state for the start of the visible window so
928        // that unclosed SGR sequences on lines above top_line carry through.
929        // Only meaningful in Interpret mode; harmless (and cheap) to skip otherwise.
930        let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
931            reconstruct_render_state(src, idx, self.top_line)
932        } else {
933            crate::render::RenderState::default()
934        };
935        // Store in the struct field for future cache use; mark current top_line.
936        self.render_state = render_state.clone();
937        self.render_state_for = self.top_line;
938
939        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
940        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
941        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
942        let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
943        let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
944        // In hide mode we walk visible_lines; otherwise we walk logical lines.
945        let hide = self.hide_mode();
946        let total_lines = idx.line_count();
947
948        // `--header=L`: pin the first L source lines as the top L rows of
949        // the body. Renders only the first cell-row of each pinned line
950        // (matches less semantics; long pinned lines truncate). Skipped in
951        // hide mode where "first L lines" might not be visible. Skipped in
952        // raw passthrough since the user's intent there is byte-faithful
953        // emission, not pinned headers.
954        let header_rows = if !hide && !raw_passthrough {
955            self.header_lines.min(body_rows).min(total_lines)
956        } else {
957            0
958        };
959        if header_rows > 0 {
960            for hl in 0..header_rows {
961                let raw = src.bytes(idx.line_range(hl, src));
962                let display_bytes = if let Some(r) = self.display.as_ref() {
963                    match r.render_line(&raw) {
964                        Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
965                        None => raw.clone(),
966                    }
967                } else {
968                    raw.clone()
969                };
970                let rows = render_line(&display_bytes, &r_opts, None);
971                let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
972                    let mut v = Vec::with_capacity(self.cols as usize);
973                    while v.len() < self.cols as usize { v.push(Cell::Empty); }
974                    v
975                });
976                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
977                if gutter > 0 {
978                    let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
979                    for c in label.chars() {
980                        full.push(Cell::Char {
981                            ch: c,
982                            width: 1,
983                            style: crate::ansi::Style::default(),
984                            hyperlink: None,
985                        });
986                    }
987                }
988                full.append(&mut content_row);
989                body.push(full);
990                row_styles.push(RowStyle::Normal);
991                highlights.push(Vec::new());
992                raw_rows.push(None);
993            }
994        }
995
996        // For hide mode, find where the viewport starts in visible_lines.
997        let mut hide_pos = if hide {
998            self.visible_lines
999                .iter()
1000                .position(|&l| l >= self.top_line)
1001                .unwrap_or(self.visible_lines.len())
1002        } else {
1003            0
1004        };
1005        let mut line_n = if hide {
1006            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1007        } else {
1008            // When header pinning is on, skip past the pinned region so the
1009            // scrolling window doesn't show those lines a second time.
1010            self.top_line.max(self.header_lines)
1011        };
1012        let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1013
1014        while body.len() < body_rows {
1015            if line_n >= total_lines {
1016                let mut row = Vec::with_capacity(self.cols as usize);
1017                if gutter > 0 {
1018                    for _ in 0..gutter { row.push(Cell::Empty); }
1019                }
1020                while row.len() < self.cols as usize { row.push(Cell::Empty); }
1021                body.push(row);
1022                row_styles.push(RowStyle::Normal);
1023                highlights.push(Vec::new());
1024                raw_rows.push(None);
1025                line_n += 1;
1026                continue;
1027            }
1028            // Filter evaluation runs on the raw line (it uses captures, not
1029            // text), but rendering goes through the template if one is set.
1030            let raw = src.bytes(idx.line_range(line_n, src));
1031            // `-s` / --squeeze-blank-lines: skip a blank line if its
1032            // immediate predecessor (in logical-line space) was also blank.
1033            // Real line numbers / counts in `idx` stay accurate — this is a
1034            // display-layer filter only.
1035            if self.squeeze_blanks && line_is_blank(&raw) {
1036                let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1037                    let prev = src.bytes(idx.line_range(p, src));
1038                    line_is_blank(&prev)
1039                });
1040                if prev_blank {
1041                    line_n += 1;
1042                    continue;
1043                }
1044            }
1045            let display_bytes = if let Some(r) = self.display.as_ref() {
1046                match r.render_line(&raw) {
1047                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1048                    None => raw.clone(),
1049                }
1050            } else {
1051                raw.clone()
1052            };
1053            let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1054                Some(&mut render_state)
1055            } else {
1056                None
1057            };
1058            let rows = render_line(&display_bytes, &r_opts, state_arg);
1059            let style = if self.filter.is_some() || self.grep.is_some() {
1060                if self.dim_mode {
1061                    if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1062                } else {
1063                    // hide mode: only matching lines reach here
1064                    RowStyle::Normal
1065                }
1066            } else {
1067                RowStyle::Normal
1068            };
1069
1070            let mut first_emitted_for_this_line = true;
1071            for (i, mut content_row) in rows.into_iter().enumerate() {
1072                if i < skip { continue; }
1073                if body.len() >= body_rows { break; }
1074                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1075                if gutter > 0 {
1076                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1077                    for c in label.chars() {
1078                        full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1079                    }
1080                }
1081                full.append(&mut content_row);
1082                // Compute search highlights for this display row by running
1083                // the regex against the row's rendered text. Each match's
1084                // char range maps to a cell column range via `starts`.
1085                let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1086                    find_row_highlights(&full, &s.regex)
1087                } else {
1088                    Vec::new()
1089                };
1090                body.push(full);
1091                row_styles.push(style);
1092                highlights.push(row_highlights);
1093                if raw_passthrough {
1094                    if first_emitted_for_this_line {
1095                        // Emit the original line bytes verbatim once. Sub-rows
1096                        // (mid-line wrap continuations) are no-ops — the
1097                        // terminal will have already consumed enough columns
1098                        // from the line's full byte stream to fill them.
1099                        raw_rows.push(Some(raw.to_vec()));
1100                        first_emitted_for_this_line = false;
1101                    } else {
1102                        raw_rows.push(Some(Vec::new()));
1103                    }
1104                } else {
1105                    raw_rows.push(None);
1106                }
1107            }
1108            skip = 0;
1109            // Advance to next line — visible-space if hiding, logical-space otherwise.
1110            if hide {
1111                hide_pos += 1;
1112                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1113            } else {
1114                line_n += 1;
1115            }
1116        }
1117
1118        // After walking through the frame, render_state has been advanced past
1119        // top_line. Invalidate the cached sentinel so next frame re-reconstructs.
1120        self.render_state_for = usize::MAX;
1121
1122        let status = self.format_status(idx, src);
1123        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1124    }
1125
1126    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1127        if let Some(p) = self.prompt.as_ref() {
1128            let ctx = self.build_prompt_context(idx, src);
1129            return p.render(&ctx);
1130        }
1131        let body_rows = self.body_rows() as usize;
1132        let total = idx.line_count();
1133        // In hide mode, the line range and percentage refer to visible (matched)
1134        // lines, not the underlying logical line count.
1135        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1136            let visible_total = self.visible_lines.len();
1137            // top_line is a logical line; find its visible index.
1138            let cur = self
1139                .visible_lines
1140                .iter()
1141                .position(|&l| l >= self.top_line)
1142                .unwrap_or(visible_total);
1143            let top = cur + 1;
1144            let bottom = (cur + body_rows).min(visible_total.max(1));
1145            let total_str = if src.is_complete() {
1146                format!("{visible_total}/{total}")
1147            } else {
1148                format!("{visible_total}/{total}+")
1149            };
1150            (top, bottom, visible_total, total_str)
1151        } else {
1152            let top = self.top_line + 1;
1153            let bottom = (self.top_line + body_rows).min(total.max(1));
1154            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1155            (top, bottom, total, total_str)
1156        };
1157        let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1158        // In records mode, prefix line numbers with 'L' and append an 'R' record block.
1159        // The R block always refers to logical lines on screen, which in hide
1160        // mode is *not* the same as `bottom` (which counts visible matches).
1161        let bottom_line = self.bottom_visible_line(idx);
1162        let (line_prefix, records_block) = if idx.records_mode() {
1163            let line_total = idx.line_count();
1164            let rec_total = idx.record_count();
1165            let rec_block = if line_total == 0 || rec_total == 0 {
1166                format!("R0-0/{}", rec_total)
1167            } else {
1168                let rec_top = idx.line_to_record(self.top_line) + 1;
1169                let rec_bottom = idx.line_to_record(bottom_line) + 1;
1170                let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1171                    // Defensive: should be unreachable given `bottom_visible_line`
1172                    // is always `>= self.top_line`, but guard against future
1173                    // regressions producing nonsense like `R290-8/...`.
1174                    (rec_top, rec_top)
1175                } else {
1176                    (rec_top, rec_bottom)
1177                };
1178                format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1179            };
1180            ("L", Some(rec_block))
1181        } else {
1182            ("", None)
1183        };
1184        let middle = match records_block {
1185            Some(ref rb) => format!("{}{}-{}/{}  {}  {}%", line_prefix, top, bottom, total_str, rb, pct),
1186            None         => format!("{}-{}/{}  {}%", top, bottom, total_str, pct),
1187        };
1188        let label_with_index = match self.file_index {
1189            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1190            None => self.source_label.clone(),
1191        };
1192        let mut s = format!("{}  {}", label_with_index, middle);
1193        // Wrap-row offset: when scrolled inside a long wrapping line, surface
1194        // the offset so the user knows scrolling is happening at sub-line
1195        // granularity. Without this the line range above stays static while
1196        // pressing `j` and the scroll is invisible on repeating content.
1197        if !self.hide_mode() && self.top_row > 0 {
1198            let line_rows = if total > 0 {
1199                let bytes = self.line_display_bytes(src, idx, self.top_line);
1200                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1201            } else { 1 };
1202            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
1203        }
1204        if let Some(f) = self.filter.as_ref() {
1205            s.push_str(&format!("  [{}]", f.format_name));
1206        }
1207        if self.grep.is_some() {
1208            s.push_str("  [grep]");
1209        }
1210        if self.filter.is_some() || self.grep.is_some() {
1211            s.push_str(if self.dim_mode { "  [dim]" } else { "  [hide]" });
1212        }
1213        if let Some(sr) = self.search.as_ref() {
1214            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1215            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
1216        }
1217        if let Some(label) = self.prettify_label.as_ref() {
1218            s.push_str(&format!("  [pretty:{label}]"));
1219        }
1220        if self.live_mode { s.push_str("  (L)"); }
1221        if self.follow_mode {
1222            if let Some((msg, _)) = self.status_flash.as_ref() {
1223                s.push_str("  ");
1224                s.push_str(msg);
1225            } else if self.is_idle() {
1226                s.push_str("  (F idle)");
1227            } else {
1228                s.push_str("  (F)");
1229            }
1230        }
1231        if let Some(msg) = self.preprocess_failure.as_ref() {
1232            let first_line = msg.lines().next().unwrap_or("");
1233            s.push_str(&format!("  [preprocess-failed: {}]", first_line));
1234        }
1235        let tag_suffix = match &self.tag_active {
1236            Some((name, cur, total)) if *total > 1 => {
1237                format!("  [tag: {name} ({cur}/{total})]")
1238            }
1239            _ => String::new(),
1240        };
1241        s.push_str(&tag_suffix);
1242        // Right-aligned :help hint. If the existing status already overshoots
1243        // the width, no pad — the renderer will clip on draw.
1244        let used = s.chars().count();
1245        let hint = ":help";
1246        if (self.cols as usize) > used + 1 + hint.chars().count() {
1247            let pad = self.cols as usize - used - hint.chars().count();
1248            s.push_str(&" ".repeat(pad));
1249            s.push_str(hint);
1250        } else {
1251            s.push(' ');
1252            s.push_str(hint);
1253        }
1254        s
1255    }
1256
1257    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1258        use crate::prompt::PromptContext;
1259
1260        let body_rows = self.body_rows() as usize;
1261        let total = idx.line_count();
1262        let top = self.top_line + 1;
1263        let bottom = (self.top_line + body_rows).min(total.max(1));
1264        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1265        let bottom_line = self.bottom_visible_line(idx);
1266
1267        let records_mode = idx.records_mode();
1268        let (rec_top, rec_bottom, rec_total) = if records_mode {
1269            let rt = idx.line_to_record(self.top_line) + 1;
1270            let rb_raw = idx.line_to_record(bottom_line) + 1;
1271            let rb = if rb_raw < rt { rt } else { rb_raw };
1272            (rt, rb, idx.record_count())
1273        } else {
1274            (0, 0, 0)
1275        };
1276
1277        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1278            let line_rows = if total > 0 {
1279                let bytes = self.line_display_bytes(src, idx, self.top_line);
1280                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1281            } else { 1 };
1282            format!("+{}/{}", self.top_row, line_rows)
1283        } else {
1284            String::new()
1285        };
1286
1287        let format_tag = self.format_label.as_ref()
1288            .map(|n| format!("  [{}]", n))
1289            .unwrap_or_default();
1290        let filter_tag = self.filter.as_ref()
1291            .map(|f| format!("  [{}]", f.format_name))
1292            .unwrap_or_default();
1293        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
1294        let hide_tag = if self.filter.is_some() || self.grep.is_some() {
1295            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
1296        } else {
1297            String::new()
1298        };
1299        let search_tag = self.search.as_ref()
1300            .map(|s| {
1301                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1302                format!("  [{}{}]", p, s.raw)
1303            })
1304            .unwrap_or_default();
1305        let pretty_tag = self.prettify_label.as_ref()
1306            .map(|l| format!("  [pretty:{l}]"))
1307            .unwrap_or_default();
1308        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
1309        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
1310        let preprocess_failed_tag = self.preprocess_failure.as_ref()
1311            .map(|msg| {
1312                let first_line = msg.lines().next().unwrap_or("");
1313                format!("  [preprocess-failed: {}]", first_line)
1314            })
1315            .unwrap_or_default();
1316
1317        let file_index_tag = match self.file_index {
1318            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
1319            None => String::new(),
1320        };
1321
1322        let tag_tag = match &self.tag_active {
1323            Some((name, cur, total)) if *total > 1 => {
1324                format!("  [tag: {name} ({cur}/{total})]")
1325            }
1326            _ => String::new(),
1327        };
1328
1329        PromptContext {
1330            label: self.source_label.clone(),
1331            top,
1332            bottom,
1333            total,
1334            pct: pct.min(100) as u8,
1335            rec_top,
1336            rec_bottom,
1337            rec_total,
1338            records_mode,
1339            wrap_offset,
1340            format_tag,
1341            filter_tag,
1342            grep_tag,
1343            hide_tag,
1344            search_tag,
1345            pretty_tag,
1346            live_tag,
1347            follow_tag,
1348            preprocess_failed_tag,
1349            file_index_tag,
1350            tag_tag,
1351        }
1352    }
1353
1354    fn frame_hex(&self, src: &dyn Source) -> Frame {
1355        use crate::hex::format_hex_row;
1356        use crate::render::{render_line, Cell, RenderOpts};
1357
1358        let body_rows = self.rows.saturating_sub(1) as usize;
1359        let total_bytes = src.len();
1360        let total_hex_rows = total_bytes.div_ceil(16);
1361
1362        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1363        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1364        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1365
1366        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
1367
1368        for row_idx in 0..body_rows {
1369            let hex_row = self.top_line + row_idx;
1370            if hex_row >= total_hex_rows {
1371                body.push(vec![Cell::Empty; self.cols as usize]);
1372            } else {
1373                let offset = hex_row * 16;
1374                let end = (offset + 16).min(total_bytes);
1375                let bytes_cow = src.bytes(offset..end);
1376                let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1377                let rows = render_line(text.as_bytes(), &opts, None);
1378                body.push(rows.into_iter().next().unwrap_or_else(|| {
1379                    vec![Cell::Empty; self.cols as usize]
1380                }));
1381            }
1382            row_styles.push(RowStyle::Normal);
1383            highlights.push(Vec::new());
1384        }
1385
1386        let status = self.format_status_hex(src);
1387        let raw_rows = vec![None; body.len()];
1388        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
1389    }
1390
1391    fn format_status_hex(&self, src: &dyn Source) -> String {
1392        let total_bytes = src.len();
1393        let body_rows = self.rows.saturating_sub(1) as usize;
1394        // Byte offset of the first visible byte (start of the top hex row).
1395        let top_byte = self.top_line * 16;
1396        // Byte offset just past the last visible byte. Clamped to total_bytes
1397        // so we never show a value past EOF.
1398        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1399        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1400        let label_with_index = match self.file_index {
1401            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1402            None => self.source_label.clone(),
1403        };
1404        let tag_suffix = match &self.tag_active {
1405            Some((name, cur, total)) if *total > 1 => {
1406                format!("  [tag: {name} ({cur}/{total})]")
1407            }
1408            _ => String::new(),
1409        };
1410        format!(
1411            "{}  off {}-{}/{}  {}%  [hex]{}",
1412            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1413        )
1414    }
1415
1416    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
1417    /// reset to 0 so the start of the destination line is at the top of
1418    /// the viewport. In hide mode this is equivalent to `scroll_lines`
1419    /// (which already moves by visible/logical lines).
1420    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1421        if delta == 0 { return; }
1422        if self.hide_mode() {
1423            // J/K move by whole visible lines (ignoring wrap rows), with K
1424            // first snapping to the start of the current line — the visible-
1425            // line analogue of the non-hide branch below.
1426            self.extend_visible_lines(idx, src);
1427            let n = self.visible_lines.len();
1428            if n == 0 {
1429                self.top_line = 0;
1430                self.top_row = 0;
1431                return;
1432            }
1433            let vi = self
1434                .visible_lines
1435                .iter()
1436                .position(|&l| l >= self.top_line)
1437                .unwrap_or(n - 1);
1438            if delta > 0 {
1439                let target = (vi + delta as usize).min(n - 1);
1440                self.top_line = self.visible_lines[target];
1441                self.top_row = 0;
1442            } else {
1443                let back = (-delta) as usize;
1444                let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1445                let extra_back = back.saturating_sub(consumed_for_snap);
1446                self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1447                self.top_row = 0;
1448            }
1449            return;
1450        }
1451        if delta > 0 {
1452            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1453            let total = idx.line_count();
1454            if total == 0 { return; }
1455            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1456            self.top_line = target;
1457            self.top_row = 0;
1458        } else {
1459            let back = (-delta) as usize;
1460            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1461            // the start of the current line; only the remaining count goes to
1462            // previous lines. This matches the user's mental model of "jump
1463            // to the start of the previous line".
1464            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1465            let extra_back = back.saturating_sub(consumed_for_snap);
1466            self.top_line = self.top_line.saturating_sub(extra_back);
1467            self.top_row = 0;
1468        }
1469    }
1470
1471    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1472        if delta == 0 { return; }
1473        if self.hide_mode() {
1474            // Scroll by display rows over the visible (matching) lines, honoring
1475            // wrap rows via top_row — the same model as the non-hide branch
1476            // below, but walking visible_lines instead of every line.
1477            self.extend_visible_lines(idx, src);
1478            let n = self.visible_lines.len();
1479            if n == 0 {
1480                self.top_line = 0;
1481                self.top_row = 0;
1482                return;
1483            }
1484            let mut vi = self
1485                .visible_lines
1486                .iter()
1487                .position(|&l| l >= self.top_line)
1488                .unwrap_or(n - 1);
1489            // Keep top anchored on a real visible line; a top_row only means
1490            // something relative to one.
1491            if self.visible_lines[vi] != self.top_line {
1492                self.top_row = 0;
1493            }
1494            self.top_line = self.visible_lines[vi];
1495            let r_opts = self.render_opts(self.gutter_width(idx));
1496            if delta > 0 {
1497                let mut remaining = delta as usize;
1498                while remaining > 0 {
1499                    let line = self.visible_lines[vi];
1500                    let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1501                    if self.top_row + 1 < rows {
1502                        self.top_row += 1;
1503                    } else if vi + 1 < n {
1504                        self.top_row = 0;
1505                        vi += 1;
1506                        self.top_line = self.visible_lines[vi];
1507                    } else {
1508                        break;
1509                    }
1510                    remaining -= 1;
1511                }
1512                let anchor = self.hide_bottom_anchor(src, idx);
1513                if (self.top_line, self.top_row) > anchor {
1514                    self.top_line = anchor.0;
1515                    self.top_row = anchor.1;
1516                }
1517            } else {
1518                let mut remaining = (-delta) as usize;
1519                while remaining > 0 {
1520                    if self.top_row > 0 {
1521                        self.top_row -= 1;
1522                    } else if vi > 0 {
1523                        vi -= 1;
1524                        self.top_line = self.visible_lines[vi];
1525                        let line = self.visible_lines[vi];
1526                        let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1527                        self.top_row = rows.saturating_sub(1);
1528                    } else {
1529                        break;
1530                    }
1531                    remaining -= 1;
1532                }
1533            }
1534            return;
1535        }
1536        if delta > 0 {
1537            let mut remaining = delta as usize;
1538            while remaining > 0 {
1539                idx.extend_to_line(self.top_line + 1, src);
1540                let total = idx.line_count();
1541                if total == 0 { break; }
1542                let bytes = self.line_display_bytes(src, idx, self.top_line);
1543                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1544                if self.top_row + 1 < line_rows {
1545                    self.top_row += 1;
1546                } else if self.top_line + 1 < total {
1547                    self.top_row = 0;
1548                    self.top_line += 1;
1549                } else {
1550                    break;
1551                }
1552                remaining -= 1;
1553            }
1554            // Don't scroll past the natural bottom (the last line resting on
1555            // the last body row). Only clamp once the source is fully
1556            // scanned — mid-scan the true end is unknown, and clamping to a
1557            // partial index would strand the viewport short of unread content.
1558            if idx.scanned_through() >= src.len() {
1559                let anchor = self.bottom_anchor(src, idx);
1560                if (self.top_line, self.top_row) > anchor {
1561                    self.top_line = anchor.0;
1562                    self.top_row = anchor.1;
1563                }
1564            }
1565        } else {
1566            let mut remaining = (-delta) as usize;
1567            while remaining > 0 {
1568                if self.top_row > 0 {
1569                    self.top_row -= 1;
1570                } else if self.top_line > 0 {
1571                    self.top_line -= 1;
1572                    let bytes = self.line_display_bytes(src, idx, self.top_line);
1573                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1574                    self.top_row = line_rows.saturating_sub(1);
1575                } else {
1576                    break;
1577                }
1578                remaining -= 1;
1579            }
1580        }
1581    }
1582
1583    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1584        let n = self.page_size
1585            .map(|p| p as i64)
1586            .unwrap_or_else(|| self.body_rows() as i64);
1587        self.scroll_lines(n, src, idx);
1588    }
1589
1590    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1591        let n = self.page_size
1592            .map(|p| p as i64)
1593            .unwrap_or_else(|| self.body_rows() as i64);
1594        self.scroll_lines(-n, src, idx);
1595    }
1596
1597    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1598        let n = (self.body_rows() / 2).max(1) as i64;
1599        self.scroll_lines(n, src, idx);
1600    }
1601
1602    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1603        let n = (self.body_rows() / 2).max(1) as i64;
1604        self.scroll_lines(-n, src, idx);
1605    }
1606
1607    pub fn goto_top(&mut self) {
1608        self.top_line = 0;
1609        self.top_row = 0;
1610    }
1611
1612    /// The top `(line, row)` position such that the source's final display row
1613    /// lands on the last body row. Computed in display-row units by walking
1614    /// backward from the end, so it stays correct when lines wrap (the
1615    /// default): the last `body` logical lines can occupy more than `body`
1616    /// rows. Returns `(0, 0)` when the whole document fits within the body.
1617    /// Non-hide-mode only — hide mode scrolls by whole visible lines.
1618    fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1619        let body = self.body_rows() as usize;
1620        let total = idx.line_count();
1621        if total == 0 || body == 0 {
1622            return (0, 0);
1623        }
1624        let r_opts = self.render_opts(self.gutter_width(idx));
1625        let mut remaining = body;
1626        let mut line = total - 1;
1627        loop {
1628            let bytes = self.line_display_bytes(src, idx, line);
1629            let line_rows = count_rows(&bytes, &r_opts, None).max(1);
1630            if line_rows >= remaining {
1631                return (line, line_rows - remaining);
1632            }
1633            remaining -= line_rows;
1634            if line == 0 {
1635                return (0, 0);
1636            }
1637            line -= 1;
1638        }
1639    }
1640
1641    /// Hide-mode bottom anchor, in display-row units over the *visible*
1642    /// (matching) lines: the top `(line, row)` such that the last visible
1643    /// line's final wrap row lands on the last body row. Mirrors
1644    /// `bottom_anchor`, but walks `visible_lines` instead of every line.
1645    fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
1646        let body = self.body_rows() as usize;
1647        let n = self.visible_lines.len();
1648        if n == 0 || body == 0 {
1649            return (0, 0);
1650        }
1651        let r_opts = self.render_opts(self.gutter_width(idx));
1652        let mut remaining = body;
1653        let mut vi = n - 1;
1654        loop {
1655            let line = self.visible_lines[vi];
1656            let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1657            if rows >= remaining {
1658                return (line, rows - remaining);
1659            }
1660            remaining -= rows;
1661            if vi == 0 {
1662                return (self.visible_lines[0], 0);
1663            }
1664            vi -= 1;
1665        }
1666    }
1667
1668    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1669        idx.extend_to_end(src);
1670        if self.hide_mode() {
1671            self.extend_visible_lines(idx, src);
1672            let (line, row) = self.hide_bottom_anchor(src, idx);
1673            self.top_line = line;
1674            self.top_row = row;
1675        } else {
1676            let (line, row) = self.bottom_anchor(src, idx);
1677            self.top_line = line;
1678            self.top_row = row;
1679        }
1680    }
1681
1682    /// Position the viewport so line `n` (0-indexed) is the top visible line.
1683    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1684        idx.extend_to_line(n, src);
1685        let target = n.min(idx.line_count().saturating_sub(1));
1686        self.top_line = target;
1687        self.top_row = 0;
1688    }
1689
1690    /// Position the viewport at the start of record `n` (0-indexed).
1691    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1692        // Ensure the record exists by extending the index. Records can only
1693        // appear after their constituent lines are scanned; extend repeatedly
1694        // until the record exists or we hit EOF.
1695        while idx.record_count() <= n && idx.scanned_through() < src.len() {
1696            idx.extend_to_end(src);
1697        }
1698        if idx.record_count() == 0 {
1699            return;
1700        }
1701        let target = n.min(idx.record_count().saturating_sub(1));
1702        let line_range = idx.record_line_range(target);
1703        self.top_line = line_range.start;
1704        self.top_row = 0;
1705    }
1706
1707    /// Position the viewport at `p` percent through the file by bytes.
1708    /// `p` is clamped to 0..=100. p=100 lands at the last line.
1709    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1710        let p = p.min(100) as usize;
1711        let target_byte = src.len().saturating_mul(p) / 100;
1712        idx.extend_to_byte_for_query(src, target_byte);
1713        let line_n = idx.line_at_byte(target_byte)
1714            .or_else(|| {
1715                // target_byte at or past EOF: fall through to the last line.
1716                let lc = idx.line_count();
1717                if lc > 0 { Some(lc - 1) } else { None }
1718            })
1719            .unwrap_or(0);
1720        self.top_line = line_n;
1721        self.top_row = 0;
1722    }
1723
1724    /// Get the currently top-displayed physical line index.
1725    pub fn top_line(&self) -> usize {
1726        self.top_line
1727    }
1728
1729    pub fn resize(&mut self, cols: u16, rows: u16) {
1730        self.cols = cols.max(1);
1731        self.rows = rows.max(2);
1732        self.opts.cols = self.cols;
1733    }
1734
1735    pub fn toggle_line_numbers(&mut self) {
1736        self.show_line_numbers = !self.show_line_numbers;
1737    }
1738
1739    pub fn toggle_chop(&mut self) {
1740        self.opts.wrap = !self.opts.wrap;
1741    }
1742
1743    /// Return the current set of visible (matched) line indices. Non-empty only
1744    /// in hide mode (filter or grep active without --dim). Stable public accessor
1745    /// so integration tests and external tooling can inspect filter results.
1746    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751    use super::*;
1752    use crate::source::MockSource;
1753
1754    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1755        let m = MockSource::new();
1756        m.append(content);
1757        m.finish();
1758        let idx = LineIndex::new();
1759        (m, idx)
1760    }
1761
1762    #[test]
1763    fn frame_renders_body_height_rows() {
1764        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1765        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
1766        let frame = v.frame(&m, &mut idx);
1767        assert_eq!(frame.body.len(), 4);
1768        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1769        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1770    }
1771
1772    #[test]
1773    fn scroll_down_advances_top_line() {
1774        // 8 lines, body=4 → there are 4 lines below the first screen to scroll
1775        // into, so scrolling down by 2 lands cleanly above the bottom anchor.
1776        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1777        let mut v = Viewport::new(10, 5, "test".into());
1778        v.scroll_lines(2, &m, &mut idx);
1779        assert_eq!(v.top_line, 2);
1780        assert_eq!(v.top_row, 0);
1781    }
1782
1783    #[test]
1784    fn scroll_up_clamps_at_zero() {
1785        let (m, mut idx) = setup(b"a\nb\nc\n");
1786        let mut v = Viewport::new(10, 5, "test".into());
1787        v.scroll_lines(-5, &m, &mut idx);
1788        assert_eq!(v.top_line, 0);
1789        assert_eq!(v.top_row, 0);
1790    }
1791
1792    #[test]
1793    fn scroll_down_clamps_at_last_line() {
1794        // 8 single-row lines, body=4. Scrolling far past the end clamps at the
1795        // bottom anchor: the last line resting on the last body row, i.e.
1796        // top_line = 8 - 4 = 4. (Not line 7 at the top — that would strand the
1797        // tail off-screen, the bug this guards against.)
1798        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
1799        let mut v = Viewport::new(10, 5, "test".into());
1800        v.scroll_lines(50, &m, &mut idx);
1801        assert_eq!((v.top_line, v.top_row), (4, 0));
1802        assert!(v.is_at_bottom(&m, &idx));
1803    }
1804
1805    #[test]
1806    fn scroll_logical_lines_skips_wrap_rows() {
1807        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
1808        let mut content = vec![b'X'; 500];
1809        content.push(b'\n');
1810        content.extend_from_slice(b"second\n");
1811        content.extend_from_slice(b"third\n");
1812        let (m, mut idx) = setup(&content);
1813        let mut v = Viewport::new(10, 8, "f".into());
1814        v.scroll_logical_lines(1, &m, &mut idx);
1815        assert_eq!((v.top_line, v.top_row), (1, 0));
1816        v.scroll_logical_lines(1, &m, &mut idx);
1817        assert_eq!((v.top_line, v.top_row), (2, 0));
1818    }
1819
1820    #[test]
1821    fn scroll_logical_lines_back_snaps_to_line_start() {
1822        // Mid-wrap K should snap to start of current line first, then go back.
1823        // Three 50-char lines (5 wrap rows each) with body=8 put the bottom
1824        // anchor at (1, 2), so scrolling down 7 rows lands inside line 1's
1825        // wraps without being clamped.
1826        let mut content = vec![b'A'; 50];
1827        content.push(b'\n');
1828        content.extend_from_slice(&[b'B'; 50]);
1829        content.push(b'\n');
1830        content.extend_from_slice(&[b'C'; 50]);
1831        content.push(b'\n');
1832        let (m, mut idx) = setup(&content);
1833        let mut v = Viewport::new(10, 8, "f".into());
1834        v.scroll_lines(7, &m, &mut idx);
1835        assert_eq!(v.top_line, 1, "should be on line 1");
1836        assert!(v.top_row > 0, "should be inside line 1's wraps");
1837        v.scroll_logical_lines(-1, &m, &mut idx);
1838        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1839        v.scroll_logical_lines(-1, &m, &mut idx);
1840        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1841    }
1842
1843    #[test]
1844    fn scroll_down_walks_wraps_of_last_line() {
1845        // Last line is 60 chars in a 10-col viewport → 6 wrap rows. With body=4
1846        // the bottom anchor sits at (1, 2), so walking into the last line's
1847        // wraps up to row 2 is legitimate (it doesn't strand the tail).
1848        let mut content = b"first\n".to_vec();
1849        content.extend_from_slice(&[b'X'; 60]);
1850        content.push(b'\n');
1851        let (m, mut idx) = setup(&content);
1852        let mut v = Viewport::new(10, 5, "f".into());
1853        v.scroll_lines(1, &m, &mut idx);
1854        assert_eq!((v.top_line, v.top_row), (1, 0));
1855        v.scroll_lines(1, &m, &mut idx);
1856        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1857        v.scroll_lines(1, &m, &mut idx);
1858        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
1859        // Already at the anchor — scrolling further must not strand the tail.
1860        v.scroll_lines(5, &m, &mut idx);
1861        assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
1862    }
1863
1864    #[test]
1865    fn scroll_down_walks_wrap_rows_within_long_line() {
1866        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
1867        // Six short lines follow so the bottom anchor sits well below line 0,
1868        // leaving room to walk through line 0's wrap rows and into line 1.
1869        let mut content = vec![b'X'; 30];
1870        content.push(b'\n');
1871        content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
1872        let (m, mut idx) = setup(&content);
1873        let mut v = Viewport::new(10, 5, "f".into());
1874        v.scroll_lines(1, &m, &mut idx);
1875        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1876        v.scroll_lines(1, &m, &mut idx);
1877        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1878        v.scroll_lines(1, &m, &mut idx);
1879        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1880    }
1881
1882    #[test]
1883    fn status_line_shows_range_and_pct() {
1884        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1885        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
1886        let frame = v.frame(&m, &mut idx);
1887        assert!(frame.status.starts_with("f  1-4/10"));
1888    }
1889
1890    #[test]
1891    fn page_down_advances_by_body_rows() {
1892        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1893        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1894        v.page_down(&m, &mut idx);
1895        assert_eq!(v.top_line, 4);
1896    }
1897
1898    #[test]
1899    fn page_up_then_page_down_returns_to_start_when_no_resize() {
1900        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1901        let mut v = Viewport::new(10, 5, "f".into());
1902        v.page_down(&m, &mut idx);
1903        v.page_up(&m, &mut idx);
1904        assert_eq!(v.top_line, 0);
1905        assert_eq!(v.top_row, 0);
1906    }
1907
1908    #[test]
1909    fn half_page_down_advances_by_half_body() {
1910        // 12 lines, body=6 → bottom anchor at line 6, so a half-page (3) lands
1911        // cleanly above it.
1912        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
1913        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
1914        v.half_page_down(&m, &mut idx);
1915        assert_eq!(v.top_line, 3);
1916    }
1917
1918    #[test]
1919    fn goto_top_resets_position() {
1920        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1921        let mut v = Viewport::new(10, 5, "f".into());
1922        v.scroll_lines(2, &m, &mut idx);
1923        v.goto_top();
1924        assert_eq!(v.top_line, 0);
1925        assert_eq!(v.top_row, 0);
1926    }
1927
1928    #[test]
1929    fn goto_bottom_scrolls_to_last_page() {
1930        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1931        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1932        v.goto_bottom(&m, &mut idx);
1933        // Last page should show lines 7..=10 → top_line = 6.
1934        assert_eq!(v.top_line, 6);
1935    }
1936
1937    #[test]
1938    fn goto_line_positions_top_line() {
1939        let m = MockSource::new();
1940        m.append(b"a\nb\nc\nd\ne\n");
1941        let mut idx = LineIndex::new();
1942        idx.extend_to_end(&m);
1943        let mut v = Viewport::new(20, 5, "f".into());
1944        v.goto_line(3, &m, &mut idx);
1945        assert_eq!(v.top_line(), 3);
1946    }
1947
1948    #[test]
1949    fn goto_line_clamps_to_last_line() {
1950        let m = MockSource::new();
1951        m.append(b"a\nb\n");
1952        let mut idx = LineIndex::new();
1953        idx.extend_to_end(&m);
1954        let mut v = Viewport::new(20, 5, "f".into());
1955        v.goto_line(999, &m, &mut idx);
1956        assert_eq!(v.top_line(), 1);
1957    }
1958
1959    #[test]
1960    fn goto_record_positions_at_record_start_line() {
1961        let m = MockSource::new();
1962        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
1963        let mut idx = LineIndex::new();
1964        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1965        idx.extend_to_end(&m);
1966        let mut v = Viewport::new(20, 5, "f".into());
1967        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
1968        assert_eq!(v.top_line(), 2);
1969    }
1970
1971    #[test]
1972    fn goto_record_in_line_per_record_mode_equals_goto_line() {
1973        let m = MockSource::new();
1974        m.append(b"a\nb\nc\n");
1975        let mut idx = LineIndex::new();
1976        idx.extend_to_end(&m);
1977        let mut v = Viewport::new(20, 5, "f".into());
1978        v.goto_record(2, &m, &mut idx);
1979        assert_eq!(v.top_line(), 2);
1980    }
1981
1982    #[test]
1983    fn goto_percent_50_lands_in_middle() {
1984        let m = MockSource::new();
1985        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
1986        let mut idx = LineIndex::new();
1987        idx.extend_to_end(&m);
1988        let mut v = Viewport::new(20, 5, "f".into());
1989        v.goto_percent(50, &m, &mut idx);
1990        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
1991    }
1992
1993    #[test]
1994    fn goto_percent_100_lands_at_last_line() {
1995        let m = MockSource::new();
1996        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
1997        let mut idx = LineIndex::new();
1998        idx.extend_to_end(&m);
1999        let mut v = Viewport::new(20, 5, "f".into());
2000        v.goto_percent(100, &m, &mut idx);
2001        assert_eq!(v.top_line(), 2);
2002    }
2003
2004    #[test]
2005    fn goto_percent_0_lands_at_first_line() {
2006        let m = MockSource::new();
2007        m.append(b"a\nb\nc\n");
2008        let mut idx = LineIndex::new();
2009        idx.extend_to_end(&m);
2010        let mut v = Viewport::new(20, 5, "f".into());
2011        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
2012        assert_eq!(v.top_line(), 2);
2013        v.goto_percent(0, &m, &mut idx);
2014        assert_eq!(v.top_line(), 0);
2015    }
2016
2017    #[test]
2018    fn resize_updates_dimensions_and_render_opts() {
2019        let (m, mut idx) = setup(b"1\n2\n");
2020        let mut v = Viewport::new(10, 5, "f".into());
2021        v.resize(40, 12);
2022        assert_eq!(v.cols, 40);
2023        assert_eq!(v.rows, 12);
2024        assert_eq!(v.opts.cols, 40);
2025        let _ = v.frame(&m, &mut idx);
2026    }
2027
2028    #[test]
2029    fn toggle_line_numbers_changes_gutter() {
2030        let (m, mut idx) = setup(b"a\nb\nc\n");
2031        let mut v = Viewport::new(10, 5, "f".into());
2032        let frame_off = v.frame(&m, &mut idx);
2033        v.toggle_line_numbers();
2034        let frame_on = v.frame(&m, &mut idx);
2035        // With gutter, first cell is a digit or space, not 'a'.
2036        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2037        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2038    }
2039
2040    #[test]
2041    fn toggle_chop_changes_wrap_mode() {
2042        let (m, mut idx) = setup(b"abcdefghij\n");
2043        let mut v = Viewport::new(4, 5, "f".into());
2044        v.toggle_chop();
2045        let frame = v.frame(&m, &mut idx);
2046        // After toggle_chop, the line is one row, not wrapped.
2047        // Body row 0 is "abcd"; rows 1..3 are blank fill.
2048        assert_eq!(frame.body[0][..4],
2049            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2050             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2051             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2052             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2053        // Row 1 should be all-empty (no wrap continuation).
2054        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2055    }
2056
2057    // ----- Follow mode -----
2058
2059    #[test]
2060    fn is_at_bottom_initially_only_when_source_fits() {
2061        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
2062        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
2063        idx.extend_to_end(&m);
2064        assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2065    }
2066
2067    #[test]
2068    fn is_at_bottom_false_when_top_and_more_lines_below() {
2069        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
2070        let v = Viewport::new(10, 5, "f".into());  // body = 4
2071        idx.extend_to_end(&m);
2072        assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2073    }
2074
2075    #[test]
2076    fn is_at_bottom_true_after_goto_bottom() {
2077        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2078        let mut v = Viewport::new(10, 5, "f".into());
2079        v.goto_bottom(&m, &mut idx);
2080        assert!(v.is_at_bottom(&m, &idx));
2081    }
2082
2083    #[test]
2084    fn status_shows_follow_suffix_when_follow_mode_on() {
2085        let (m, mut idx) = setup(b"a\nb\n");
2086        let mut v = Viewport::new(20, 5, "f".into());
2087        let frame_off = v.frame(&m, &mut idx);
2088        assert!(!frame_off.status.contains("(F)"));
2089        v.set_follow_mode(true);
2090        let frame_on = v.frame(&m, &mut idx);
2091        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2092    }
2093
2094    #[test]
2095    fn toggle_follow_flips_state() {
2096        let mut v = Viewport::new(10, 5, "f".into());
2097        assert!(!v.follow_mode());
2098        v.toggle_follow();
2099        assert!(v.follow_mode());
2100        v.toggle_follow();
2101        assert!(!v.follow_mode());
2102    }
2103
2104    #[test]
2105    fn idle_indicator_kicks_in_at_threshold() {
2106        let (m, mut idx) = setup(b"a\nb\n");
2107        let mut v = Viewport::new(20, 5, "f".into());
2108        v.set_follow_mode(true);
2109        // 19 idle ticks → still (F).
2110        for _ in 0..19 { v.tick_idle(); }
2111        let f1 = v.frame(&m, &mut idx);
2112        assert!(f1.status.contains("(F)"));
2113        assert!(!f1.status.contains("idle"));
2114        // 20th tick crosses the threshold.
2115        v.tick_idle();
2116        let f2 = v.frame(&m, &mut idx);
2117        assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2118    }
2119
2120    #[test]
2121    fn note_growth_resets_idle() {
2122        let (m, mut idx) = setup(b"a\nb\n");
2123        let mut v = Viewport::new(20, 5, "f".into());
2124        v.set_follow_mode(true);
2125        for _ in 0..25 { v.tick_idle(); }
2126        assert!(v.is_idle());
2127        v.note_growth();
2128        assert!(!v.is_idle());
2129        let f = v.frame(&m, &mut idx);
2130        assert!(!f.status.contains("idle"));
2131    }
2132
2133    #[test]
2134    fn qae_off_never_quits_even_at_bottom() {
2135        let (m, mut idx) = setup(b"a\n");
2136        let mut v = Viewport::new(20, 5, "f".into());
2137        v.set_quit_at_eof(QuitAtEof::Off);
2138        v.goto_bottom(&m, &mut idx);
2139        assert!(!v.note_motion_for_eof(true, &m, &idx));
2140    }
2141
2142    #[test]
2143    fn qae_first_quits_immediately_at_bottom() {
2144        let (m, mut idx) = setup(b"a\n");
2145        let mut v = Viewport::new(20, 5, "f".into());
2146        v.set_quit_at_eof(QuitAtEof::First);
2147        v.goto_bottom(&m, &mut idx);
2148        assert!(v.note_motion_for_eof(true, &m, &idx));
2149    }
2150
2151    #[test]
2152    fn qae_first_only_quits_at_eof_not_mid_file() {
2153        let mut content = Vec::new();
2154        for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2155        let (m, mut idx) = setup(&content);
2156        idx.extend_to_end(&m);  // populate so is_at_bottom can see the 50 lines
2157        let mut v = Viewport::new(20, 5, "f".into());
2158        v.set_quit_at_eof(QuitAtEof::First);
2159        // top_line is 0; with 50 lines and a 5-row body, we're not at bottom.
2160        assert!(!v.is_at_bottom(&m, &idx));
2161        assert!(!v.note_motion_for_eof(true, &m, &idx));
2162    }
2163
2164    #[test]
2165    fn qae_second_quits_on_second_hit() {
2166        let (m, mut idx) = setup(b"a\n");
2167        let mut v = Viewport::new(20, 5, "f".into());
2168        v.set_quit_at_eof(QuitAtEof::Second);
2169        v.goto_bottom(&m, &mut idx);
2170        // 1st forward at EOF: count, don't quit.
2171        assert!(!v.note_motion_for_eof(true, &m, &idx));
2172        // 2nd forward at EOF: quit.
2173        assert!(v.note_motion_for_eof(true, &m, &idx));
2174    }
2175
2176    #[test]
2177    fn squeeze_collapses_consecutive_blanks() {
2178        // Source: a, blank, blank, blank, b.
2179        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2180        let mut v = Viewport::new(10, 8, "f".into());
2181        v.set_squeeze_blanks(true);
2182        let f = v.frame(&m, &mut idx);
2183        // First non-empty body row chars (trimmed).
2184        let stringify = |row: &Vec<Cell>| -> String {
2185            row.iter().filter_map(|c| match c {
2186                Cell::Char { ch, .. } => Some(*ch),
2187                _ => None,
2188            }).collect::<String>().trim().to_string()
2189        };
2190        let rows: Vec<String> = f.body.iter().map(stringify).collect();
2191        // With squeeze: a, blank, b. Then padding.
2192        assert_eq!(&rows[0], "a");
2193        assert_eq!(&rows[1], "");
2194        assert_eq!(&rows[2], "b");
2195    }
2196
2197    #[test]
2198    fn header_pins_top_rows_when_scrolling() {
2199        // 12 lines, 6-row terminal → body_rows = 5. header=2 pins lines 0,1.
2200        let mut content = Vec::new();
2201        for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2202        let (m, mut idx) = setup(&content);
2203        let mut v = Viewport::new(20, 6, "f".into());
2204        v.set_header(2, 0);
2205        // set_header floors top_line at header_lines, so we start showing
2206        // line2 in the scroll window. Scrolling down 5 advances by 5
2207        // logical lines from there.
2208        v.scroll_lines(5, &m, &mut idx);
2209        let f = v.frame(&m, &mut idx);
2210        let chs = |row: &Vec<Cell>| -> String {
2211            row.iter().filter_map(|c| match c {
2212                Cell::Char { ch, .. } => Some(*ch),
2213                _ => None,
2214            }).collect::<String>().trim().to_string()
2215        };
2216        // Rows 0 and 1 are the pinned header (line0, line1) regardless of scroll.
2217        assert_eq!(&chs(&f.body[0]), "line0");
2218        assert_eq!(&chs(&f.body[1]), "line1");
2219        // top_line is now 2 + 5 = 7; row 2 shows line7.
2220        assert_eq!(&chs(&f.body[2]), "line7");
2221    }
2222
2223    #[test]
2224    fn page_size_when_set_overrides_body_rows() {
2225        let mut content = Vec::new();
2226        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2227        let (m, mut idx) = setup(&content);
2228        let mut v = Viewport::new(20, 10, "f".into());
2229        v.set_page_size(Some(3));
2230        let before = v.top_line();
2231        v.page_down(&m, &mut idx);
2232        assert_eq!(v.top_line(), before + 3);
2233        v.page_up(&m, &mut idx);
2234        assert_eq!(v.top_line(), before);
2235    }
2236
2237    #[test]
2238    fn page_size_unset_uses_body_rows() {
2239        let mut content = Vec::new();
2240        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2241        let (m, mut idx) = setup(&content);
2242        let mut v = Viewport::new(20, 10, "f".into());
2243        // body_rows = rows - 1 = 9.
2244        v.page_down(&m, &mut idx);
2245        assert_eq!(v.top_line(), 9);
2246    }
2247
2248    #[test]
2249    fn header_zero_lines_renders_like_no_header() {
2250        let mut content = Vec::new();
2251        for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2252        let (m, mut idx) = setup(&content);
2253        let mut v = Viewport::new(20, 6, "f".into());
2254        v.set_header(0, 0);
2255        let f = v.frame(&m, &mut idx);
2256        let chs = |row: &Vec<Cell>| -> String {
2257            row.iter().filter_map(|c| match c {
2258                Cell::Char { ch, .. } => Some(*ch),
2259                _ => None,
2260            }).collect::<String>().trim().to_string()
2261        };
2262        assert_eq!(&chs(&f.body[0]), "line0");
2263        assert_eq!(&chs(&f.body[1]), "line1");
2264    }
2265
2266    #[test]
2267    fn squeeze_off_preserves_blanks() {
2268        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2269        let mut v = Viewport::new(10, 8, "f".into());
2270        // Default is off.
2271        let f = v.frame(&m, &mut idx);
2272        let stringify = |row: &Vec<Cell>| -> String {
2273            row.iter().filter_map(|c| match c {
2274                Cell::Char { ch, .. } => Some(*ch),
2275                _ => None,
2276            }).collect::<String>().trim().to_string()
2277        };
2278        let rows: Vec<String> = f.body.iter().map(stringify).collect();
2279        // Without squeeze: a, blank, blank, blank, b.
2280        assert_eq!(&rows[0], "a");
2281        assert_eq!(&rows[1], "");
2282        assert_eq!(&rows[2], "");
2283        assert_eq!(&rows[3], "");
2284        assert_eq!(&rows[4], "b");
2285    }
2286
2287    #[test]
2288    fn qae_second_resets_on_backward_motion() {
2289        let (m, mut idx) = setup(b"a\n");
2290        let mut v = Viewport::new(20, 5, "f".into());
2291        v.set_quit_at_eof(QuitAtEof::Second);
2292        v.goto_bottom(&m, &mut idx);
2293        assert!(!v.note_motion_for_eof(true, &m, &idx));
2294        // Backward motion clears the counter.
2295        v.note_motion_for_eof(false, &m, &idx);
2296        // Next forward starts fresh: counts, doesn't quit.
2297        assert!(!v.note_motion_for_eof(true, &m, &idx));
2298        // Now the second consecutive forward triggers quit.
2299        assert!(v.note_motion_for_eof(true, &m, &idx));
2300    }
2301
2302    #[test]
2303    fn flash_message_overrides_follow_suffix() {
2304        let (m, mut idx) = setup(b"a\nb\n");
2305        let mut v = Viewport::new(40, 5, "f".into());
2306        v.set_follow_mode(true);
2307        v.flash("(F reopened)", 3);
2308        let f = v.frame(&m, &mut idx);
2309        assert!(f.status.contains("(F reopened)"), "{}", f.status);
2310        assert!(!f.status.contains("(F idle)"));
2311    }
2312
2313    #[test]
2314    fn flash_countdown_clears() {
2315        let mut v = Viewport::new(10, 5, "f".into());
2316        v.flash("hello", 2);
2317        v.tick_flash();
2318        assert!(v.status_flash.is_some());
2319        v.tick_flash();
2320        assert!(v.status_flash.is_none());
2321    }
2322
2323    #[test]
2324    fn suspend_follow_if_off_is_noop() {
2325        let mut v = Viewport::new(10, 5, "f".into());
2326        v.set_follow_mode(true);
2327        v.suspend_follow_if(false);
2328        assert!(v.follow_mode());
2329    }
2330
2331    #[test]
2332    fn suspend_follow_if_on_flips_off() {
2333        let mut v = Viewport::new(10, 5, "f".into());
2334        v.set_follow_mode(true);
2335        v.suspend_follow_if(true);
2336        assert!(!v.follow_mode());
2337    }
2338
2339    #[test]
2340    fn case_mode_sensitive_returns_pattern_unchanged() {
2341        assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
2342        assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
2343    }
2344
2345    #[test]
2346    fn case_mode_insensitive_prepends_i_flag() {
2347        assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
2348        assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
2349    }
2350
2351    #[test]
2352    fn case_mode_smart_lowercase_is_insensitive() {
2353        assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
2354    }
2355
2356    #[test]
2357    fn case_mode_smart_with_uppercase_is_sensitive() {
2358        assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
2359        assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
2360    }
2361
2362    #[test]
2363    fn set_case_mode_recompiles_active_search() {
2364        let (m, mut idx) = setup(b"hello WORLD\n");
2365        let mut v = Viewport::new(40, 5, "f".into());
2366        v.set_search("world".into(), SearchDirection::Forward).unwrap();
2367        // Sensitive: no match for lowercase against WORLD.
2368        assert!(!v.search_repeat(&m, &mut idx, false));
2369        // Switch to insensitive — should re-compile and now match.
2370        v.set_case_mode(CaseMode::Insensitive);
2371        assert!(v.search_repeat(&m, &mut idx, false));
2372    }
2373
2374    #[test]
2375    fn status_shows_prettify_label_when_set() {
2376        let (m, mut idx) = setup(b"a\n");
2377        let mut v = Viewport::new(40, 5, "f".into());
2378        let frame_off = v.frame(&m, &mut idx);
2379        assert!(!frame_off.status.contains("[pretty"));
2380        v.set_prettify_label(Some("json".into()));
2381        let frame_on = v.frame(&m, &mut idx);
2382        assert!(frame_on.status.contains("[pretty:json]"),
2383            "expected [pretty:json] in status, got: {}", frame_on.status);
2384        v.set_prettify_label(Some("json:err".into()));
2385        let frame_err = v.frame(&m, &mut idx);
2386        assert!(frame_err.status.contains("[pretty:json:err]"),
2387            "expected [pretty:json:err] in status, got: {}", frame_err.status);
2388    }
2389
2390    #[test]
2391    fn status_shows_l_suffix_when_live_mode_on() {
2392        let (m, mut idx) = setup(b"a\nb\n");
2393        let mut v = Viewport::new(20, 5, "f".into());
2394        let frame_off = v.frame(&m, &mut idx);
2395        assert!(!frame_off.status.contains("(L)"));
2396        v.set_live_mode(true);
2397        let frame_on = v.frame(&m, &mut idx);
2398        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
2399    }
2400
2401    #[test]
2402    fn clamp_top_line_pulls_back_when_total_shrinks() {
2403        let mut v = Viewport::new(20, 5, "f".into());
2404        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
2405        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
2406        // Force top_line via a sequence; easiest: just call clamp directly.
2407        // We can't poke private state, but clamp works regardless of how we got there.
2408        v.clamp_top_line(100);  // total bigger than top_line=0, no change
2409        v.clamp_top_line(0);    // empty source: must reset
2410        // After clamp(0), line 0 is the floor.
2411        // (No public getter for top_line; we verify indirectly by going to top.)
2412        v.goto_top();
2413        // Just confirm no panic and no overflow on subsequent frame composition.
2414        let (m, mut idx) = setup(b"only\n");
2415        let _ = v.frame(&m, &mut idx);
2416    }
2417
2418    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
2419    /// when follow mode is on and the viewport is at the bottom.
2420    fn simulate_growth_tick(
2421        v: &mut Viewport,
2422        src: &MockSource,
2423        idx: &mut LineIndex,
2424    ) {
2425        if !v.follow_mode() { return; }
2426        let was_at_bottom = v.is_at_bottom(src, idx);
2427        let lines_before = idx.line_count();
2428        idx.notice_new_bytes(src);
2429        if idx.line_count() != lines_before && was_at_bottom {
2430            v.goto_bottom(src, idx);
2431        }
2432    }
2433
2434    #[test]
2435    fn auto_scroll_engages_when_at_bottom() {
2436        let m = MockSource::new();
2437        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
2438        let mut idx = LineIndex::new();
2439        let mut v = Viewport::new(10, 5, "f".into());
2440        v.set_follow_mode(true);
2441        idx.extend_to_end(&m);
2442        assert!(v.is_at_bottom(&m, &idx));
2443        let top_before = {
2444            let f = v.frame(&m, &mut idx);
2445            f.status.clone()  // unused, just exercise frame
2446        };
2447        let _ = top_before;
2448        // Simulate growth: source gains 4 more lines.
2449        m.append(b"5\n6\n7\n8\n");
2450        simulate_growth_tick(&mut v, &m, &mut idx);
2451        // After auto-scroll, top_line should have advanced so the new last line is in view.
2452        assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
2453        let frame = v.frame(&m, &mut idx);
2454        // The bottom-most body row should now contain the last logical line ('8').
2455        // Find which row has '8'.
2456        let last_row = &frame.body[frame.body.len() - 1];
2457        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2458    }
2459
2460    #[test]
2461    fn auto_scroll_suppressed_when_scrolled_up() {
2462        let m = MockSource::new();
2463        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
2464        let mut idx = LineIndex::new();
2465        let mut v = Viewport::new(10, 5, "f".into());  // body=4
2466        v.set_follow_mode(true);
2467        idx.extend_to_end(&m);
2468        v.goto_bottom(&m, &mut idx);
2469        // Now scroll up off the bottom.
2470        v.scroll_lines(-2, &m, &mut idx);
2471        assert!(!v.is_at_bottom(&m, &idx));
2472        let frame_before = v.frame(&m, &mut idx);
2473        let top_first_cell_before = frame_before.body[0][0].clone();
2474        // Simulate growth.
2475        m.append(b"9\n10\n");
2476        simulate_growth_tick(&mut v, &m, &mut idx);
2477        // Viewport should NOT have moved (auto-scroll suppressed).
2478        let frame_after = v.frame(&m, &mut idx);
2479        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
2480    }
2481
2482    // ----- Search -----
2483
2484    #[test]
2485    fn set_search_compiles_regex() {
2486        let mut v = Viewport::new(10, 5, "f".into());
2487        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
2488        assert!(v.search_active());
2489    }
2490
2491    #[test]
2492    fn set_search_rejects_bad_regex() {
2493        let mut v = Viewport::new(10, 5, "f".into());
2494        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
2495        assert!(!err.is_empty());
2496        assert!(!v.search_active(), "no search should be set on error");
2497    }
2498
2499    #[test]
2500    fn search_step_forward_finds_match_after_top() {
2501        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2502        let mut v = Viewport::new(20, 5, "f".into());
2503        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2504        let found = v.search_repeat(&m, &mut idx, false);
2505        assert!(found);
2506        // gamma is line 2 (0-indexed)
2507        assert_eq!(v.top_line, 2);
2508    }
2509
2510    #[test]
2511    fn search_step_backward_finds_match_before_top() {
2512        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
2513        let mut v = Viewport::new(20, 5, "f".into());
2514        v.scroll_lines(4, &m, &mut idx); // top_line = 4
2515        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
2516        let found = v.search_repeat(&m, &mut idx, false);
2517        assert!(found);
2518        assert_eq!(v.top_line, 0);
2519    }
2520
2521    #[test]
2522    fn search_wraps_at_end() {
2523        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2524        let mut v = Viewport::new(20, 5, "f".into());
2525        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
2526        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
2527        let found = v.search_repeat(&m, &mut idx, false);
2528        assert!(found, "search should wrap forward past EOF");
2529        assert_eq!(v.top_line, 0);
2530    }
2531
2532    #[test]
2533    fn search_no_match_returns_false_and_does_not_move() {
2534        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
2535        let mut v = Viewport::new(20, 5, "f".into());
2536        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
2537        let found = v.search_repeat(&m, &mut idx, false);
2538        assert!(!found);
2539        assert_eq!(v.top_line, 0);
2540    }
2541
2542    #[test]
2543    fn frame_records_highlight_ranges_for_matches() {
2544        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
2545        let mut v = Viewport::new(20, 5, "f".into());
2546        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
2547        let frame = v.frame(&m, &mut idx);
2548        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
2549        assert_eq!(frame.row_styles[0], RowStyle::Normal);
2550        assert!(frame.highlights[0].is_empty());
2551        assert!(frame.highlights[1].is_empty());
2552        assert_eq!(frame.highlights[2], vec![0..5]);
2553        assert!(frame.highlights[3].is_empty());
2554    }
2555
2556    #[test]
2557    fn frame_highlights_substring_inside_a_row() {
2558        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
2559        let mut v = Viewport::new(40, 5, "f".into());
2560        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2561        let frame = v.frame(&m, &mut idx);
2562        // "beta" starts at column 18 in the first row.
2563        assert_eq!(frame.highlights[0], vec![18..22]);
2564        assert!(frame.highlights[1].is_empty());
2565    }
2566
2567    #[test]
2568    fn search_highlight_with_filter_dim_keeps_row_dim() {
2569        // alpha matches filter → Normal. beta doesn't → Dim. Search for
2570        // "beta" should leave row style Dim and mark the substring 0..4.
2571        let (m, mut idx) = setup(b"alpha\nbeta\n");
2572        let mut v = Viewport::new(20, 5, "f".into());
2573        let fmt = crate::format::LogFormat::compile(
2574            "simple",
2575            r"^(?P<line>.+)$",
2576        )
2577        .unwrap();
2578        let f = crate::filter::CompiledFilter::compile(
2579            &fmt,
2580            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
2581            CaseMode::Sensitive,
2582        )
2583        .unwrap();
2584        v.set_filter(Some(f));
2585        v.set_dim_mode(true);
2586        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
2587        let frame = v.frame(&m, &mut idx);
2588        assert_eq!(frame.row_styles[0], RowStyle::Normal);
2589        assert_eq!(frame.row_styles[1], RowStyle::Dim);
2590        assert_eq!(frame.highlights[1], vec![0..4]);
2591    }
2592
2593    #[test]
2594    fn grep_only_hides_non_matching_lines() {
2595        use crate::grep::GrepPredicate;
2596        let src = crate::source::MockSource::new();
2597        src.append(b"keep this error\n");
2598        src.append(b"drop this one\n");
2599        src.append(b"another error line\n");
2600        src.finish();
2601        let mut idx = crate::line_index::LineIndex::new();
2602        idx.extend_to_end(&src);
2603
2604        let mut v = Viewport::new(40, 5, "test".into());
2605        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
2606        v.extend_visible_lines(&idx, &src);
2607
2608        // Only the two "error" lines should be visible.
2609        let frame = v.frame(&src, &mut idx);
2610        let body_text: Vec<String> = frame.body.iter()
2611            .map(|row| row.iter().filter_map(|c| match c {
2612                crate::render::Cell::Char { ch, .. } => Some(*ch),
2613                _ => None,
2614            }).collect())
2615            .collect();
2616        assert!(body_text[0].contains("keep this error"));
2617        assert!(body_text[1].contains("another error line"));
2618        assert!(frame.status.contains("[grep]"));
2619    }
2620
2621    #[test]
2622    fn filter_and_grep_combine_with_and() {
2623        use crate::grep::GrepPredicate;
2624        let fmt = crate::format::LogFormat::compile(
2625            "simple",
2626            r"^(?P<level>\w+) (?P<msg>.+)$",
2627        ).unwrap();
2628        let f = crate::filter::CompiledFilter::compile(
2629            &fmt,
2630            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
2631            CaseMode::Sensitive,
2632        ).unwrap();
2633        let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2634
2635        let src = crate::source::MockSource::new();
2636        src.append(b"ERROR timeout connecting\n");      // matches both → keep
2637        src.append(b"ERROR file not found\n");          // matches filter only → drop
2638        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
2639        src.append(b"INFO all good\n");                 // matches neither → drop
2640        src.finish();
2641        let mut idx = crate::line_index::LineIndex::new();
2642        idx.extend_to_end(&src);
2643
2644        let mut v = Viewport::new(80, 5, "test".into());
2645        v.set_filter(Some(f));
2646        v.set_grep(Some(g));
2647        v.extend_visible_lines(&idx, &src);
2648        assert_eq!(v.visible_lines(), &[0usize]);
2649    }
2650
2651    #[test]
2652    fn search_status_shows_pattern() {
2653        let (m, mut idx) = setup(b"x\n");
2654        let mut v = Viewport::new(20, 5, "f".into());
2655        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2656        let frame = v.frame(&m, &mut idx);
2657        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
2658    }
2659
2660    #[test]
2661    fn repeat_search_after_first_match_advances() {
2662        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
2663        let mut v = Viewport::new(40, 5, "f".into());
2664        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2665        assert!(v.search_repeat(&m, &mut idx, false));
2666        assert_eq!(v.top_line, 1, "first foo");
2667        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
2668        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
2669        assert_eq!(v.top_line, 3, "should advance to next foo");
2670    }
2671
2672    #[test]
2673    fn auto_scroll_paused_when_follow_off() {
2674        let m = MockSource::new();
2675        m.append(b"1\n2\n3\n4\n");
2676        let mut idx = LineIndex::new();
2677        let mut v = Viewport::new(10, 5, "f".into());
2678        // Follow is off; viewport at top.
2679        idx.extend_to_end(&m);
2680        let frame_before = v.frame(&m, &mut idx);
2681        let top_first_cell = frame_before.body[0][0].clone();
2682        m.append(b"5\n6\n7\n8\n");
2683        simulate_growth_tick(&mut v, &m, &mut idx);
2684        let frame_after = v.frame(&m, &mut idx);
2685        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
2686    }
2687
2688    // ----- Records-mode search -----
2689
2690    #[test]
2691    fn search_jumps_to_next_matching_record() {
2692        let m = MockSource::new();
2693        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
2694        let mut idx = LineIndex::new();
2695        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2696        idx.extend_to_end(&m);
2697        let mut v = Viewport::new(40, 10, "f".into());
2698        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
2699        let hit = v.search_repeat(&m, &mut idx, false);
2700        assert!(hit, "should find 'charlie' in record 2");
2701        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
2702    }
2703
2704    #[test]
2705    fn search_finds_cross_line_match_in_record_with_s_flag() {
2706        let m = MockSource::new();
2707        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
2708        let mut idx = LineIndex::new();
2709        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2710        idx.extend_to_end(&m);
2711        let mut v = Viewport::new(40, 10, "f".into());
2712        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
2713        let hit = v.search_repeat(&m, &mut idx, false);
2714        assert!(hit, "should match across \\n inside record 0 with (?s)");
2715        assert_eq!(v.top_line(), 0);
2716    }
2717
2718    #[test]
2719    fn search_repeat_with_no_match_returns_false() {
2720        let m = MockSource::new();
2721        m.append(b"[1] alpha\n[2] bravo\n");
2722        let mut idx = LineIndex::new();
2723        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2724        idx.extend_to_end(&m);
2725        let mut v = Viewport::new(40, 10, "f".into());
2726        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
2727        let hit = v.search_repeat(&m, &mut idx, false);
2728        assert!(!hit);
2729    }
2730
2731    // ----- Records-mode filter/grep -----
2732
2733    #[test]
2734    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
2735        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
2736        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
2737        let m = MockSource::new();
2738        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
2739        let mut idx = LineIndex::new();
2740        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2741        idx.extend_to_end(&m);
2742        let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2743        let mut v = Viewport::new(40, 10, "f".into());
2744        v.set_grep(Some(grep));
2745        v.extend_visible_lines(&idx, &m);
2746        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
2747        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
2748        assert_eq!(v.visible_lines(), &[0usize, 1]);
2749    }
2750
2751    #[test]
2752    fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2753        // The format regex is designed for the header line (it ends with `$`).
2754        // Applied to the full multi-line record bytes it would never match
2755        // because `$` doesn't match before a non-final `\n`. Records-mode
2756        // filter must evaluate against the first line of the record, then
2757        // include all of the record's lines when it matches.
2758        let m = MockSource::new();
2759        m.append(
2760            b"[1] kind=category\n  body a\n  body a2\n[2] kind=rule\n  body b\n",
2761        );
2762        let mut idx = LineIndex::new();
2763        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2764        idx.extend_to_end(&m);
2765        let fmt = crate::format::LogFormat::compile(
2766            "rec",
2767            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2768        )
2769        .unwrap();
2770        let f = crate::filter::CompiledFilter::compile(
2771            &fmt,
2772            vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2773            CaseMode::Sensitive,
2774        )
2775        .unwrap();
2776        let mut v = Viewport::new(40, 10, "f".into());
2777        v.set_filter(Some(f));
2778        v.extend_visible_lines(&idx, &m);
2779        // Record 0 (lines 0, 1, 2) matches; record 1 (lines 3, 4) does not.
2780        assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2781    }
2782
2783    #[test]
2784    fn grep_matches_across_record_newlines_in_records_mode() {
2785        // Pattern spans the record-header and a continuation line (needs (?s) for .).
2786        let m = MockSource::new();
2787        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
2788        let mut idx = LineIndex::new();
2789        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2790        idx.extend_to_end(&m);
2791        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
2792        let mut v = Viewport::new(40, 10, "f".into());
2793        v.set_grep(Some(grep));
2794        v.extend_visible_lines(&idx, &m);
2795        // Record 0 matches (cross-line); record 1 does not.
2796        assert_eq!(v.visible_lines(), &[0usize, 1]);
2797    }
2798
2799    #[test]
2800    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2801        // All 4 lines stay in visible_lines (dim mode = no hiding).
2802        // Record 0 matches grep → Normal; record 1 does not → Dim.
2803        let m = MockSource::new();
2804        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
2805        let mut idx = LineIndex::new();
2806        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2807        idx.extend_to_end(&m);
2808        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
2809        let mut v = Viewport::new(40, 10, "f".into());
2810        v.set_grep(Some(grep));
2811        v.set_dim_mode(true);
2812        v.extend_visible_lines(&idx, &m);
2813        // Dim mode: visible_lines stays empty (hide_mode() is false).
2814        assert_eq!(v.visible_lines(), &[] as &[usize]);
2815        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
2816        assert!(!v.should_dim_line(0, &idx, &m));
2817        assert!(!v.should_dim_line(1, &idx, &m));
2818        // Lines 2 and 3 belong to non-matching record → Dim.
2819        assert!(v.should_dim_line(2, &idx, &m));
2820        assert!(v.should_dim_line(3, &idx, &m));
2821    }
2822
2823    #[test]
2824    fn status_unchanged_when_records_inactive() {
2825        let (m, mut idx) = setup(b"a\nb\nc\n");
2826        let mut v = Viewport::new(20, 5, "f".into());
2827        let frame = v.frame(&m, &mut idx);
2828        let status = &frame.status;
2829        // Default format: <label>  <top>-<bot>/<total>  <pct>%
2830        assert!(status.contains("1-3/3"), "got: {status}");
2831        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2832        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2833    }
2834
2835    #[test]
2836    fn status_r_block_uses_real_lines_in_hide_mode() {
2837        // Regression: in hide mode `bottom` is a position in visible_lines
2838        // (i.e. a count of *visible* matches), not a logical line index.
2839        // The R-block was passing that position into `line_to_record`, which
2840        // resolved to whatever record contained logical line `bottom-1` —
2841        // typically a very early record, producing nonsense like `R290-8`
2842        // where the bottom record is *before* the top record on screen.
2843        // Build a scenario: many records, only the last few match the filter,
2844        // and the viewport is scrolled to the matching tail.
2845        let m = MockSource::new();
2846        // 10 records, two physical lines each. Record N's header has `kind=A`
2847        // for N < 8 and `kind=B` for N >= 8 (so only records 8 and 9 match).
2848        let mut buf = Vec::new();
2849        for n in 0..10 {
2850            let kind = if n >= 8 { "B" } else { "A" };
2851            buf.extend_from_slice(format!("[{}] kind={}\n  body {}\n", n, kind, n).as_bytes());
2852        }
2853        m.append(&buf);
2854        m.finish();
2855
2856        let mut idx = LineIndex::new();
2857        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2858        idx.extend_to_end(&m);
2859
2860        let fmt = crate::format::LogFormat::compile(
2861            "rec",
2862            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2863        )
2864        .unwrap();
2865        let f = crate::filter::CompiledFilter::compile(
2866            &fmt,
2867            vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2868            CaseMode::Sensitive,
2869        )
2870        .unwrap();
2871
2872        // 5-row terminal: 4 body rows + 1 status row. With 4 visible-matches
2873        // rows of body and 4 visible lines, the whole filtered set fits.
2874        let mut v = Viewport::new(80, 5, "f".into());
2875        v.set_filter(Some(f));
2876        v.extend_visible_lines(&idx, &m);
2877
2878        // Jump to the first matching record (record 8, 0-indexed).
2879        v.goto_record(8, &m, &mut idx);
2880
2881        let frame = v.frame(&m, &mut idx);
2882        // Records 8 (rec_top=9) and 9 (rec_bottom=10) are on screen.
2883        assert!(
2884            frame.status.contains("R9-10/10"),
2885            "expected R9-10/10 in status, got: {}",
2886            frame.status,
2887        );
2888    }
2889
2890    #[test]
2891    fn status_dual_readout_when_records_active() {
2892        let m = MockSource::new();
2893        m.append(b"[1] a\n  cont\n[2] b\n");
2894        m.finish();
2895        let mut idx = LineIndex::new();
2896        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2897        idx.extend_to_end(&m);
2898        let mut v = Viewport::new(20, 5, "f".into());
2899        let frame = v.frame(&m, &mut idx);
2900        let status = &frame.status;
2901        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2902        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2903    }
2904
2905    #[test]
2906    fn format_status_uses_custom_template_when_set() {
2907        let m = MockSource::new();
2908        m.append(b"a\nb\nc\n");
2909        m.finish();
2910        let mut idx = LineIndex::new();
2911        idx.extend_to_end(&m);
2912        let mut v = Viewport::new(20, 5, "f".into());
2913        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2914        v.set_prompt(Some(prompt));
2915        let frame = v.frame(&m, &mut idx);
2916        assert_eq!(frame.status, "f 100%");
2917    }
2918
2919    #[test]
2920    fn status_shows_preprocess_failed_tag_when_set() {
2921        let m = MockSource::new();
2922        m.append(b"a\n");
2923        let mut idx = LineIndex::new();
2924        idx.extend_to_end(&m);
2925        let mut v = Viewport::new(40, 5, "f".into());
2926        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2927        let frame = v.frame(&m, &mut idx);
2928        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2929                "got: {}", frame.status);
2930    }
2931
2932    #[test]
2933    fn default_status_includes_help_hint() {
2934        let (m, mut idx) = setup(b"a\nb\nc\n");
2935        let mut v = Viewport::new(80, 5, "f".into());
2936        let frame = v.frame(&m, &mut idx);
2937        assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2938    }
2939
2940    #[test]
2941    fn custom_prompt_does_not_get_help_hint() {
2942        let (m, mut idx) = setup(b"a\nb\nc\n");
2943        let mut v = Viewport::new(80, 5, "f".into());
2944        v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2945        let frame = v.frame(&m, &mut idx);
2946        assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2947    }
2948
2949    #[test]
2950    fn status_shows_file_index_when_multifile() {
2951        let m = MockSource::new();
2952        m.append(b"a\n");
2953        let mut idx = LineIndex::new();
2954        idx.extend_to_end(&m);
2955        let mut v = Viewport::new(60, 5, "f.log".into());
2956        v.set_file_index(0, 3);
2957        let frame = v.frame(&m, &mut idx);
2958        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
2959    }
2960
2961    #[test]
2962    fn status_omits_file_index_when_single_file() {
2963        let m = MockSource::new();
2964        m.append(b"a\n");
2965        let mut idx = LineIndex::new();
2966        idx.extend_to_end(&m);
2967        let mut v = Viewport::new(60, 5, "f.log".into());
2968        v.set_file_index(0, 1);
2969        let frame = v.frame(&m, &mut idx);
2970        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2971    }
2972
2973    #[test]
2974    fn status_shows_tag_active_when_multimatch() {
2975        let m = MockSource::new();
2976        m.append(b"a\n");
2977        let mut idx = LineIndex::new();
2978        idx.extend_to_end(&m);
2979        let mut v = Viewport::new(80, 5, "f.log".into());
2980        v.set_tag_active(Some(("foo".into(), 2, 3)));
2981        let frame = v.frame(&m, &mut idx);
2982        assert!(
2983            frame.status.contains("[tag: foo (2/3)]"),
2984            "got: {}",
2985            frame.status
2986        );
2987    }
2988
2989    #[test]
2990    fn status_omits_tag_active_when_single_match() {
2991        let m = MockSource::new();
2992        m.append(b"a\n");
2993        let mut idx = LineIndex::new();
2994        idx.extend_to_end(&m);
2995        let mut v = Viewport::new(80, 5, "f.log".into());
2996        v.set_tag_active(Some(("foo".into(), 1, 1)));
2997        let frame = v.frame(&m, &mut idx);
2998        assert!(
2999            !frame.status.contains("[tag:"),
3000            "should not show indicator for single match: {}",
3001            frame.status
3002        );
3003    }
3004
3005    // ----- SGR state reconstruction tests -----
3006
3007    #[test]
3008    fn reconstruct_picks_up_state_from_prior_lines() {
3009        let m = MockSource::new();
3010        m.append(b"\x1b[31mline 1\n");
3011        m.append(b"line 2 (still red, no reset)\n");
3012        m.append(b"line 3\n");
3013        let mut idx = LineIndex::new();
3014        idx.extend_to_end(&m);
3015        let state = reconstruct_render_state(&m, &idx, 2);
3016        assert_eq!(
3017            state.style.fg,
3018            Some(crate::ansi::Color::Ansi(1)),
3019            "red SGR from line 0 should persist to line 2"
3020        );
3021    }
3022
3023    #[test]
3024    fn reconstruct_respects_reset_between_lines() {
3025        let m = MockSource::new();
3026        m.append(b"\x1b[31mline 1\x1b[0m\n");
3027        m.append(b"line 2 (default)\n");
3028        let mut idx = LineIndex::new();
3029        idx.extend_to_end(&m);
3030        let state = reconstruct_render_state(&m, &idx, 1);
3031        assert_eq!(state.style.fg, None);
3032    }
3033
3034    #[test]
3035    fn reconstruct_caps_walkback_at_max_lines() {
3036        let m = MockSource::new();
3037        m.append(b"\x1b[31mvery early\n");
3038        for _ in 0..300 {
3039            m.append(b"line\n");
3040        }
3041        let mut idx = LineIndex::new();
3042        idx.extend_to_end(&m);
3043        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
3044        // anchor we'd pick is line 34 (290 - 256), which is past the red.
3045        let state = reconstruct_render_state(&m, &idx, 290);
3046        assert_eq!(state.style.fg, None);
3047    }
3048}