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::or::OrGroups;
8use crate::line_index::LineIndex;
9use crate::render::{count_rows, render_line, Cell, RenderOpts};
10use crate::source::Source;
11
12/// Maximum number of lines to walk backwards when reconstructing SGR state
13/// for a scroll-up. Picked to comfortably cover a screen-height plus
14/// headroom; bounds cost so that scrolling in huge files stays snappy.
15const MAX_RECONSTRUCT_LINES: usize = 256;
16
17/// Reconstruct the SGR state at the start of `target_line` by walking up
18/// to MAX_RECONSTRUCT_LINES lines back and replaying byte-by-byte through
19/// the ANSI parser. Lines beyond the cap are skipped: if there's an
20/// unclosed SGR more than 256 lines above the top, the reconstruction starts
21/// from default — first visible lines may render in default colors until a
22/// reset appears (rare for normal log files).
23fn reconstruct_render_state(
24    src: &dyn Source,
25    idx: &crate::line_index::LineIndex,
26    target_line: usize,
27) -> crate::render::RenderState {
28    let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
29    let mut state = crate::render::RenderState::default();
30    for line_no in start..target_line {
31        let range = idx.line_range(line_no, src);
32        let raw = src.bytes(range);
33        for &b in raw.as_ref() {
34            let _ = crate::ansi::step(
35                &mut state.parse,
36                &mut state.style,
37                &mut state.hyperlink,
38                b,
39            );
40        }
41    }
42    state
43}
44
45/// Build the rendered text of a display row plus a `starts` table mapping
46/// each char index in that text back to its starting cell column. The last
47/// entry is a sentinel pointing one past the row's width, so a match's
48/// `[char_start, char_end)` translates to the cell range
49/// `starts[char_start]..starts[char_end]`.
50fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
51    let mut text = String::new();
52    let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
53    for (col, cell) in row.iter().enumerate() {
54        match cell {
55            Cell::Char { ch, .. } => {
56                starts.push(col);
57                text.push(*ch);
58            }
59            Cell::Empty => {
60                starts.push(col);
61                text.push(' ');
62            }
63            Cell::Continuation => {}
64        }
65    }
66    starts.push(row.len());
67    (text, starts)
68}
69
70/// Find every regex match in the rendered text of a row, translating each
71/// True when the byte slice contains only whitespace (space, tab, CR, LF)
72/// or is empty. Used by `-s` / `--squeeze-blank-lines` to detect runs of
73/// blank lines at frame-composition time.
74fn line_is_blank(bytes: &[u8]) -> bool {
75    bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
76}
77
78/// to a cell column range. Empty matches are dropped. Trailing-padding
79/// spaces on a row would otherwise satisfy patterns like `\s+`; we trim
80/// those by clamping match ends to where actual content stops.
81fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
82    if row.is_empty() {
83        return Vec::new();
84    }
85    let last_content_col = row
86        .iter()
87        .enumerate()
88        .rev()
89        .find_map(|(c, cell)| match cell {
90            Cell::Char { width, .. } => Some(c + *width as usize),
91            Cell::Continuation => Some(c + 1),
92            Cell::Empty => None,
93        })
94        .unwrap_or(0);
95    if last_content_col == 0 {
96        return Vec::new();
97    }
98    let (text, starts) = row_text_and_starts(row);
99    let mut out = Vec::new();
100    for m in regex.find_iter(&text) {
101        if m.start() == m.end() {
102            continue;
103        }
104        let char_start = text[..m.start()].chars().count();
105        let char_end = text[..m.end()].chars().count();
106        if char_start >= starts.len() - 1 || char_end <= char_start {
107            continue;
108        }
109        let col_start = starts[char_start];
110        let col_end = starts[char_end].min(last_content_col);
111        if col_end > col_start {
112            out.push(col_start..col_end);
113        }
114    }
115    out
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RowStyle {
120    Normal,
121    /// Render with a reduced-emphasis terminal attribute. Used by `--dim` to
122    /// keep filtered-out lines visible as context.
123    Dim,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum SearchDirection {
128    Forward,
129    Backward,
130}
131
132/// How `--grep`, `--filter ~/!~`, `/`, `?`, and `:tag` patterns interpret
133/// case. `Smart` matches less / ripgrep / vim `smartcase`: a pattern with
134/// no uppercase characters is treated as case-insensitive; one with any
135/// uppercase character is case-sensitive.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum CaseMode {
138    #[default]
139    Sensitive,
140    Smart,
141    Insensitive,
142}
143
144/// Controls auto-exit on end-of-file. `Off` (default) never quits.
145/// `Second` (less `-e`) quits on the second forward-motion that lands at
146/// EOF in a row. `First` (less `-E`) quits the moment a forward motion
147/// lands at EOF.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum QuitAtEof {
150    #[default]
151    Off,
152    Second,
153    First,
154}
155
156impl CaseMode {
157    /// Compile this case policy into a regex pattern by prepending the
158    /// `(?i)` inline flag when case-insensitive matching is desired.
159    pub fn apply_to_pattern(self, pattern: &str) -> String {
160        match self {
161            CaseMode::Sensitive => pattern.to_string(),
162            CaseMode::Insensitive => format!("(?i){pattern}"),
163            CaseMode::Smart => {
164                if pattern.chars().any(|c| c.is_uppercase()) {
165                    pattern.to_string()
166                } else {
167                    format!("(?i){pattern}")
168                }
169            }
170        }
171    }
172}
173
174#[derive(Debug, Clone)]
175pub struct SearchState {
176    pub raw: String,
177    pub regex: Regex,
178    pub direction: SearchDirection,
179}
180
181#[derive(Debug, Clone)]
182pub struct Frame {
183    pub body: Vec<Vec<Cell>>,        // exactly (rows-1) entries
184    pub row_styles: Vec<RowStyle>,   // parallel to body
185    /// Per-row column ranges to render with reverse-video. Used by `/`
186    /// search to highlight just the matched phrase rather than the whole row.
187    /// Indexed parallel to `body`; each inner Vec holds column ranges in
188    /// `[start, end)` form (cell columns).
189    pub highlights: Vec<Vec<std::ops::Range<usize>>>,
190    pub status: String,
191    /// Style applied to the status row by the writer.
192    pub status_style: crate::ansi::Style,
193    /// `AnsiMode::Raw` passthrough hints — parallel to `body`. `Some(bytes)`
194    /// on a row instructs the writer to emit those original source bytes
195    /// instead of rendering the cell grid (lets escape sequences pass to
196    /// the terminal verbatim). `Some(empty)` skips emission (continuation
197    /// row of a wrapped line whose first row already wrote the bytes).
198    /// `None` means "render cells normally". Only populated when the
199    /// viewport's ansi_mode is Raw.
200    pub raw_rows: Vec<Option<Vec<u8>>>,
201    /// When `Some`, the whole body is a single terminal-graphics escape blob
202    /// (Sixel/Kitty). `write_frame` clears the body once, positions the cursor
203    /// at (0,0), and writes these bytes verbatim instead of the per-row cell
204    /// loop. `None` for all text/hex/ASCII-image frames.
205    pub image_blob: Option<Vec<u8>>,
206}
207
208/// How images are rendered to the terminal. `Ascii` is the default (colored
209/// ASCII / half-block art via `image_render`); `Kitty` / `Sixel` emit native
210/// terminal-graphics escape blobs via `image_protocol`.
211#[cfg(feature = "image")]
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub enum ImageProtocol {
214    #[default]
215    Ascii,
216    Kitty,
217    Sixel,
218}
219
220pub struct Viewport {
221    top_line: usize,
222    top_row: usize,
223    /// Horizontal scroll offset (display columns). Only meaningful when not
224    /// wrapping (chop text / image). Clamped per-frame; reset on new file /
225    /// wrap-on. See `hscroll_active`.
226    left_col: usize,
227    cols: u16,
228    rows: u16,
229    pub opts: RenderOpts,
230    pub show_line_numbers: bool,
231    pub source_label: String,
232    follow_mode: bool,
233    live_mode: bool,
234    prettify_label: Option<String>,
235    format_label: Option<String>,
236    filter: Option<CompiledFilter>,
237    grep: Option<GrepPredicate>,
238    or_groups: OrGroups,
239    dim_mode: bool,
240    /// In hide mode (filter active, !dim), maps visible position → logical line
241    /// index. Empty otherwise.
242    visible_lines: Vec<usize>,
243    /// How many logical lines we've evaluated for filter membership. Used by
244    /// `extend_visible_lines` to avoid re-scanning lines on every tick.
245    visible_scanned: usize,
246    search: Option<SearchState>,
247    /// Active display template + format regex. When set, lines are rendered
248    /// through the template before being shown, searched, or counted for wraps.
249    /// Filtering still operates on the raw line (it uses captures, not text).
250    display: Option<crate::format::DisplayRenderer>,
251    hex_mode: bool,
252    #[cfg(feature = "image")]
253    image: Option<image::RgbaImage>,
254    #[cfg(feature = "image")]
255    image_protocol: ImageProtocol,
256    /// Pixel dimensions of one terminal cell (w, h), used by protocol rendering
257    /// to scale the image to fit terminal width and map text rows to pixels.
258    #[cfg(feature = "image")]
259    cell_px: (u16, u16),
260    /// Width-scaled image cache. The `u16` is the scaled WIDTH the cache was
261    /// built for; reused across vertical scrolls until the target width changes.
262    #[cfg(feature = "image")]
263    image_scaled: Option<(u16, image::RgbaImage)>,
264    image_mode: bool,
265    image_no_color: bool,
266    #[cfg_attr(not(feature = "image"), allow(dead_code))]
267    image_format: String,
268    #[cfg(feature = "image")]
269    image_style: crate::image_render::AsciiStyle,
270    #[cfg_attr(not(feature = "image"), allow(dead_code))]
271    image_width: Option<usize>,
272    /// Bytes per hex group in `--hex` mode. One of 1, 2, 4, 8, 16.
273    /// Default 2 (matches the historical `xxd` 2-byte / 4-char grouping).
274    hex_group_size: usize,
275    /// Custom status-line prompt template. When set, replaces the built-in
276    /// format_status output with the template rendered against PromptContext.
277    prompt: Option<crate::prompt::ParsedPrompt>,
278    /// Error message from a failed preprocessor run. When set, surfaces
279    /// a `[preprocess-failed: ...]` tag in the status line.
280    preprocess_failure: Option<String>,
281    /// When `count > 1`, status line shows `<label>  [current+1/count]`.
282    file_index: Option<(usize, usize)>,
283    /// When set, status line and prompt context include `[tag: <name> (N/M)]`.
284    tag_active: Option<(String, usize, usize)>,  // (name, cursor+1, total)
285    /// ANSI interpretation mode, resolved from --no-color / -r / env at startup.
286    ansi_mode: crate::render::AnsiMode,
287    /// Style applied to the status row at the writer level. Default
288    /// `reverse` for backwards-compat. Overridden by --status-style /
289    /// --prompt-style / per-format prompt_style.
290    status_style: crate::ansi::Style,
291    /// Transient status message shown for a few ticks (e.g. "(F reopened)"
292    /// after a file rotation). The `u32` is the remaining tick count; the
293    /// app loop decrements via `tick_flash` and the formatter renders the
294    /// message as long as it's non-empty.
295    status_flash: Option<(String, u32)>,
296    /// Ticks since the line index last grew. Used to render `(F idle)`
297    /// instead of `(F)` after a few seconds with no new bytes. Reset to
298    /// 0 in `note_growth`, incremented in `tick_idle`. 20 ticks ≈ 5s at
299    /// the 250 ms poll cadence.
300    ticks_since_growth: u32,
301    /// Case-sensitivity policy for search / filter / grep regex compile.
302    /// Resolved from -i / -I CLI flags at startup; mutated by the `:case`
303    /// colon command at runtime.
304    case_mode: CaseMode,
305    /// When false, search-match highlighting is suppressed in frame
306    /// composition (but search navigation still works). Toggled by
307    /// `-G` / `--no-hilite-search` and `:hlsearch` / `:nohlsearch`.
308    hilite_search: bool,
309    /// Auto-exit-on-EOF policy resolved from `-e` / `-E` at startup.
310    quit_at_eof: QuitAtEof,
311    /// Counter for `QuitAtEof::Second`: number of consecutive forward
312    /// motions that landed at EOF. Reset by any backward motion.
313    eof_hits: u8,
314    /// `-s` / `--squeeze-blank-lines`: collapse runs of blank lines to
315    /// a single blank line at display time. Real line numbers / counts
316    /// in `idx` are preserved.
317    squeeze_blanks: bool,
318    /// `--header=L,C`: pin the top `L` source lines at the top of the
319    /// viewport and the left `C` columns at the left. The cols dimension
320    /// is currently inert (no horizontal scroll yet); wired so future
321    /// horizontal-scroll support can opt into it without re-plumbing.
322    header_lines: usize,
323    header_cols: usize,
324    /// `-z` / `--window=N`: PageDown / PageUp step size in lines. `None`
325    /// (default) means "use body_rows" — full-screen page step. Half-page
326    /// commands always use body_rows/2 regardless.
327    page_size: Option<u16>,
328    /// Cached SGR/hyperlink state at the start of `render_state_for`.
329    /// Invalidated when top_line changes or source grows; reconstructed
330    /// by walking up to MAX_RECONSTRUCT_LINES lines back.
331    render_state: crate::render::RenderState,
332    /// Line number that `render_state` matches the start of. Sentinel
333    /// `usize::MAX` means "invalid, must reconstruct".
334    render_state_for: usize,
335    /// `--incsearch` / `:incsearch`: when on, each keystroke in the `/`/`?`
336    /// search prompt previews the first match (jump + highlight) from the
337    /// position the prompt opened at. Esc restores; Enter commits. Default off.
338    incsearch: bool,
339    /// `-J` / `--status-column`: when on, a 1-column gutter is drawn at the far
340    /// left (left of the line-number gutter) showing a mark letter, else `*`
341    /// on lines with a current-search match. Default off. No-op in hex/raw/image.
342    status_column: bool,
343    /// Per-frame input: current file's marks keyed by line → mark letter.
344    /// Set by the app loop before composing each frame when `status_column`
345    /// is on. Drives the mark glyph in the status column.
346    status_marks: std::collections::HashMap<usize, char>,
347}
348
349impl Viewport {
350    pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
351        let opts = RenderOpts { cols, ..RenderOpts::default() };
352        Self {
353            top_line: 0,
354            top_row: 0,
355            left_col: 0,
356            cols,
357            rows,
358            opts,
359            show_line_numbers: false,
360            source_label,
361            follow_mode: false,
362            live_mode: false,
363            prettify_label: None,
364            format_label: None,
365            filter: None,
366            grep: None,
367            or_groups: OrGroups::default(),
368            dim_mode: false,
369            visible_lines: Vec::new(),
370            visible_scanned: 0,
371            search: None,
372            display: None,
373            hex_mode: false,
374            #[cfg(feature = "image")]
375            image: None,
376            #[cfg(feature = "image")]
377            image_protocol: ImageProtocol::Ascii,
378            #[cfg(feature = "image")]
379            cell_px: (8, 16),
380            #[cfg(feature = "image")]
381            image_scaled: None,
382            image_mode: false,
383            image_no_color: false,
384            image_format: String::new(),
385            #[cfg(feature = "image")]
386            image_style: crate::image_render::AsciiStyle::Ramp,
387            image_width: None,
388            hex_group_size: 2,
389            prompt: None,
390            preprocess_failure: None,
391            file_index: None,
392            tag_active: None,
393            ansi_mode: crate::render::AnsiMode::Strict,
394            status_style: crate::ansi::Style { reverse: true, ..Default::default() },
395            status_flash: None,
396            ticks_since_growth: 0,
397            case_mode: CaseMode::default(),
398            hilite_search: true,
399            quit_at_eof: QuitAtEof::default(),
400            eof_hits: 0,
401            squeeze_blanks: false,
402            header_lines: 0,
403            header_cols: 0,
404            page_size: None,
405            render_state: crate::render::RenderState::default(),
406            render_state_for: usize::MAX,
407            incsearch: false,
408            status_column: false,
409            status_marks: std::collections::HashMap::new(),
410        }
411    }
412
413    pub fn status_column(&self) -> bool { self.status_column }
414
415    pub fn set_status_column(&mut self, on: bool) { self.status_column = on; }
416
417    /// Provide the current file's marks (line → mark letter) for the next
418    /// frame. Cheap no-op cost when status_column is off (the frame never
419    /// reads it then), but the app gates the call anyway.
420    pub fn set_status_marks(&mut self, marks: std::collections::HashMap<usize, char>) {
421        self.status_marks = marks;
422    }
423
424    /// Width of the far-left status column (1 if `-J` active, else 0). Forced
425    /// to 0 in raw passthrough since `-J` is a no-op there (hex/image use
426    /// separate frame paths and never reach the text composition).
427    fn status_col_width(&self) -> u16 {
428        if self.status_column && self.ansi_mode != crate::render::AnsiMode::Raw { 1 } else { 0 }
429    }
430
431    /// A single status-column cell carrying `glyph`. Styled plainly to match
432    /// the line-number gutter label cells.
433    fn status_cell(glyph: char) -> Cell {
434        Cell::Char { ch: glyph, width: 1, style: crate::ansi::Style::default(), hyperlink: None }
435    }
436
437    /// Glyph for the status column on source line `line_n`'s first display row:
438    /// a mark letter (precedence) if the line is marked, else `*` when the line
439    /// contains a current-search match, else a blank.
440    fn status_glyph(&self, line_n: usize, has_match: bool) -> char {
441        if let Some(&ch) = self.status_marks.get(&line_n) {
442            ch
443        } else if has_match {
444            '*'
445        } else {
446            ' '
447        }
448    }
449
450    pub fn case_mode(&self) -> CaseMode { self.case_mode }
451
452    pub fn hilite_search(&self) -> bool { self.hilite_search }
453
454    pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
455
456    pub fn incsearch(&self) -> bool { self.incsearch }
457
458    pub fn set_incsearch(&mut self, on: bool) { self.incsearch = on; }
459
460    pub fn top_row(&self) -> usize { self.top_row }
461
462    /// Set the scroll position directly (logical line + wrap-row within it).
463    pub fn set_top(&mut self, line: usize, row: usize) {
464        self.top_line = line;
465        self.top_row = row;
466    }
467
468    /// Preview an incremental-search pattern from `origin` (a (top_line, top_row)
469    /// captured when the prompt opened), scrolling to the first match. Empty or
470    /// invalid patterns are a silent no-op. Used by `--incsearch`.
471    pub fn incsearch_preview(&mut self, src: &dyn Source, idx: &mut LineIndex,
472                             pattern: &str, direction: SearchDirection,
473                             origin: (usize, usize)) {
474        if pattern.is_empty() { return; }
475        self.set_top(origin.0, origin.1);
476        if self.set_search(pattern.to_string(), direction).is_ok() {
477            self.search_repeat(src, idx, false);
478        }
479    }
480
481    pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
482        self.quit_at_eof = mode;
483        self.eof_hits = 0;
484    }
485
486    pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
487    pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
488
489    pub fn set_header(&mut self, lines: usize, cols: usize) {
490        self.header_lines = lines;
491        self.header_cols = cols;
492        // Don't let top_line land inside the pinned region — the scrolling
493        // window starts at line `header_lines` once the feature is on.
494        if self.top_line < self.header_lines {
495            self.top_line = self.header_lines;
496        }
497    }
498    pub fn header_lines(&self) -> usize { self.header_lines }
499    pub fn header_cols(&self) -> usize { self.header_cols }
500
501    pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
502    pub fn page_size(&self) -> Option<u16> { self.page_size }
503
504    /// Notify the EOF state machine of a motion. Returns `true` when the
505    /// caller should quit. `forward = true` for any motion that could
506    /// advance past EOF; `false` for backward motions (which reset the
507    /// hit counter under `QuitAtEof::Second`).
508    pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
509        match self.quit_at_eof {
510            QuitAtEof::Off => false,
511            QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
512            QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
513                self.eof_hits = self.eof_hits.saturating_add(1);
514                self.eof_hits >= 2
515            }
516            _ => {
517                if !forward { self.eof_hits = 0; }
518                false
519            }
520        }
521    }
522
523    /// Switch the case-mode policy. Re-compiles any active search so the
524    /// new policy takes effect on the next frame without the user having
525    /// to retype the pattern.
526    pub fn set_case_mode(&mut self, mode: CaseMode) {
527        self.case_mode = mode;
528        if let Some(s) = self.search.clone() {
529            let _ = self.set_search(s.raw, s.direction);
530        }
531    }
532
533    pub fn set_status_style(&mut self, style: crate::ansi::Style) {
534        self.status_style = style;
535    }
536
537    pub fn status_style(&self) -> crate::ansi::Style {
538        self.status_style
539    }
540
541    /// Show `msg` in the status row for the next `ticks` calls to the
542    /// timeout branch (~250 ms each). Overrides the normal status during
543    /// that window.
544    pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
545        self.status_flash = Some((msg.into(), ticks));
546    }
547
548    /// Decrement the flash countdown by one tick. Clears the flash when
549    /// it reaches zero.
550    pub fn tick_flash(&mut self) {
551        if let Some((_, n)) = &mut self.status_flash {
552            *n = n.saturating_sub(1);
553            if *n == 0 {
554                self.status_flash = None;
555            }
556        }
557    }
558
559    /// Reset the idle counter; the source just produced fresh bytes.
560    pub fn note_growth(&mut self) {
561        self.ticks_since_growth = 0;
562    }
563
564    /// Increment the idle counter. Called in the timeout branch when the
565    /// line index didn't grow.
566    pub fn tick_idle(&mut self) {
567        self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
568    }
569
570    /// True when the source has been quiet long enough to surface
571    /// `(F idle)` instead of `(F)`. Threshold: 20 ticks ≈ 5s.
572    pub fn is_idle(&self) -> bool {
573        self.ticks_since_growth >= 20
574    }
575
576    pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
577        self.display = renderer;
578    }
579
580    pub fn set_hex_mode(&mut self, on: bool) {
581        self.hex_mode = on;
582    }
583
584    /// Returns whether `--hex` rendering is active.
585    pub fn hex_mode(&self) -> bool {
586        self.hex_mode
587    }
588
589    #[cfg(feature = "image")]
590    pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
591        self.image = Some(img);
592        self.image_format = format.to_string();
593        self.image_style = style;
594        self.image_width = width;
595        self.image_mode = true;
596        self.top_line = 0;
597        self.top_row = 0;
598        self.image_scaled = None;
599    }
600
601    pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
602
603    #[cfg(feature = "image")]
604    pub fn set_image_protocol(&mut self, proto: ImageProtocol, cell_px: Option<(u16, u16)>) {
605        self.image_protocol = proto;
606        if let Some(c) = cell_px {
607            if c.0 > 0 && c.1 > 0 { self.cell_px = c; }
608        }
609        self.image_scaled = None;
610    }
611
612    #[cfg(feature = "image")]
613    pub fn image_protocol(&self) -> ImageProtocol { self.image_protocol }
614
615    pub fn image_mode(&self) -> bool { self.image_mode }
616
617    #[cfg(feature = "image")]
618    fn image_cols(&self) -> u16 {
619        self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
620    }
621
622    #[cfg(feature = "image")]
623    pub fn image_total_rows(&self) -> usize {
624        match &self.image {
625            Some(img) => {
626                let (w, h) = img.dimensions();
627                if self.image_protocol != ImageProtocol::Ascii {
628                    protocol_occupied_rows(w, h, self.cols, self.cell_px, self.image_width)
629                } else {
630                    crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
631                }
632            }
633            None => 0,
634        }
635    }
636
637    #[cfg(feature = "image")]
638    pub fn is_at_bottom_image(&self) -> bool {
639        let body = self.body_rows() as usize;
640        self.top_line + body >= self.image_total_rows()
641    }
642
643    /// Set bytes-per-group for `--hex` rendering. Accepts 1, 2, 4, 8, or 16.
644    /// Invalid values are ignored.
645    pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
646        if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
647            self.hex_group_size = bytes_per_group;
648        }
649    }
650
651    /// Current bytes-per-group for `--hex` rendering.
652    pub fn hex_group_size(&self) -> usize {
653        self.hex_group_size
654    }
655
656    pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
657        self.prompt = prompt;
658    }
659
660    pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
661        self.preprocess_failure = msg;
662    }
663
664    pub fn set_file_index(&mut self, current: usize, total: usize) {
665        self.file_index = if total > 1 {
666            Some((current, total))
667        } else {
668            None
669        };
670    }
671
672    pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
673        self.tag_active = info;
674    }
675
676    pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
677        self.ansi_mode = mode;
678    }
679
680    pub fn ansi_mode(&self) -> crate::render::AnsiMode {
681        self.ansi_mode
682    }
683
684    pub fn set_source_label(&mut self, label: String) {
685        self.source_label = label;
686    }
687
688    pub fn source_label_clone(&self) -> String {
689        self.source_label.clone()
690    }
691
692    /// Fetch a logical line's display bytes — rendered through the active
693    /// display template if one is set and the line parses against the format
694    /// regex, otherwise the raw bytes. Used everywhere the *visible* form of
695    /// the line matters: rendering, search, wrap-row counting.
696    fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
697        let range = idx.line_range(line_n, src);
698        let raw = src.bytes(range);
699        if let Some(r) = self.display.as_ref() {
700            if let Some(rendered) = r.render_line(&raw) {
701                return std::borrow::Cow::Owned(rendered.into_bytes());
702            }
703        }
704        raw
705    }
706
707    /// Compile and store a search pattern. Returns the parse error from the
708    /// regex crate if the pattern is invalid; the previous search (if any)
709    /// is preserved on error.
710    pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
711        let compiled = self.case_mode.apply_to_pattern(&raw);
712        let regex = Regex::new(&compiled).map_err(|e| e.to_string())?;
713        self.search = Some(SearchState { raw, regex, direction });
714        Ok(())
715    }
716
717    pub fn clear_search(&mut self) { self.search = None; }
718
719    pub fn search_active(&self) -> bool { self.search.is_some() }
720
721    pub fn search_direction(&self) -> SearchDirection {
722        self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
723    }
724
725    /// Jump to the next match of the active search, in `direction` (or its
726    /// reverse if `reverse` is true). Wraps at the end of the source.
727    /// Returns true iff a match was found and the viewport moved.
728    pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
729        if idx.records_mode() {
730            self.search_repeat_records(src, idx, reverse)
731        } else {
732            self.search_repeat_lines(src, idx, reverse)
733        }
734    }
735
736    /// Line-mode search: unchanged original logic.
737    fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
738        let Some(s) = self.search.as_ref() else { return false; };
739        let forward = matches!(
740            (s.direction, reverse),
741            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
742        );
743        idx.extend_to_end(src);
744        let pattern = s.regex.clone();
745        if self.hide_mode() {
746            self.extend_visible_lines(idx, src);
747            self.search_step_in_visible(&pattern, src, idx, forward)
748        } else {
749            self.search_step_in_logical(&pattern, src, idx, forward)
750        }
751    }
752
753    /// Records-mode search: iterate records, match against UTF-8-lossy decoded
754    /// record bytes (which may contain embedded `\n`s), and jump the viewport
755    /// to the first line of the matching record.
756    fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
757        let Some(s) = self.search.as_ref() else { return false; };
758        let forward = matches!(
759            (s.direction, reverse),
760            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
761        );
762        let pattern = s.regex.clone();
763        idx.extend_to_end(src);
764
765        let total = idx.record_count();
766        if total == 0 { return false; }
767
768        let cur_record = idx.line_to_record(self.top_line);
769
770        let range: Box<dyn Iterator<Item = usize>> = if forward {
771            Box::new(((cur_record + 1)..total).chain(0..=cur_record))
772        } else {
773            let earlier: Vec<usize> = (0..cur_record).rev().collect();
774            let later: Vec<usize> = (cur_record..total).rev().collect();
775            Box::new(earlier.into_iter().chain(later))
776        };
777
778        for r in range {
779            let bytes = idx.record_bytes_stripped(r, src);
780            let text = String::from_utf8_lossy(&bytes);
781            if pattern.is_match(&text) {
782                let line_range = idx.record_line_range(r);
783                self.top_line = line_range.start;
784                self.top_row = 0;
785                return true;
786            }
787        }
788        false
789    }
790
791    fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
792        // Search runs against the *displayed* bytes so what the user sees is
793        // what they can find. With a template active, that's the rendered form;
794        // otherwise the raw line. ANSI color sequences are stripped so that
795        // `/error` finds a red `error` regardless of escape codes.
796        let display = self.line_display_bytes(src, idx, line_n);
797        let bytes = crate::ansi::strip_sgr(&display);
798        match std::str::from_utf8(&bytes) {
799            Ok(s) => pattern.is_match(s),
800            Err(_) => false,
801        }
802    }
803
804    fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
805        let total = idx.line_count();
806        if total == 0 { return false; }
807        let start = self.top_line;
808        // Walk every logical line once, starting from start+1 (or start-1)
809        // and wrapping at the end / beginning.
810        for offset in 1..=total {
811            let line_n = if forward {
812                (start + offset) % total
813            } else {
814                (start + total - offset) % total
815            };
816            if self.line_matches(pattern, src, idx, line_n) {
817                self.top_line = line_n;
818                self.top_row = 0;
819                return true;
820            }
821        }
822        false
823    }
824
825    fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
826        let total = self.visible_lines.len();
827        if total == 0 { return false; }
828        // Find current visible position for top_line.
829        let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
830        for offset in 1..=total {
831            let visible_idx = if forward {
832                (cur + offset) % total
833            } else {
834                (cur + total - offset) % total
835            };
836            let line_n = self.visible_lines[visible_idx];
837            if self.line_matches(pattern, src, idx, line_n) {
838                self.top_line = line_n;
839                self.top_row = 0;
840                return true;
841            }
842        }
843        false
844    }
845
846    pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
847        self.filter = filter;
848        self.visible_lines.clear();
849        self.visible_scanned = 0;
850        // Drop scroll state — line numbering may have changed under us.
851        self.top_line = 0;
852        self.top_row = 0;
853        self.left_col = 0;
854    }
855
856    pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
857        self.grep = grep;
858        self.visible_lines.clear();
859        self.visible_scanned = 0;
860        self.top_line = 0;
861        self.top_row = 0;
862        self.left_col = 0;
863    }
864
865    pub fn set_or_groups(&mut self, or_groups: OrGroups) {
866        self.or_groups = or_groups;
867        self.visible_lines.clear();
868        self.visible_scanned = 0;
869        self.top_line = 0;
870        self.top_row = 0;
871        self.left_col = 0;
872    }
873
874    pub fn or_active(&self) -> bool {
875        self.or_groups.is_active()
876    }
877
878    pub fn grep_active(&self) -> bool { self.grep.is_some() }
879
880    pub fn set_dim_mode(&mut self, on: bool) {
881        self.dim_mode = on;
882        // Hide mode is the only mode that needs visible_lines; clear when
883        // turning dim ON, and re-derive from scratch when turning dim OFF
884        // (next extend_visible_lines call rebuilds it).
885        self.visible_lines.clear();
886        self.visible_scanned = 0;
887    }
888
889    pub fn filter_active(&self) -> bool { self.filter.is_some() }
890
891    pub fn dim_mode(&self) -> bool { self.dim_mode }
892
893    fn hide_mode(&self) -> bool {
894        (self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active())
895            && !self.dim_mode
896    }
897
898    /// Walk any newly indexed logical lines and append matching ones to
899    /// `visible_lines` if we're in hide mode. No-op otherwise. Cheap to call
900    /// every loop tick — keeps a `visible_scanned` cursor (line mode only;
901    /// records mode rebuilds from scratch each call).
902    pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
903        if !self.hide_mode() {
904            return;
905        }
906        if idx.records_mode() {
907            self.extend_visible_lines_records(idx, src);
908        } else {
909            self.extend_visible_lines_per_line(idx, src);
910        }
911    }
912
913    /// Line-mode: incrementally append newly indexed matching lines.
914    fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
915        let total = idx.line_count();
916        while self.visible_scanned < total {
917            let line_n = self.visible_scanned;
918            let bytes = idx.line_bytes_stripped(line_n, src);
919            if self.line_passes(&bytes) {
920                self.visible_lines.push(line_n);
921            }
922            self.visible_scanned += 1;
923        }
924    }
925
926    /// Records-mode: evaluate predicates once per record on the full record
927    /// bytes (which include embedded `\n`s). All physical lines of a matching
928    /// record are pushed to `visible_lines`; non-matching records are dropped
929    /// entirely (hide mode). Rebuilds from scratch on each call — O(records)
930    /// per frame but acceptable for current workloads; avoids the complexity
931    /// of tracking a records-scanned cursor alongside `visible_scanned`.
932    fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
933        self.visible_lines.clear();
934        self.visible_scanned = 0; // not used by records path; reset for clarity
935        let total_records = idx.record_count();
936        for r in 0..total_records {
937            if self.record_passes(idx, src, r) {
938                for line_n in idx.record_line_range(r) {
939                    self.visible_lines.push(line_n);
940                }
941            }
942        }
943    }
944
945    /// Combined predicate: bytes pass iff the (optional) filter matches AND
946    /// the (optional) grep matches. Missing predicates vacuously pass.
947    /// `bytes` is always a single logical line — records-mode callers go
948    /// through `record_passes` instead because the two predicates have
949    /// different granularity (filter = header line, grep = whole record).
950    fn line_passes(&self, line: &[u8]) -> bool {
951        let filter_ok = match self.filter.as_ref() {
952            Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
953            None => true,
954        };
955        let grep_ok = match self.grep.as_ref() {
956            Some(g) => g.matches(line),
957            None => true,
958        };
959        filter_ok && grep_ok && self.or_groups.matches_line(line)
960    }
961
962    /// Records-mode predicate. Both filter and grep are evaluated against
963    /// the full multi-line record bytes. Filter uses the format regex with
964    /// dotall + multi-line semantics so greedy captures like
965    /// `(?P<message>.*)$` span the whole record body — `--filter
966    /// message~foo` matches when `foo` appears anywhere in the record, not
967    /// only on the header. Grep matches anywhere in the record bytes too,
968    /// so `(?s)foo.*bar` keeps working across continuation lines.
969    fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
970        let need = self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active();
971        let bytes = if need {
972            Some(idx.record_bytes_stripped(r, src))
973        } else {
974            None
975        };
976        let filter_ok = match self.filter.as_ref() {
977            Some(f) => matches!(
978                f.evaluate_record(bytes.as_deref().unwrap()),
979                FilterMatch::Matched,
980            ),
981            None => true,
982        };
983        let grep_ok = match self.grep.as_ref() {
984            Some(g) => g.matches(bytes.as_deref().unwrap()),
985            None => true,
986        };
987        let or_ok = if self.or_groups.is_active() {
988            self.or_groups.matches_record(bytes.as_deref().unwrap())
989        } else {
990            true
991        };
992        filter_ok && grep_ok && or_ok
993    }
994
995    /// Return true iff line `line_n` should be rendered dim. In records mode,
996    /// the match decision is made once per record and applied to all its
997    /// physical lines. In line mode, the decision is made per line.
998    fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
999        if !self.dim_mode {
1000            return false;
1001        }
1002        if idx.records_mode() {
1003            let r = idx.line_to_record(line_n);
1004            !self.record_passes(idx, src, r)
1005        } else {
1006            let bytes = idx.line_bytes_stripped(line_n, src);
1007            !self.line_passes(&bytes)
1008        }
1009    }
1010
1011    /// Logical line index of the *last* row drawn in the body, given the
1012    /// current `top_line` and `body_rows`. In line mode this is just
1013    /// `top_line + body_rows - 1` clamped to the indexed line count. In hide
1014    /// mode it's the logical line that sits at the bottom of the visible
1015    /// slice — i.e. `visible_lines[cur + body_rows - 1]`. Always returns a
1016    /// value `>= self.top_line`, so callers passing it to `line_to_record`
1017    /// never get a "bottom record < top record" inversion.
1018    fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
1019        let body_rows = self.body_rows() as usize;
1020        if self.hide_mode() && !self.visible_lines.is_empty() {
1021            let cur = self
1022                .visible_lines
1023                .iter()
1024                .position(|&l| l >= self.top_line)
1025                .unwrap_or(self.visible_lines.len().saturating_sub(1));
1026            let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
1027            return self.visible_lines[last_pos];
1028        }
1029        let total = idx.line_count();
1030        if total == 0 {
1031            return self.top_line;
1032        }
1033        (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
1034    }
1035
1036    pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
1037
1038    pub fn follow_mode(&self) -> bool { self.follow_mode }
1039
1040    /// Conditionally turn follow mode off. Used by motion handlers when
1041    /// `--follow-suspend-on-motion` is in effect — any motion (scroll,
1042    /// page, goto-line) suspends following until the user re-engages
1043    /// with Shift-F.
1044    pub fn suspend_follow_if(&mut self, flag: bool) {
1045        if flag {
1046            self.follow_mode = false;
1047        }
1048    }
1049
1050    pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
1051
1052    pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
1053
1054    pub fn live_mode(&self) -> bool { self.live_mode }
1055
1056    pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
1057
1058    /// Status-line label for active pretty-print state, e.g. `"json"` or
1059    /// `"json:err"`. `None` means no indicator is shown.
1060    pub fn set_prettify_label(&mut self, label: Option<String>) {
1061        self.prettify_label = label;
1062    }
1063
1064    /// Active --format name shown in <format-tag>. Set from main when a named
1065    /// format is resolved; independent of whether --filter is also active.
1066    pub fn set_format_label(&mut self, label: Option<String>) {
1067        self.format_label = label;
1068    }
1069
1070    /// Drop the per-line filter-membership cache without disturbing the filter
1071    /// itself or scroll position. Used after a `--live` rebuild: line numbering
1072    /// may have changed, so cached `visible_lines` is stale, but we want to
1073    /// keep the same filter applied and let the user stay where they were.
1074    pub fn invalidate_filter_cache(&mut self) {
1075        self.visible_lines.clear();
1076        self.visible_scanned = 0;
1077    }
1078
1079    /// Clamp `top_line` so it doesn't fall past the new end of the source.
1080    /// Pairs with `invalidate_filter_cache` after a content rewrite.
1081    pub fn clamp_top_line(&mut self, line_count: usize) {
1082        if line_count == 0 {
1083            self.top_line = 0;
1084            self.top_row = 0;
1085        } else if self.top_line >= line_count {
1086            self.top_line = line_count - 1;
1087            self.top_row = 0;
1088        }
1089    }
1090
1091    /// True when the viewport's body window already covers the last line of
1092    /// the source. New content added past this point should auto-scroll if
1093    /// follow mode is on.
1094    pub fn is_at_bottom(&self, src: &dyn Source, idx: &LineIndex) -> bool {
1095        #[cfg(feature = "image")]
1096        if self.image_mode {
1097            return self.is_at_bottom_image();
1098        }
1099        if self.hide_mode() {
1100            // Wrap-aware: at the bottom once (top_line, top_row) is at/after
1101            // the visible-line anchor that puts the last match's tail on the
1102            // last body row.
1103            (self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
1104        } else {
1105            // Compare in display-row units against the wrap-aware bottom
1106            // anchor — `top_line + body >= line_count` would read true while
1107            // a wrapped tail is still off-screen below.
1108            (self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
1109        }
1110    }
1111
1112    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
1113    fn gutter_width(&self, idx: &LineIndex) -> u16 {
1114        if !self.show_line_numbers { return 0; }
1115        let n = idx.line_count().max(1);
1116        let digits = (n as f64).log10().floor() as u16 + 1;
1117        digits + 1
1118    }
1119
1120    fn render_opts(&self, gutter: u16) -> RenderOpts {
1121        let mut o = self.opts.clone();
1122        // The status column (`-J`) reserves a fixed far-left cell outside the
1123        // scrolled content, so its width comes off the content budget too.
1124        o.cols = self.cols.saturating_sub(self.status_col_width() + gutter);
1125        o.mode = self.ansi_mode;
1126        o.left_col = self.left_col;   // horizontal scroll offset carried into the kernel
1127        o
1128    }
1129
1130    pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
1131        #[cfg(feature = "image")]
1132        if self.image_mode {
1133            return self.frame_image();
1134        }
1135        if self.hex_mode {
1136            return self.frame_hex(src);
1137        }
1138        let body_rows = self.body_rows() as usize;
1139        idx.extend_to_line(self.top_line + body_rows + 1, src);
1140
1141        // Clamp horizontal scroll to the widest line currently visible, so we
1142        // never scroll into empty space. (Chop/text path only.)
1143        if self.left_col > 0 && self.hscroll_active() {
1144            let gutter_for_clamp = self.status_col_width() + self.gutter_width(idx);
1145            let avail = self.cols.saturating_sub(gutter_for_clamp) as usize;
1146            // Build opts with left_col=0 — display_width measures full line width
1147            // regardless of the current scroll offset.
1148            let mut width_opts = self.opts.clone();
1149            width_opts.cols = self.cols.saturating_sub(gutter_for_clamp);
1150            width_opts.mode = self.ansi_mode;
1151            width_opts.left_col = 0;
1152            let mut widest = 0usize;
1153            let total_lines_for_clamp = idx.line_count();
1154            if self.hide_mode() {
1155                let hide_pos = self.visible_lines.iter()
1156                    .position(|&l| l >= self.top_line)
1157                    .unwrap_or(self.visible_lines.len());
1158                let end_vi = (hide_pos + body_rows).min(self.visible_lines.len());
1159                for vi in hide_pos..end_vi {
1160                    let ln = self.visible_lines[vi];
1161                    let bytes = self.line_display_bytes(src, idx, ln);
1162                    widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1163                }
1164            } else {
1165                let start = self.top_line.max(self.header_lines);
1166                let end = (start + body_rows).min(total_lines_for_clamp);
1167                for ln in start..end {
1168                    let bytes = self.line_display_bytes(src, idx, ln);
1169                    widest = widest.max(crate::render::display_width(&bytes, &width_opts));
1170                }
1171            }
1172            self.left_col = self.left_col.min(widest.saturating_sub(avail));
1173        }
1174
1175        let gutter = self.gutter_width(idx);
1176        let scol = self.status_col_width();
1177        let r_opts = self.render_opts(gutter);
1178
1179        // Reconstruct per-line SGR state for the start of the visible window so
1180        // that unclosed SGR sequences on lines above top_line carry through.
1181        // Only meaningful in Interpret mode; harmless (and cheap) to skip otherwise.
1182        let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1183            reconstruct_render_state(src, idx, self.top_line)
1184        } else {
1185            crate::render::RenderState::default()
1186        };
1187        // Store in the struct field for future cache use; mark current top_line.
1188        self.render_state = render_state.clone();
1189        self.render_state_for = self.top_line;
1190
1191        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1192        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1193        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1194        let mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
1195        let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
1196        // In hide mode we walk visible_lines; otherwise we walk logical lines.
1197        let hide = self.hide_mode();
1198        let total_lines = idx.line_count();
1199
1200        // `--header=L`: pin the first L source lines as the top L rows of
1201        // the body. Renders only the first cell-row of each pinned line
1202        // (matches less semantics; long pinned lines truncate). Skipped in
1203        // hide mode where "first L lines" might not be visible. Skipped in
1204        // raw passthrough since the user's intent there is byte-faithful
1205        // emission, not pinned headers.
1206        let header_rows = if !hide && !raw_passthrough {
1207            self.header_lines.min(body_rows).min(total_lines)
1208        } else {
1209            0
1210        };
1211        if header_rows > 0 {
1212            for hl in 0..header_rows {
1213                let raw = src.bytes(idx.line_range(hl, src));
1214                let display_bytes = if let Some(r) = self.display.as_ref() {
1215                    match r.render_line(&raw) {
1216                        Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1217                        None => raw.clone(),
1218                    }
1219                } else {
1220                    raw.clone()
1221                };
1222                let rows = render_line(&display_bytes, &r_opts, None);
1223                let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
1224                    let mut v = Vec::with_capacity(self.cols as usize);
1225                    while v.len() < self.cols as usize { v.push(Cell::Empty); }
1226                    v
1227                });
1228                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1229                if scol > 0 {
1230                    let matched = self.search.as_ref()
1231                        .is_some_and(|s| !find_row_highlights(&content_row, &s.regex).is_empty());
1232                    let glyph = self.status_glyph(hl, matched);
1233                    full.push(Self::status_cell(glyph));
1234                }
1235                if gutter > 0 {
1236                    let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
1237                    for c in label.chars() {
1238                        full.push(Cell::Char {
1239                            ch: c,
1240                            width: 1,
1241                            style: crate::ansi::Style::default(),
1242                            hyperlink: None,
1243                        });
1244                    }
1245                }
1246                full.append(&mut content_row);
1247                body.push(full);
1248                row_styles.push(RowStyle::Normal);
1249                highlights.push(Vec::new());
1250                raw_rows.push(None);
1251            }
1252        }
1253
1254        // For hide mode, find where the viewport starts in visible_lines.
1255        let mut hide_pos = if hide {
1256            self.visible_lines
1257                .iter()
1258                .position(|&l| l >= self.top_line)
1259                .unwrap_or(self.visible_lines.len())
1260        } else {
1261            0
1262        };
1263        let mut line_n = if hide {
1264            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
1265        } else {
1266            // When header pinning is on, skip past the pinned region so the
1267            // scrolling window doesn't show those lines a second time.
1268            self.top_line.max(self.header_lines)
1269        };
1270        let mut skip = if header_rows > 0 { 0 } else { self.top_row };
1271
1272        while body.len() < body_rows {
1273            if line_n >= total_lines {
1274                let mut row = Vec::with_capacity(self.cols as usize);
1275                if scol > 0 {
1276                    for _ in 0..scol { row.push(Cell::Empty); }
1277                }
1278                if gutter > 0 {
1279                    for _ in 0..gutter { row.push(Cell::Empty); }
1280                }
1281                while row.len() < self.cols as usize { row.push(Cell::Empty); }
1282                body.push(row);
1283                row_styles.push(RowStyle::Normal);
1284                highlights.push(Vec::new());
1285                raw_rows.push(None);
1286                line_n += 1;
1287                continue;
1288            }
1289            // Filter evaluation runs on the raw line (it uses captures, not
1290            // text), but rendering goes through the template if one is set.
1291            let raw = src.bytes(idx.line_range(line_n, src));
1292            // `-s` / --squeeze-blank-lines: skip a blank line if its
1293            // immediate predecessor (in logical-line space) was also blank.
1294            // Real line numbers / counts in `idx` stay accurate — this is a
1295            // display-layer filter only.
1296            if self.squeeze_blanks && line_is_blank(&raw) {
1297                let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
1298                    let prev = src.bytes(idx.line_range(p, src));
1299                    line_is_blank(&prev)
1300                });
1301                if prev_blank {
1302                    line_n += 1;
1303                    continue;
1304                }
1305            }
1306            let display_bytes = if let Some(r) = self.display.as_ref() {
1307                match r.render_line(&raw) {
1308                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
1309                    None => raw.clone(),
1310                }
1311            } else {
1312                raw.clone()
1313            };
1314            let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
1315                Some(&mut render_state)
1316            } else {
1317                None
1318            };
1319            let rows = render_line(&display_bytes, &r_opts, state_arg);
1320            let style = if self.filter.is_some() || self.grep.is_some() {
1321                if self.dim_mode {
1322                    if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
1323                } else {
1324                    // hide mode: only matching lines reach here
1325                    RowStyle::Normal
1326                }
1327            } else {
1328                RowStyle::Normal
1329            };
1330
1331            let mut first_emitted_for_this_line = true;
1332            // `-J` status column: remember the body index of this line's first
1333            // emitted display row, and whether ANY of its rows hold a search
1334            // match. We patch the glyph in after rendering the whole line so a
1335            // match on a wrapped continuation row still flags the first row.
1336            let mut status_first_row_idx: Option<usize> = None;
1337            let mut line_matched = false;
1338            for (i, mut content_row) in rows.into_iter().enumerate() {
1339                if i < skip { continue; }
1340                if body.len() >= body_rows { break; }
1341                // Track whether this line carries a search match for `-J`,
1342                // matching against the CONTENT only (before the status cell +
1343                // gutter are prepended) so gutter digits / padding can't
1344                // falsely flag a `*`. Independent of `hilite_search` (`-G`):
1345                // the status column reflects matches even when visual
1346                // highlighting is suppressed.
1347                if scol > 0 && !line_matched {
1348                    if let Some(s) = self.search.as_ref() {
1349                        if !find_row_highlights(&content_row, &s.regex).is_empty() {
1350                            line_matched = true;
1351                        }
1352                    }
1353                }
1354                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
1355                if scol > 0 {
1356                    if status_first_row_idx.is_none() {
1357                        status_first_row_idx = Some(body.len());
1358                    }
1359                    // Placeholder blank; patched to the real glyph after the
1360                    // line's rows are all rendered (so wrapped-row matches count).
1361                    full.push(Self::status_cell(' '));
1362                }
1363                if gutter > 0 {
1364                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
1365                    for c in label.chars() {
1366                        full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1367                    }
1368                }
1369                full.append(&mut content_row);
1370                // Paint the `<` left-edge marker when the view is scrolled right
1371                // in chop mode, so the user can see that content is off-screen left.
1372                // Mirrors the `>` rscroll marker style (dim). Placed at the first
1373                // content column (after the status column + gutter, if any).
1374                if self.left_col > 0 && !self.opts.wrap {
1375                    let marker_col = (scol + gutter) as usize;
1376                    if let Some(cell) = full.get_mut(marker_col) {
1377                        *cell = Cell::Char {
1378                            ch: '<',
1379                            width: 1,
1380                            style: crate::ansi::Style { dim: true, ..Default::default() },
1381                            hyperlink: None,
1382                        };
1383                    }
1384                }
1385                // Compute search highlights for this display row by running
1386                // the regex against the row's rendered text. Each match's
1387                // char range maps to a cell column range via `starts`.
1388                let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
1389                    find_row_highlights(&full, &s.regex)
1390                } else {
1391                    Vec::new()
1392                };
1393                body.push(full);
1394                row_styles.push(style);
1395                highlights.push(row_highlights);
1396                if raw_passthrough {
1397                    if first_emitted_for_this_line {
1398                        // Emit the original line bytes verbatim once. Sub-rows
1399                        // (mid-line wrap continuations) are no-ops — the
1400                        // terminal will have already consumed enough columns
1401                        // from the line's full byte stream to fill them.
1402                        raw_rows.push(Some(raw.to_vec()));
1403                        first_emitted_for_this_line = false;
1404                    } else {
1405                        raw_rows.push(Some(Vec::new()));
1406                    }
1407                } else {
1408                    raw_rows.push(None);
1409                }
1410            }
1411            // `-J`: now that the whole line is rendered, set the first row's
1412            // status glyph (mark letter beats search-`*`). Continuation rows
1413            // keep their blank placeholder.
1414            if let Some(fi) = status_first_row_idx {
1415                let glyph = self.status_glyph(line_n, line_matched);
1416                if glyph != ' ' {
1417                    if let Some(cell) = body[fi].first_mut() {
1418                        *cell = Self::status_cell(glyph);
1419                    }
1420                }
1421            }
1422            skip = 0;
1423            // Advance to next line — visible-space if hiding, logical-space otherwise.
1424            if hide {
1425                hide_pos += 1;
1426                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
1427            } else {
1428                line_n += 1;
1429            }
1430        }
1431
1432        // After walking through the frame, render_state has been advanced past
1433        // top_line. Invalidate the cached sentinel so next frame re-reconstructs.
1434        self.render_state_for = usize::MAX;
1435
1436        let status = self.format_status(idx, src);
1437        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1438    }
1439
1440    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
1441        if let Some(p) = self.prompt.as_ref() {
1442            let ctx = self.build_prompt_context(idx, src);
1443            return p.render(&ctx);
1444        }
1445        let body_rows = self.body_rows() as usize;
1446        let total = idx.line_count();
1447        // In hide mode, the line range and percentage refer to visible (matched)
1448        // lines, not the underlying logical line count.
1449        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
1450            let visible_total = self.visible_lines.len();
1451            // top_line is a logical line; find its visible index.
1452            let cur = self
1453                .visible_lines
1454                .iter()
1455                .position(|&l| l >= self.top_line)
1456                .unwrap_or(visible_total);
1457            let top = cur + 1;
1458            let bottom = (cur + body_rows).min(visible_total.max(1));
1459            let total_str = if src.is_complete() {
1460                format!("{visible_total}/{total}")
1461            } else {
1462                format!("{visible_total}/{total}+")
1463            };
1464            (top, bottom, visible_total, total_str)
1465        } else {
1466            let top = self.top_line + 1;
1467            let bottom = (self.top_line + body_rows).min(total.max(1));
1468            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
1469            (top, bottom, total, total_str)
1470        };
1471        let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
1472        // In records mode, prefix line numbers with 'L' and append an 'R' record block.
1473        // The R block always refers to logical lines on screen, which in hide
1474        // mode is *not* the same as `bottom` (which counts visible matches).
1475        let bottom_line = self.bottom_visible_line(idx);
1476        let (line_prefix, records_block) = if idx.records_mode() {
1477            let line_total = idx.line_count();
1478            let rec_total = idx.record_count();
1479            let rec_block = if line_total == 0 || rec_total == 0 {
1480                format!("R0-0/{}", rec_total)
1481            } else {
1482                let rec_top = idx.line_to_record(self.top_line) + 1;
1483                let rec_bottom = idx.line_to_record(bottom_line) + 1;
1484                let (rec_top, rec_bottom) = if rec_bottom < rec_top {
1485                    // Defensive: should be unreachable given `bottom_visible_line`
1486                    // is always `>= self.top_line`, but guard against future
1487                    // regressions producing nonsense like `R290-8/...`.
1488                    (rec_top, rec_top)
1489                } else {
1490                    (rec_top, rec_bottom)
1491                };
1492                format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
1493            };
1494            ("L", Some(rec_block))
1495        } else {
1496            ("", None)
1497        };
1498        let middle = match records_block {
1499            Some(ref rb) => format!("{}{}-{}/{}  {}  {}%", line_prefix, top, bottom, total_str, rb, pct),
1500            None         => format!("{}-{}/{}  {}%", top, bottom, total_str, pct),
1501        };
1502        let label_with_index = match self.file_index {
1503            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1504            None => self.source_label.clone(),
1505        };
1506        let mut s = format!("{}  {}", label_with_index, middle);
1507        // Wrap-row offset: when scrolled inside a long wrapping line, surface
1508        // the offset so the user knows scrolling is happening at sub-line
1509        // granularity. Without this the line range above stays static while
1510        // pressing `j` and the scroll is invisible on repeating content.
1511        if !self.hide_mode() && self.top_row > 0 {
1512            let line_rows = if total > 0 {
1513                let bytes = self.line_display_bytes(src, idx, self.top_line);
1514                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1515            } else { 1 };
1516            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
1517        }
1518        if self.left_col > 0 {
1519            s.push_str(&format!("  \u{00bb}{}", self.left_col));
1520        }
1521        if let Some(f) = self.filter.as_ref() {
1522            s.push_str(&format!("  [{}]", f.format_name));
1523        }
1524        if self.grep.is_some() {
1525            s.push_str("  [grep]");
1526        }
1527        if self.or_groups.is_active() {
1528            s.push_str("  [or]");
1529        }
1530        if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1531            s.push_str(if self.dim_mode { "  [dim]" } else { "  [hide]" });
1532        }
1533        if let Some(sr) = self.search.as_ref() {
1534            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
1535            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
1536        }
1537        if let Some(label) = self.prettify_label.as_ref() {
1538            s.push_str(&format!("  [pretty:{label}]"));
1539        }
1540        if self.live_mode { s.push_str("  (L)"); }
1541        if self.follow_mode {
1542            if let Some((msg, _)) = self.status_flash.as_ref() {
1543                s.push_str("  ");
1544                s.push_str(msg);
1545            } else if self.is_idle() {
1546                s.push_str("  (F idle)");
1547            } else {
1548                s.push_str("  (F)");
1549            }
1550        }
1551        if let Some(msg) = self.preprocess_failure.as_ref() {
1552            let first_line = msg.lines().next().unwrap_or("");
1553            s.push_str(&format!("  [preprocess-failed: {}]", first_line));
1554        }
1555        let tag_suffix = match &self.tag_active {
1556            Some((name, cur, total)) if *total > 1 => {
1557                format!("  [tag: {name} ({cur}/{total})]")
1558            }
1559            _ => String::new(),
1560        };
1561        s.push_str(&tag_suffix);
1562        // Right-aligned :help hint. If the existing status already overshoots
1563        // the width, no pad — the renderer will clip on draw.
1564        let used = s.chars().count();
1565        let hint = ":help";
1566        if (self.cols as usize) > used + 1 + hint.chars().count() {
1567            let pad = self.cols as usize - used - hint.chars().count();
1568            s.push_str(&" ".repeat(pad));
1569            s.push_str(hint);
1570        } else {
1571            s.push(' ');
1572            s.push_str(hint);
1573        }
1574        s
1575    }
1576
1577    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
1578        use crate::prompt::PromptContext;
1579
1580        let body_rows = self.body_rows() as usize;
1581        let total = idx.line_count();
1582        let top = self.top_line + 1;
1583        let bottom = (self.top_line + body_rows).min(total.max(1));
1584        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
1585        let bottom_line = self.bottom_visible_line(idx);
1586
1587        let records_mode = idx.records_mode();
1588        let (rec_top, rec_bottom, rec_total) = if records_mode {
1589            let rt = idx.line_to_record(self.top_line) + 1;
1590            let rb_raw = idx.line_to_record(bottom_line) + 1;
1591            let rb = if rb_raw < rt { rt } else { rb_raw };
1592            (rt, rb, idx.record_count())
1593        } else {
1594            (0, 0, 0)
1595        };
1596
1597        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
1598            let line_rows = if total > 0 {
1599                let bytes = self.line_display_bytes(src, idx, self.top_line);
1600                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
1601            } else { 1 };
1602            format!("+{}/{}", self.top_row, line_rows)
1603        } else {
1604            String::new()
1605        };
1606
1607        let col_offset = if self.left_col > 0 { format!("  \u{00bb}{}", self.left_col) } else { String::new() };
1608
1609        let format_tag = self.format_label.as_ref()
1610            .map(|n| format!("  [{}]", n))
1611            .unwrap_or_default();
1612        let filter_tag = self.filter.as_ref()
1613            .map(|f| format!("  [{}]", f.format_name))
1614            .unwrap_or_default();
1615        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
1616        let or_tag = if self.or_groups.is_active() { "  [or]".to_string() } else { String::new() };
1617        let hide_tag = if self.filter.is_some() || self.grep.is_some() || self.or_groups.is_active() {
1618            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
1619        } else {
1620            String::new()
1621        };
1622        let search_tag = self.search.as_ref()
1623            .map(|s| {
1624                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
1625                format!("  [{}{}]", p, s.raw)
1626            })
1627            .unwrap_or_default();
1628        let pretty_tag = self.prettify_label.as_ref()
1629            .map(|l| format!("  [pretty:{l}]"))
1630            .unwrap_or_default();
1631        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
1632        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
1633        let preprocess_failed_tag = self.preprocess_failure.as_ref()
1634            .map(|msg| {
1635                let first_line = msg.lines().next().unwrap_or("");
1636                format!("  [preprocess-failed: {}]", first_line)
1637            })
1638            .unwrap_or_default();
1639
1640        let file_index_tag = match self.file_index {
1641            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
1642            None => String::new(),
1643        };
1644
1645        let tag_tag = match &self.tag_active {
1646            Some((name, cur, total)) if *total > 1 => {
1647                format!("  [tag: {name} ({cur}/{total})]")
1648            }
1649            _ => String::new(),
1650        };
1651
1652        PromptContext {
1653            label: self.source_label.clone(),
1654            top,
1655            bottom,
1656            total,
1657            pct: pct.min(100) as u8,
1658            rec_top,
1659            rec_bottom,
1660            rec_total,
1661            records_mode,
1662            wrap_offset,
1663            col_offset,
1664            format_tag,
1665            filter_tag,
1666            grep_tag,
1667            or_tag,
1668            hide_tag,
1669            search_tag,
1670            pretty_tag,
1671            live_tag,
1672            follow_tag,
1673            preprocess_failed_tag,
1674            file_index_tag,
1675            tag_tag,
1676        }
1677    }
1678
1679    fn frame_hex(&self, src: &dyn Source) -> Frame {
1680        use crate::hex::format_hex_row;
1681        use crate::render::{render_line, Cell, RenderOpts};
1682
1683        let body_rows = self.rows.saturating_sub(1) as usize;
1684        let total_bytes = src.len();
1685        let total_hex_rows = total_bytes.div_ceil(16);
1686
1687        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1688        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1689        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1690
1691        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false, left_col: 0, tab_stops: None };
1692
1693        for row_idx in 0..body_rows {
1694            let hex_row = self.top_line + row_idx;
1695            if hex_row >= total_hex_rows {
1696                body.push(vec![Cell::Empty; self.cols as usize]);
1697            } else {
1698                let offset = hex_row * 16;
1699                let end = (offset + 16).min(total_bytes);
1700                let bytes_cow = src.bytes(offset..end);
1701                let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1702                let rows = render_line(text.as_bytes(), &opts, None);
1703                body.push(rows.into_iter().next().unwrap_or_else(|| {
1704                    vec![Cell::Empty; self.cols as usize]
1705                }));
1706            }
1707            row_styles.push(RowStyle::Normal);
1708            highlights.push(Vec::new());
1709        }
1710
1711        let status = self.format_status_hex(src);
1712        let raw_rows = vec![None; body.len()];
1713        Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows, image_blob: None }
1714    }
1715
1716    fn format_status_hex(&self, src: &dyn Source) -> String {
1717        let total_bytes = src.len();
1718        let body_rows = self.rows.saturating_sub(1) as usize;
1719        // Byte offset of the first visible byte (start of the top hex row).
1720        let top_byte = self.top_line * 16;
1721        // Byte offset just past the last visible byte. Clamped to total_bytes
1722        // so we never show a value past EOF.
1723        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1724        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1725        let label_with_index = match self.file_index {
1726            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1727            None => self.source_label.clone(),
1728        };
1729        let tag_suffix = match &self.tag_active {
1730            Some((name, cur, total)) if *total > 1 => {
1731                format!("  [tag: {name} ({cur}/{total})]")
1732            }
1733            _ => String::new(),
1734        };
1735        format!(
1736            "{}  off {}-{}/{}  {}%  [hex]{}",
1737            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1738        )
1739    }
1740
1741    #[cfg(feature = "image")]
1742    fn frame_image(&mut self) -> Frame {
1743        use crate::render::Cell;
1744        if self.image_protocol != ImageProtocol::Ascii {
1745            return self.frame_image_protocol();
1746        }
1747        let body_rows = self.body_rows() as usize;
1748        let cols = self.cols as usize;
1749        let img = match &self.image {
1750            Some(i) => i,
1751            None => {
1752                let body = vec![vec![Cell::Empty; cols]; body_rows];
1753                return Frame {
1754                    body,
1755                    row_styles: vec![RowStyle::Normal; body_rows],
1756                    highlights: vec![Vec::new(); body_rows],
1757                    status: self.image_format.clone(),
1758                    status_style: self.status_style,
1759                    raw_rows: vec![None; body_rows],
1760                    image_blob: None,
1761                };
1762            }
1763        };
1764        let color = !self.image_no_color;
1765        let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
1766        let grid_w = grid.first().map(|r| r.len()).unwrap_or(0);
1767        let max_off = grid_w.saturating_sub(cols);
1768        if self.left_col > max_off { self.left_col = max_off; }
1769        let off = self.left_col;
1770        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1771        for r in 0..body_rows {
1772            let gi = self.top_line + r;
1773            if gi < grid.len() {
1774                let mut row: Vec<Cell> = grid[gi].iter().skip(off).take(cols).cloned().collect();
1775                while row.len() < cols { row.push(Cell::Empty); }
1776                body.push(row);
1777            } else {
1778                body.push(vec![Cell::Empty; cols]);
1779            }
1780        }
1781        let status = self.format_status_image(grid.len());
1782        Frame {
1783            body,
1784            row_styles: vec![RowStyle::Normal; body_rows],
1785            highlights: vec![Vec::new(); body_rows],
1786            status,
1787            status_style: self.status_style,
1788            raw_rows: vec![None; body_rows],
1789            image_blob: None,
1790        }
1791    }
1792
1793    #[cfg(feature = "image")]
1794    fn format_status_image(&self, total_rows: usize) -> String {
1795        let body = self.body_rows() as usize;
1796        let top = self.top_line + 1;
1797        let bottom = (self.top_line + body).min(total_rows.max(1));
1798        let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1799        let mut s = format!("{}  {}  {}  rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows);
1800        if self.left_col > 0 {
1801            s.push_str(&format!("  \u{00bb}{}", self.left_col));
1802        }
1803        s
1804    }
1805
1806    #[cfg(feature = "image")]
1807    fn frame_image_protocol(&mut self) -> Frame {
1808        use crate::render::Cell;
1809        let body_rows = self.body_rows() as usize;
1810        let cols = self.cols as usize;
1811        let status_style = self.status_style;
1812        let blank = |status: String, blob: Option<Vec<u8>>| Frame {
1813            body: vec![vec![Cell::Empty; cols]; body_rows],
1814            row_styles: vec![RowStyle::Normal; body_rows],
1815            highlights: vec![Vec::new(); body_rows],
1816            status,
1817            status_style,
1818            raw_rows: vec![None; body_rows],
1819            image_blob: blob,
1820        };
1821        let (iw, ih) = match &self.image {
1822            Some(i) => i.dimensions(),
1823            None => return blank(self.image_format.clone(), None),
1824        };
1825        let ch = self.cell_px.1.max(1) as u32;
1826        let (scaled_w, scaled_h) = protocol_scaled_dims(iw, ih, self.cols, self.cell_px, self.image_width);
1827
1828        // Build / reuse the width-scaled image.
1829        let need = self.image_scaled.as_ref().map(|(c, _)| *c != scaled_w as u16).unwrap_or(true);
1830        if need {
1831            let src = self.image.as_ref().unwrap();
1832            let scaled = image::imageops::resize(src, scaled_w, scaled_h, image::imageops::FilterType::Triangle);
1833            self.image_scaled = Some((scaled_w as u16, scaled));
1834        }
1835
1836        let total_rows = protocol_occupied_rows(iw, ih, self.cols, self.cell_px, self.image_width);
1837        let max_top = total_rows.saturating_sub(body_rows);
1838        if self.top_line > max_top { self.top_line = max_top; }
1839        self.left_col = 0; // horizontal scroll is a no-op in protocol mode
1840
1841        let y0 = (self.top_line as u32 * ch).min(scaled_h);
1842        let band_h = ((body_rows as u32) * ch).min(scaled_h - y0).max(1);
1843        let scaled = &self.image_scaled.as_ref().unwrap().1;
1844        let band = image::imageops::crop_imm(scaled, 0, y0, scaled_w, band_h).to_image();
1845        let blob = match self.image_protocol {
1846            ImageProtocol::Kitty => crate::image_protocol::encode_kitty(&band),
1847            ImageProtocol::Sixel => crate::image_protocol::encode_sixel(&band),
1848            ImageProtocol::Ascii => unreachable!("frame_image_protocol only entered for non-Ascii"),
1849        };
1850        let status = self.format_status_image_protocol(total_rows);
1851        blank(status, Some(blob))
1852    }
1853
1854    #[cfg(feature = "image")]
1855    fn format_status_image_protocol(&self, total_rows: usize) -> String {
1856        let body = self.body_rows() as usize;
1857        let top = self.top_line + 1;
1858        let bottom = (self.top_line + body).min(total_rows.max(1));
1859        let proto = match self.image_protocol {
1860            ImageProtocol::Kitty => "kitty",
1861            ImageProtocol::Sixel => "sixel",
1862            ImageProtocol::Ascii => "ascii",
1863        };
1864        let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
1865        format!("{}  {}  {}  [{}]  rows {}-{}/{}", self.source_label, dims, self.image_format, proto, top, bottom, total_rows)
1866    }
1867
1868    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
1869    /// reset to 0 so the start of the destination line is at the top of
1870    /// the viewport. In hide mode this is equivalent to `scroll_lines`
1871    /// (which already moves by visible/logical lines).
1872    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1873        if delta == 0 { return; }
1874        #[cfg(feature = "image")]
1875        if self.image_mode {
1876            self.scroll_lines(delta, src, idx);
1877            return;
1878        }
1879        if self.hide_mode() {
1880            // J/K move by whole visible lines (ignoring wrap rows), with K
1881            // first snapping to the start of the current line — the visible-
1882            // line analogue of the non-hide branch below.
1883            self.extend_visible_lines(idx, src);
1884            let n = self.visible_lines.len();
1885            if n == 0 {
1886                self.top_line = 0;
1887                self.top_row = 0;
1888                return;
1889            }
1890            let vi = self
1891                .visible_lines
1892                .iter()
1893                .position(|&l| l >= self.top_line)
1894                .unwrap_or(n - 1);
1895            if delta > 0 {
1896                let target = (vi + delta as usize).min(n - 1);
1897                self.top_line = self.visible_lines[target];
1898                self.top_row = 0;
1899            } else {
1900                let back = (-delta) as usize;
1901                let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1902                let extra_back = back.saturating_sub(consumed_for_snap);
1903                self.top_line = self.visible_lines[vi.saturating_sub(extra_back)];
1904                self.top_row = 0;
1905            }
1906            return;
1907        }
1908        if delta > 0 {
1909            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1910            let total = idx.line_count();
1911            if total == 0 { return; }
1912            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1913            self.top_line = target;
1914            self.top_row = 0;
1915        } else {
1916            let back = (-delta) as usize;
1917            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1918            // the start of the current line; only the remaining count goes to
1919            // previous lines. This matches the user's mental model of "jump
1920            // to the start of the previous line".
1921            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1922            let extra_back = back.saturating_sub(consumed_for_snap);
1923            self.top_line = self.top_line.saturating_sub(extra_back);
1924            self.top_row = 0;
1925        }
1926    }
1927
1928    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1929        if delta == 0 { return; }
1930        #[cfg(feature = "image")]
1931        if self.image_mode {
1932            let total = self.image_total_rows();
1933            let body = self.body_rows() as usize;
1934            let max_top = total.saturating_sub(body);
1935            let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
1936            self.top_line = next as usize;
1937            self.top_row = 0;
1938            return;
1939        }
1940        if self.hide_mode() {
1941            // Scroll by display rows over the visible (matching) lines, honoring
1942            // wrap rows via top_row — the same model as the non-hide branch
1943            // below, but walking visible_lines instead of every line.
1944            self.extend_visible_lines(idx, src);
1945            let n = self.visible_lines.len();
1946            if n == 0 {
1947                self.top_line = 0;
1948                self.top_row = 0;
1949                return;
1950            }
1951            let mut vi = self
1952                .visible_lines
1953                .iter()
1954                .position(|&l| l >= self.top_line)
1955                .unwrap_or(n - 1);
1956            // Keep top anchored on a real visible line; a top_row only means
1957            // something relative to one.
1958            if self.visible_lines[vi] != self.top_line {
1959                self.top_row = 0;
1960            }
1961            self.top_line = self.visible_lines[vi];
1962            let r_opts = self.render_opts(self.gutter_width(idx));
1963            if delta > 0 {
1964                let mut remaining = delta as usize;
1965                while remaining > 0 {
1966                    let line = self.visible_lines[vi];
1967                    let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1968                    if self.top_row + 1 < rows {
1969                        self.top_row += 1;
1970                    } else if vi + 1 < n {
1971                        self.top_row = 0;
1972                        vi += 1;
1973                        self.top_line = self.visible_lines[vi];
1974                    } else {
1975                        break;
1976                    }
1977                    remaining -= 1;
1978                }
1979                let anchor = self.hide_bottom_anchor(src, idx);
1980                if (self.top_line, self.top_row) > anchor {
1981                    self.top_line = anchor.0;
1982                    self.top_row = anchor.1;
1983                }
1984            } else {
1985                let mut remaining = (-delta) as usize;
1986                while remaining > 0 {
1987                    if self.top_row > 0 {
1988                        self.top_row -= 1;
1989                    } else if vi > 0 {
1990                        vi -= 1;
1991                        self.top_line = self.visible_lines[vi];
1992                        let line = self.visible_lines[vi];
1993                        let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
1994                        self.top_row = rows.saturating_sub(1);
1995                    } else {
1996                        break;
1997                    }
1998                    remaining -= 1;
1999                }
2000            }
2001            return;
2002        }
2003        if delta > 0 {
2004            let mut remaining = delta as usize;
2005            while remaining > 0 {
2006                idx.extend_to_line(self.top_line + 1, src);
2007                let total = idx.line_count();
2008                if total == 0 { break; }
2009                let bytes = self.line_display_bytes(src, idx, self.top_line);
2010                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2011                if self.top_row + 1 < line_rows {
2012                    self.top_row += 1;
2013                } else if self.top_line + 1 < total {
2014                    self.top_row = 0;
2015                    self.top_line += 1;
2016                } else {
2017                    break;
2018                }
2019                remaining -= 1;
2020            }
2021            // Don't scroll past the natural bottom (the last line resting on
2022            // the last body row). Only clamp once the source is fully
2023            // scanned — mid-scan the true end is unknown, and clamping to a
2024            // partial index would strand the viewport short of unread content.
2025            if idx.scanned_through() >= src.len() {
2026                let anchor = self.bottom_anchor(src, idx);
2027                if (self.top_line, self.top_row) > anchor {
2028                    self.top_line = anchor.0;
2029                    self.top_row = anchor.1;
2030                }
2031            }
2032        } else {
2033            let mut remaining = (-delta) as usize;
2034            while remaining > 0 {
2035                if self.top_row > 0 {
2036                    self.top_row -= 1;
2037                } else if self.top_line > 0 {
2038                    self.top_line -= 1;
2039                    let bytes = self.line_display_bytes(src, idx, self.top_line);
2040                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
2041                    self.top_row = line_rows.saturating_sub(1);
2042                } else {
2043                    break;
2044                }
2045                remaining -= 1;
2046            }
2047        }
2048    }
2049
2050    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2051        let n = self.page_size
2052            .map(|p| p as i64)
2053            .unwrap_or_else(|| self.body_rows() as i64);
2054        self.scroll_lines(n, src, idx);
2055    }
2056
2057    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2058        let n = self.page_size
2059            .map(|p| p as i64)
2060            .unwrap_or_else(|| self.body_rows() as i64);
2061        self.scroll_lines(-n, src, idx);
2062    }
2063
2064    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2065        let n = (self.body_rows() / 2).max(1) as i64;
2066        self.scroll_lines(n, src, idx);
2067    }
2068
2069    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2070        let n = (self.body_rows() / 2).max(1) as i64;
2071        self.scroll_lines(-n, src, idx);
2072    }
2073
2074    pub fn goto_top(&mut self) {
2075        self.top_line = 0;
2076        self.top_row = 0;
2077    }
2078
2079    /// The top `(line, row)` position such that the source's final display row
2080    /// lands on the last body row. Computed in display-row units by walking
2081    /// backward from the end, so it stays correct when lines wrap (the
2082    /// default): the last `body` logical lines can occupy more than `body`
2083    /// rows. Returns `(0, 0)` when the whole document fits within the body.
2084    /// Non-hide-mode only — hide mode scrolls by whole visible lines.
2085    fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2086        let body = self.body_rows() as usize;
2087        let total = idx.line_count();
2088        if total == 0 || body == 0 {
2089            return (0, 0);
2090        }
2091        let r_opts = self.render_opts(self.gutter_width(idx));
2092        let mut remaining = body;
2093        let mut line = total - 1;
2094        loop {
2095            let bytes = self.line_display_bytes(src, idx, line);
2096            let line_rows = count_rows(&bytes, &r_opts, None).max(1);
2097            if line_rows >= remaining {
2098                return (line, line_rows - remaining);
2099            }
2100            remaining -= line_rows;
2101            if line == 0 {
2102                return (0, 0);
2103            }
2104            line -= 1;
2105        }
2106    }
2107
2108    /// Hide-mode bottom anchor, in display-row units over the *visible*
2109    /// (matching) lines: the top `(line, row)` such that the last visible
2110    /// line's final wrap row lands on the last body row. Mirrors
2111    /// `bottom_anchor`, but walks `visible_lines` instead of every line.
2112    fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
2113        let body = self.body_rows() as usize;
2114        let n = self.visible_lines.len();
2115        if n == 0 || body == 0 {
2116            return (0, 0);
2117        }
2118        let r_opts = self.render_opts(self.gutter_width(idx));
2119        let mut remaining = body;
2120        let mut vi = n - 1;
2121        loop {
2122            let line = self.visible_lines[vi];
2123            let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
2124            if rows >= remaining {
2125                return (line, rows - remaining);
2126            }
2127            remaining -= rows;
2128            if vi == 0 {
2129                return (self.visible_lines[0], 0);
2130            }
2131            vi -= 1;
2132        }
2133    }
2134
2135    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
2136        #[cfg(feature = "image")]
2137        if self.image_mode {
2138            let body = self.body_rows() as usize;
2139            self.top_line = self.image_total_rows().saturating_sub(body);
2140            self.top_row = 0;
2141            return;
2142        }
2143        idx.extend_to_end(src);
2144        if self.hide_mode() {
2145            self.extend_visible_lines(idx, src);
2146            let (line, row) = self.hide_bottom_anchor(src, idx);
2147            self.top_line = line;
2148            self.top_row = row;
2149        } else {
2150            let (line, row) = self.bottom_anchor(src, idx);
2151            self.top_line = line;
2152            self.top_row = row;
2153        }
2154    }
2155
2156    /// Position the viewport so line `n` (0-indexed) is the top visible line.
2157    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2158        idx.extend_to_line(n, src);
2159        let target = n.min(idx.line_count().saturating_sub(1));
2160        self.top_line = target;
2161        self.top_row = 0;
2162    }
2163
2164    /// Position the viewport at the start of record `n` (0-indexed).
2165    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
2166        // Ensure the record exists by extending the index. Records can only
2167        // appear after their constituent lines are scanned; extend repeatedly
2168        // until the record exists or we hit EOF.
2169        while idx.record_count() <= n && idx.scanned_through() < src.len() {
2170            idx.extend_to_end(src);
2171        }
2172        if idx.record_count() == 0 {
2173            return;
2174        }
2175        let target = n.min(idx.record_count().saturating_sub(1));
2176        let line_range = idx.record_line_range(target);
2177        self.top_line = line_range.start;
2178        self.top_row = 0;
2179    }
2180
2181    /// Position the viewport at `p` percent through the file by bytes.
2182    /// `p` is clamped to 0..=100. p=100 lands at the last line.
2183    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
2184        let p = p.min(100) as usize;
2185        let target_byte = src.len().saturating_mul(p) / 100;
2186        idx.extend_to_byte_for_query(src, target_byte);
2187        let line_n = idx.line_at_byte(target_byte)
2188            .or_else(|| {
2189                // target_byte at or past EOF: fall through to the last line.
2190                let lc = idx.line_count();
2191                if lc > 0 { Some(lc - 1) } else { None }
2192            })
2193            .unwrap_or(0);
2194        self.top_line = line_n;
2195        self.top_row = 0;
2196    }
2197
2198    /// Get the currently top-displayed physical line index.
2199    pub fn top_line(&self) -> usize {
2200        self.top_line
2201    }
2202
2203    pub fn resize(&mut self, cols: u16, rows: u16) {
2204        self.cols = cols.max(1);
2205        self.rows = rows.max(2);
2206        self.opts.cols = self.cols;
2207    }
2208
2209    pub fn toggle_line_numbers(&mut self) {
2210        self.show_line_numbers = !self.show_line_numbers;
2211    }
2212
2213    pub fn toggle_chop(&mut self) {
2214        self.opts.wrap = !self.opts.wrap;
2215        if self.opts.wrap {
2216            self.left_col = 0;
2217        }
2218    }
2219
2220    const HSCROLL_STEP: usize = 8;
2221
2222    /// Horizontal scroll is available only for non-wrapping content: image
2223    /// mode, or chop-mode text. Wrap mode has no horizontal axis; hex (fixed
2224    /// xxd layout) and raw (`AnsiMode::Raw`) are excluded.
2225    pub fn hscroll_active(&self) -> bool {
2226        #[cfg(feature = "image")]
2227        if self.image.is_some() {
2228            return true;
2229        }
2230        !self.opts.wrap
2231            && !self.hex_mode
2232            && self.ansi_mode != crate::render::AnsiMode::Raw
2233    }
2234
2235    fn hscroll_by(&mut self, delta: isize) {
2236        if !self.hscroll_active() {
2237            return;
2238        }
2239        self.left_col = (self.left_col as isize + delta).max(0) as usize;
2240        // Upper bound is enforced per-frame by the clamp in Task 3/4.
2241    }
2242
2243    pub fn hscroll_left_half(&mut self)  { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(-h); }
2244    pub fn hscroll_right_half(&mut self) { let h = (self.cols as usize / 2).max(1) as isize; self.hscroll_by(h); }
2245    pub fn hscroll_left_step(&mut self)  { self.hscroll_by(-(Self::HSCROLL_STEP as isize)); }
2246    pub fn hscroll_right_step(&mut self) { self.hscroll_by(Self::HSCROLL_STEP as isize); }
2247
2248    /// Scroll left by an explicit column count (used by `--shift N`).
2249    pub fn hscroll_left_cols(&mut self, n: u16)  { self.hscroll_by(-(n as isize)); }
2250    /// Scroll right by an explicit column count (used by `--shift N`).
2251    pub fn hscroll_right_cols(&mut self, n: u16) { self.hscroll_by(n as isize); }
2252
2253    pub fn left_col(&self) -> usize { self.left_col }
2254
2255    /// Drop the horizontal scroll offset. Called when a new file is opened
2256    /// (a fresh document has no relationship to the prior horizontal position).
2257    pub fn reset_hscroll(&mut self) { self.left_col = 0; }
2258
2259    /// Return the current set of visible (matched) line indices. Non-empty only
2260    /// in hide mode (filter or grep active without --dim). Stable public accessor
2261    /// so integration tests and external tooling can inspect filter results.
2262    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
2263}
2264
2265/// Fit-width scaled pixel dimensions of an image: width = target_cols cells
2266/// wide (in pixels), height preserving aspect. `width_cols` overrides the
2267/// terminal `cols` when `Some` (from `--image-width`). Pure; the single source
2268/// of the protocol fit-width scaling formula.
2269#[cfg(feature = "image")]
2270pub fn protocol_scaled_dims(img_w: u32, img_h: u32, cols: u16,
2271                            cell_px: (u16, u16), width_cols: Option<usize>) -> (u32, u32) {
2272    let target_cols = width_cols.unwrap_or(cols as usize).max(1) as u32;
2273    let scaled_w = (target_cols * cell_px.0.max(1) as u32).max(1);
2274    let img_w = img_w.max(1);
2275    let scaled_h = (img_h as u64 * scaled_w as u64 / img_w as u64).max(1) as u32;
2276    (scaled_w, scaled_h)
2277}
2278
2279/// Text rows a fit-width protocol image occupies. `width_cols` overrides the
2280/// terminal `cols` when `Some` (from `--image-width`). Pure; used for scroll math.
2281#[cfg(feature = "image")]
2282pub fn protocol_occupied_rows(img_w: u32, img_h: u32, cols: u16,
2283                              cell_px: (u16, u16), width_cols: Option<usize>) -> usize {
2284    let (_, scaled_h) = protocol_scaled_dims(img_w, img_h, cols, cell_px, width_cols);
2285    (scaled_h as usize).div_ceil(cell_px.1.max(1) as usize).max(1)
2286}
2287
2288#[cfg(test)]
2289mod tests {
2290    use super::*;
2291    use crate::source::MockSource;
2292
2293    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
2294        let m = MockSource::new();
2295        m.append(content);
2296        m.finish();
2297        let idx = LineIndex::new();
2298        (m, idx)
2299    }
2300
2301    /// Read the `ch` of the first cell of a body row (the status column when
2302    /// `-J` is on). Panics if the cell isn't a `Cell::Char`.
2303    fn first_cell_char(row: &[Cell]) -> char {
2304        match row.first() {
2305            Some(Cell::Char { ch, .. }) => *ch,
2306            other => panic!("expected Char in first cell, got {:?}", other),
2307        }
2308    }
2309
2310    #[test]
2311    fn status_column_shows_mark_then_search_glyphs() {
2312        // 3 lines, chop mode (no wrap). Mark line 1 ('a'), search matches
2313        // "bb" on line 2. Line 0 → blank, line 1 → 'a' (mark), line 2 → '*'.
2314        let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2315        let mut v = Viewport::new(20, 5, "f".into()); // body = 4
2316        v.opts.wrap = false;
2317        v.set_status_column(true);
2318        let mut marks = std::collections::HashMap::new();
2319        marks.insert(1usize, 'a');
2320        v.set_status_marks(marks);
2321        v.set_search("cc".into(), SearchDirection::Forward).unwrap();
2322
2323        let frame = v.frame(&m, &mut idx);
2324        assert_eq!(first_cell_char(&frame.body[0]), ' ', "line 0: no mark, no match");
2325        assert_eq!(first_cell_char(&frame.body[1]), 'a', "line 1: mark letter");
2326        assert_eq!(first_cell_char(&frame.body[2]), '*', "line 2: search match");
2327    }
2328
2329    #[test]
2330    fn status_column_mark_beats_search_match() {
2331        // Line 1 is BOTH marked ('z') AND matched by the search — the mark
2332        // letter takes precedence over the search `*`.
2333        let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2334        let mut v = Viewport::new(20, 5, "f".into());
2335        v.opts.wrap = false;
2336        v.set_status_column(true);
2337        let mut marks = std::collections::HashMap::new();
2338        marks.insert(1usize, 'z');
2339        v.set_status_marks(marks);
2340        v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2341
2342        let frame = v.frame(&m, &mut idx);
2343        assert_eq!(first_cell_char(&frame.body[1]), 'z', "mark beats search-match");
2344    }
2345
2346    #[test]
2347    fn status_column_matches_content_not_gutter_digits() {
2348        // Regression: with line numbers (`-N`) on, searching for a digit that
2349        // appears in a visible LINE NUMBER must not falsely flag `*` in the
2350        // status column. The `*` must reflect matches in the CONTENT only,
2351        // never the gutter digits / status cell / padding.
2352        // 12 lines, all letters, no digits in content. Line number 5 contains
2353        // the digit '5' in its gutter; searching "5" must NOT mark that line.
2354        let (m, mut idx) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2355        let mut v = Viewport::new(40, 14, "f".into()); // body = 13
2356        v.opts.wrap = false;
2357        v.show_line_numbers = true;
2358        v.set_status_column(true);
2359        v.set_search("5".into(), SearchDirection::Forward).unwrap();
2360
2361        let frame = v.frame(&m, &mut idx);
2362        // body[4] is line 5 (1-based) — its gutter shows "5" but content "ee"
2363        // has no '5'. Status column must be blank, not '*'. (Only the 12 real
2364        // content rows; row 12 is post-EOF filler with an Empty status cell.)
2365        for i in 0..12 {
2366            assert_eq!(
2367                first_cell_char(&frame.body[i]), ' ',
2368                "body row {i}: no content match for '5' but status column flagged it"
2369            );
2370        }
2371
2372        // Positive case: a search matching actual CONTENT still flags `*`.
2373        let (m2, mut idx2) = setup(b"aa\nbb\ncc\ndd\nee\nff\ngg\nhh\nii\njj\nkk\nll\n");
2374        let mut v2 = Viewport::new(40, 14, "f".into());
2375        v2.opts.wrap = false;
2376        v2.show_line_numbers = true;
2377        v2.set_status_column(true);
2378        v2.set_search("ee".into(), SearchDirection::Forward).unwrap();
2379        let frame2 = v2.frame(&m2, &mut idx2);
2380        assert_eq!(first_cell_char(&frame2.body[4]), '*', "line 5 content 'ee' matches search");
2381    }
2382
2383    #[test]
2384    fn status_column_off_leaves_first_cell_as_content() {
2385        // With the feature off, no extra column is prepended — the first cell
2386        // of each body row is the line's content (no line numbers here).
2387        let (m, mut idx) = setup(b"aa\nbb\ncc\n");
2388        let mut v = Viewport::new(20, 5, "f".into());
2389        v.opts.wrap = false;
2390        // status_column defaults off; set a mark + search anyway to prove gating.
2391        let mut marks = std::collections::HashMap::new();
2392        marks.insert(1usize, 'a');
2393        v.set_status_marks(marks);
2394        v.set_search("bb".into(), SearchDirection::Forward).unwrap();
2395
2396        let frame = v.frame(&m, &mut idx);
2397        assert_eq!(first_cell_char(&frame.body[0]), 'a', "line 0 content unchanged");
2398        assert_eq!(first_cell_char(&frame.body[1]), 'b', "line 1 content unchanged");
2399        assert_eq!(first_cell_char(&frame.body[2]), 'c', "line 2 content unchanged");
2400    }
2401
2402    #[test]
2403    fn frame_renders_body_height_rows() {
2404        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
2405        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
2406        let frame = v.frame(&m, &mut idx);
2407        assert_eq!(frame.body.len(), 4);
2408        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2409        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2410    }
2411
2412    #[test]
2413    fn scroll_down_advances_top_line() {
2414        // 8 lines, body=4 → there are 4 lines below the first screen to scroll
2415        // into, so scrolling down by 2 lands cleanly above the bottom anchor.
2416        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2417        let mut v = Viewport::new(10, 5, "test".into());
2418        v.scroll_lines(2, &m, &mut idx);
2419        assert_eq!(v.top_line, 2);
2420        assert_eq!(v.top_row, 0);
2421    }
2422
2423    #[test]
2424    fn scroll_up_clamps_at_zero() {
2425        let (m, mut idx) = setup(b"a\nb\nc\n");
2426        let mut v = Viewport::new(10, 5, "test".into());
2427        v.scroll_lines(-5, &m, &mut idx);
2428        assert_eq!(v.top_line, 0);
2429        assert_eq!(v.top_row, 0);
2430    }
2431
2432    #[test]
2433    fn scroll_down_clamps_at_last_line() {
2434        // 8 single-row lines, body=4. Scrolling far past the end clamps at the
2435        // bottom anchor: the last line resting on the last body row, i.e.
2436        // top_line = 8 - 4 = 4. (Not line 7 at the top — that would strand the
2437        // tail off-screen, the bug this guards against.)
2438        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\n");
2439        let mut v = Viewport::new(10, 5, "test".into());
2440        v.scroll_lines(50, &m, &mut idx);
2441        assert_eq!((v.top_line, v.top_row), (4, 0));
2442        assert!(v.is_at_bottom(&m, &idx));
2443    }
2444
2445    #[test]
2446    fn scroll_logical_lines_skips_wrap_rows() {
2447        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
2448        let mut content = vec![b'X'; 500];
2449        content.push(b'\n');
2450        content.extend_from_slice(b"second\n");
2451        content.extend_from_slice(b"third\n");
2452        let (m, mut idx) = setup(&content);
2453        let mut v = Viewport::new(10, 8, "f".into());
2454        v.scroll_logical_lines(1, &m, &mut idx);
2455        assert_eq!((v.top_line, v.top_row), (1, 0));
2456        v.scroll_logical_lines(1, &m, &mut idx);
2457        assert_eq!((v.top_line, v.top_row), (2, 0));
2458    }
2459
2460    #[test]
2461    fn scroll_logical_lines_back_snaps_to_line_start() {
2462        // Mid-wrap K should snap to start of current line first, then go back.
2463        // Three 50-char lines (5 wrap rows each) with body=8 put the bottom
2464        // anchor at (1, 2), so scrolling down 7 rows lands inside line 1's
2465        // wraps without being clamped.
2466        let mut content = vec![b'A'; 50];
2467        content.push(b'\n');
2468        content.extend_from_slice(&[b'B'; 50]);
2469        content.push(b'\n');
2470        content.extend_from_slice(&[b'C'; 50]);
2471        content.push(b'\n');
2472        let (m, mut idx) = setup(&content);
2473        let mut v = Viewport::new(10, 8, "f".into());
2474        v.scroll_lines(7, &m, &mut idx);
2475        assert_eq!(v.top_line, 1, "should be on line 1");
2476        assert!(v.top_row > 0, "should be inside line 1's wraps");
2477        v.scroll_logical_lines(-1, &m, &mut idx);
2478        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
2479        v.scroll_logical_lines(-1, &m, &mut idx);
2480        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
2481    }
2482
2483    #[test]
2484    fn scroll_down_walks_wraps_of_last_line() {
2485        // Last line is 60 chars in a 10-col viewport → 6 wrap rows. With body=4
2486        // the bottom anchor sits at (1, 2), so walking into the last line's
2487        // wraps up to row 2 is legitimate (it doesn't strand the tail).
2488        let mut content = b"first\n".to_vec();
2489        content.extend_from_slice(&[b'X'; 60]);
2490        content.push(b'\n');
2491        let (m, mut idx) = setup(&content);
2492        let mut v = Viewport::new(10, 5, "f".into());
2493        v.scroll_lines(1, &m, &mut idx);
2494        assert_eq!((v.top_line, v.top_row), (1, 0));
2495        v.scroll_lines(1, &m, &mut idx);
2496        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
2497        v.scroll_lines(1, &m, &mut idx);
2498        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach the bottom anchor row");
2499        // Already at the anchor — scrolling further must not strand the tail.
2500        v.scroll_lines(5, &m, &mut idx);
2501        assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
2502    }
2503
2504    #[test]
2505    fn scroll_down_walks_wrap_rows_within_long_line() {
2506        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
2507        // Six short lines follow so the bottom anchor sits well below line 0,
2508        // leaving room to walk through line 0's wrap rows and into line 1.
2509        let mut content = vec![b'X'; 30];
2510        content.push(b'\n');
2511        content.extend_from_slice(b"a\nb\nc\nd\ne\nf\n");
2512        let (m, mut idx) = setup(&content);
2513        let mut v = Viewport::new(10, 5, "f".into());
2514        v.scroll_lines(1, &m, &mut idx);
2515        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
2516        v.scroll_lines(1, &m, &mut idx);
2517        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
2518        v.scroll_lines(1, &m, &mut idx);
2519        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
2520    }
2521
2522    #[test]
2523    fn status_line_shows_range_and_pct() {
2524        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2525        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
2526        let frame = v.frame(&m, &mut idx);
2527        assert!(frame.status.starts_with("f  1-4/10"));
2528    }
2529
2530    #[test]
2531    fn page_down_advances_by_body_rows() {
2532        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2533        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
2534        v.page_down(&m, &mut idx);
2535        assert_eq!(v.top_line, 4);
2536    }
2537
2538    #[test]
2539    fn page_up_then_page_down_returns_to_start_when_no_resize() {
2540        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2541        let mut v = Viewport::new(10, 5, "f".into());
2542        v.page_down(&m, &mut idx);
2543        v.page_up(&m, &mut idx);
2544        assert_eq!(v.top_line, 0);
2545        assert_eq!(v.top_row, 0);
2546    }
2547
2548    #[test]
2549    fn half_page_down_advances_by_half_body() {
2550        // 12 lines, body=6 → bottom anchor at line 6, so a half-page (3) lands
2551        // cleanly above it.
2552        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
2553        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
2554        v.half_page_down(&m, &mut idx);
2555        assert_eq!(v.top_line, 3);
2556    }
2557
2558    #[test]
2559    fn goto_top_resets_position() {
2560        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
2561        let mut v = Viewport::new(10, 5, "f".into());
2562        v.scroll_lines(2, &m, &mut idx);
2563        v.goto_top();
2564        assert_eq!(v.top_line, 0);
2565        assert_eq!(v.top_row, 0);
2566    }
2567
2568    #[test]
2569    fn goto_bottom_scrolls_to_last_page() {
2570        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
2571        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
2572        v.goto_bottom(&m, &mut idx);
2573        // Last page should show lines 7..=10 → top_line = 6.
2574        assert_eq!(v.top_line, 6);
2575    }
2576
2577    #[test]
2578    #[cfg(feature = "image")]
2579    fn protocol_image_occupied_rows_fit_width() {
2580        // 100x200 image, cols=50, cell_px=(8,16): scaled_w=400, scaled_h=800, rows=50.
2581        assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), None), 50);
2582        // --image-width 25 cols: scaled_w=200, scaled_h=400, rows=25.
2583        assert_eq!(crate::viewport::protocol_occupied_rows(100, 200, 50, (8, 16), Some(25)), 25);
2584    }
2585
2586    #[cfg(feature = "image")]
2587    #[test]
2588    fn frame_image_protocol_sets_image_blob() {
2589        use image::{Rgba, RgbaImage};
2590        let mut vp = Viewport::new(40, 10, "cat.png".into());
2591        let mut idx = LineIndex::new();
2592        let m = MockSource::new();
2593        vp.set_image(RgbaImage::from_pixel(20, 40, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2594        vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2595        let frame = vp.frame(&m, &mut idx);
2596        assert!(frame.image_blob.is_some(), "Kitty protocol frame carries an image blob");
2597        // ASCII protocol → no blob
2598        vp.set_image_protocol(crate::viewport::ImageProtocol::Ascii, None);
2599        let frame2 = vp.frame(&m, &mut idx);
2600        assert!(frame2.image_blob.is_none(), "ASCII protocol frame has no blob");
2601    }
2602
2603    #[cfg(feature = "image")]
2604    #[test]
2605    fn protocol_image_clamps_vertical_scroll() {
2606        use image::{Rgba, RgbaImage};
2607        let mut vp = Viewport::new(40, 10, "cat.png".into()); // body = 9
2608        let mut idx = LineIndex::new();
2609        let m = MockSource::new();
2610        // Tall image so it occupies many rows.
2611        vp.set_image(RgbaImage::from_pixel(20, 2000, Rgba([10, 20, 30, 255])), "png", crate::image_render::AsciiStyle::Ramp, None);
2612        vp.set_image_protocol(crate::viewport::ImageProtocol::Kitty, Some((8, 16)));
2613        // Scroll way past the end; frame() applies the clamp.
2614        for _ in 0..10_000 {
2615            vp.scroll_lines(1, &m, &mut idx);
2616        }
2617        let _ = vp.frame(&m, &mut idx);
2618        // After scrolling past the end, top_line clamps to (protocol_total - body_rows).
2619        let total = crate::viewport::protocol_occupied_rows(20, 2000, 40, (8, 16), None);
2620        let body = vp.body_rows() as usize;
2621        assert_eq!(
2622            vp.top_line(),
2623            total.saturating_sub(body),
2624            "scroll reaches exactly the protocol image bottom"
2625        );
2626    }
2627
2628    #[cfg(feature = "image")]
2629    #[test]
2630    fn image_mode_frame_renders_and_scrolls() {
2631        use image::{Rgba, RgbaImage};
2632        let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
2633        let mut v = Viewport::new(20, 6, "cat.png".into()); // body = 5
2634        v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
2635        assert!(v.image_mode());
2636        let total = v.image_total_rows();
2637        assert!(total > 5, "tall image should exceed the body");
2638        assert!(!v.is_at_bottom_image(), "starts at top");
2639        let mut idx = LineIndex::new();
2640        let m = MockSource::new();
2641        let frame = v.frame(&m, &mut idx);
2642        assert_eq!(frame.body.len(), 5);
2643        v.goto_bottom(&m, &mut idx);
2644        assert!(v.is_at_bottom_image());
2645    }
2646
2647    #[cfg(feature = "image")]
2648    #[test]
2649    fn frame_image_slices_at_left_col() {
2650        use crate::render::Cell;
2651        use image::{Rgba, RgbaImage};
2652
2653        // Image pixel width = 40, terminal cols = 10.
2654        // set_image with width=Some(40) → image_cols() = 40 → grid rows are 40 cells wide.
2655        // Terminal is only 10 cols wide, so the grid is wider than the viewport.
2656        // Column-varying image so each grid column renders a DISTINCT cell —
2657        // otherwise the slice-advance assertions below would be tautological.
2658        let img = RgbaImage::from_fn(40, 20, |x, _y| Rgba([(x as u8).saturating_mul(6), 0, 0, 255]));
2659        let mut v = Viewport::new(10, 4, "wide.png".into()); // body = 3, cols = 10
2660        v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(40));
2661        assert!(v.hscroll_active(), "image mode should make hscroll active");
2662
2663        let mut idx = LineIndex::new();
2664        let m = MockSource::new();
2665
2666        // --- pass 1: left_col == 0, frame_image returns grid columns 0..10 ---
2667        assert_eq!(v.left_col(), 0);
2668        let frame0 = v.frame(&m, &mut idx);
2669        assert_eq!(frame0.body.len(), 3, "body should have body_rows rows");
2670        // The grid row at offset 0 (no scroll) should give us exactly cols=10 cells.
2671        assert_eq!(frame0.body[0].len(), 10);
2672        // No '<' or '>' marker cells (images never show scroll markers).
2673        assert!(
2674            !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2675            "no scroll marker expected on image frame at left_col=0"
2676        );
2677        // Record the first cell at offset 0 for comparison after scrolling.
2678        let cell_at_col0 = frame0.body[0][0].clone();
2679        let cell_at_col8 = frame0.body[0][8].clone();
2680
2681        // --- pass 2: scroll right (left_col = HSCROLL_STEP = 8) ---
2682        v.hscroll_right_step();
2683        assert_eq!(v.left_col(), 8);
2684        let frame1 = v.frame(&m, &mut idx);
2685        assert_eq!(frame1.body[0].len(), 10);
2686        // No '<' or '>' marker cells on images.
2687        assert!(
2688            !frame1.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. } | Cell::Char { ch: '>', .. })),
2689            "no scroll marker expected on image frame after hscroll_right_step"
2690        );
2691        // The slice DID advance: frame1.body[0][0] is what was at grid column 8.
2692        assert_eq!(
2693            frame1.body[0][0], cell_at_col8,
2694            "after hscroll_right_step the first visible cell should be grid col 8"
2695        );
2696        // And — because the image varies by column — it must differ from the
2697        // pre-scroll first cell, proving the offset was actually applied (not a
2698        // no-op that the uniform-image version couldn't have detected).
2699        assert_ne!(
2700            frame1.body[0][0], cell_at_col0,
2701            "the scrolled first cell must differ from the unscrolled one"
2702        );
2703    }
2704
2705    #[test]
2706    fn goto_line_positions_top_line() {
2707        let m = MockSource::new();
2708        m.append(b"a\nb\nc\nd\ne\n");
2709        let mut idx = LineIndex::new();
2710        idx.extend_to_end(&m);
2711        let mut v = Viewport::new(20, 5, "f".into());
2712        v.goto_line(3, &m, &mut idx);
2713        assert_eq!(v.top_line(), 3);
2714    }
2715
2716    #[test]
2717    fn goto_line_clamps_to_last_line() {
2718        let m = MockSource::new();
2719        m.append(b"a\nb\n");
2720        let mut idx = LineIndex::new();
2721        idx.extend_to_end(&m);
2722        let mut v = Viewport::new(20, 5, "f".into());
2723        v.goto_line(999, &m, &mut idx);
2724        assert_eq!(v.top_line(), 1);
2725    }
2726
2727    #[test]
2728    fn goto_record_positions_at_record_start_line() {
2729        let m = MockSource::new();
2730        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
2731        let mut idx = LineIndex::new();
2732        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2733        idx.extend_to_end(&m);
2734        let mut v = Viewport::new(20, 5, "f".into());
2735        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
2736        assert_eq!(v.top_line(), 2);
2737    }
2738
2739    #[test]
2740    fn goto_record_in_line_per_record_mode_equals_goto_line() {
2741        let m = MockSource::new();
2742        m.append(b"a\nb\nc\n");
2743        let mut idx = LineIndex::new();
2744        idx.extend_to_end(&m);
2745        let mut v = Viewport::new(20, 5, "f".into());
2746        v.goto_record(2, &m, &mut idx);
2747        assert_eq!(v.top_line(), 2);
2748    }
2749
2750    #[test]
2751    fn goto_percent_50_lands_in_middle() {
2752        let m = MockSource::new();
2753        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
2754        let mut idx = LineIndex::new();
2755        idx.extend_to_end(&m);
2756        let mut v = Viewport::new(20, 5, "f".into());
2757        v.goto_percent(50, &m, &mut idx);
2758        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
2759    }
2760
2761    #[test]
2762    fn goto_percent_100_lands_at_last_line() {
2763        let m = MockSource::new();
2764        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
2765        let mut idx = LineIndex::new();
2766        idx.extend_to_end(&m);
2767        let mut v = Viewport::new(20, 5, "f".into());
2768        v.goto_percent(100, &m, &mut idx);
2769        assert_eq!(v.top_line(), 2);
2770    }
2771
2772    #[test]
2773    fn goto_percent_0_lands_at_first_line() {
2774        let m = MockSource::new();
2775        m.append(b"a\nb\nc\n");
2776        let mut idx = LineIndex::new();
2777        idx.extend_to_end(&m);
2778        let mut v = Viewport::new(20, 5, "f".into());
2779        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
2780        assert_eq!(v.top_line(), 2);
2781        v.goto_percent(0, &m, &mut idx);
2782        assert_eq!(v.top_line(), 0);
2783    }
2784
2785    #[test]
2786    fn resize_updates_dimensions_and_render_opts() {
2787        let (m, mut idx) = setup(b"1\n2\n");
2788        let mut v = Viewport::new(10, 5, "f".into());
2789        v.resize(40, 12);
2790        assert_eq!(v.cols, 40);
2791        assert_eq!(v.rows, 12);
2792        assert_eq!(v.opts.cols, 40);
2793        let _ = v.frame(&m, &mut idx);
2794    }
2795
2796    #[test]
2797    fn toggle_line_numbers_changes_gutter() {
2798        let (m, mut idx) = setup(b"a\nb\nc\n");
2799        let mut v = Viewport::new(10, 5, "f".into());
2800        let frame_off = v.frame(&m, &mut idx);
2801        v.toggle_line_numbers();
2802        let frame_on = v.frame(&m, &mut idx);
2803        // With gutter, first cell is a digit or space, not 'a'.
2804        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2805        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
2806    }
2807
2808    #[test]
2809    fn toggle_chop_changes_wrap_mode() {
2810        let (m, mut idx) = setup(b"abcdefghij\n");
2811        let mut v = Viewport::new(4, 5, "f".into());
2812        v.toggle_chop();
2813        let frame = v.frame(&m, &mut idx);
2814        // After toggle_chop, the line is one row, not wrapped.
2815        // Body row 0 is "abcd"; rows 1..3 are blank fill.
2816        assert_eq!(frame.body[0][..4],
2817            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2818             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2819             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
2820             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
2821        // Row 1 should be all-empty (no wrap continuation).
2822        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
2823    }
2824
2825    // ----- Follow mode -----
2826
2827    #[test]
2828    fn is_at_bottom_initially_only_when_source_fits() {
2829        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
2830        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
2831        idx.extend_to_end(&m);
2832        assert!(v.is_at_bottom(&m, &idx), "small file fits in body, top is at bottom");
2833    }
2834
2835    #[test]
2836    fn is_at_bottom_false_when_top_and_more_lines_below() {
2837        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
2838        let v = Viewport::new(10, 5, "f".into());  // body = 4
2839        idx.extend_to_end(&m);
2840        assert!(!v.is_at_bottom(&m, &idx), "top of 8-line file with body=4 is not at bottom");
2841    }
2842
2843    #[test]
2844    fn is_at_bottom_true_after_goto_bottom() {
2845        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
2846        let mut v = Viewport::new(10, 5, "f".into());
2847        v.goto_bottom(&m, &mut idx);
2848        assert!(v.is_at_bottom(&m, &idx));
2849    }
2850
2851    #[test]
2852    fn status_shows_follow_suffix_when_follow_mode_on() {
2853        let (m, mut idx) = setup(b"a\nb\n");
2854        let mut v = Viewport::new(20, 5, "f".into());
2855        let frame_off = v.frame(&m, &mut idx);
2856        assert!(!frame_off.status.contains("(F)"));
2857        v.set_follow_mode(true);
2858        let frame_on = v.frame(&m, &mut idx);
2859        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
2860    }
2861
2862    #[test]
2863    fn toggle_follow_flips_state() {
2864        let mut v = Viewport::new(10, 5, "f".into());
2865        assert!(!v.follow_mode());
2866        v.toggle_follow();
2867        assert!(v.follow_mode());
2868        v.toggle_follow();
2869        assert!(!v.follow_mode());
2870    }
2871
2872    #[test]
2873    fn idle_indicator_kicks_in_at_threshold() {
2874        let (m, mut idx) = setup(b"a\nb\n");
2875        let mut v = Viewport::new(20, 5, "f".into());
2876        v.set_follow_mode(true);
2877        // 19 idle ticks → still (F).
2878        for _ in 0..19 { v.tick_idle(); }
2879        let f1 = v.frame(&m, &mut idx);
2880        assert!(f1.status.contains("(F)"));
2881        assert!(!f1.status.contains("idle"));
2882        // 20th tick crosses the threshold.
2883        v.tick_idle();
2884        let f2 = v.frame(&m, &mut idx);
2885        assert!(f2.status.contains("(F idle)"), "{}", f2.status);
2886    }
2887
2888    #[test]
2889    fn note_growth_resets_idle() {
2890        let (m, mut idx) = setup(b"a\nb\n");
2891        let mut v = Viewport::new(20, 5, "f".into());
2892        v.set_follow_mode(true);
2893        for _ in 0..25 { v.tick_idle(); }
2894        assert!(v.is_idle());
2895        v.note_growth();
2896        assert!(!v.is_idle());
2897        let f = v.frame(&m, &mut idx);
2898        assert!(!f.status.contains("idle"));
2899    }
2900
2901    #[test]
2902    fn qae_off_never_quits_even_at_bottom() {
2903        let (m, mut idx) = setup(b"a\n");
2904        let mut v = Viewport::new(20, 5, "f".into());
2905        v.set_quit_at_eof(QuitAtEof::Off);
2906        v.goto_bottom(&m, &mut idx);
2907        assert!(!v.note_motion_for_eof(true, &m, &idx));
2908    }
2909
2910    #[test]
2911    fn qae_first_quits_immediately_at_bottom() {
2912        let (m, mut idx) = setup(b"a\n");
2913        let mut v = Viewport::new(20, 5, "f".into());
2914        v.set_quit_at_eof(QuitAtEof::First);
2915        v.goto_bottom(&m, &mut idx);
2916        assert!(v.note_motion_for_eof(true, &m, &idx));
2917    }
2918
2919    #[test]
2920    fn qae_first_only_quits_at_eof_not_mid_file() {
2921        let mut content = Vec::new();
2922        for _ in 0..50 { content.extend_from_slice(b"x\n"); }
2923        let (m, mut idx) = setup(&content);
2924        idx.extend_to_end(&m);  // populate so is_at_bottom can see the 50 lines
2925        let mut v = Viewport::new(20, 5, "f".into());
2926        v.set_quit_at_eof(QuitAtEof::First);
2927        // top_line is 0; with 50 lines and a 5-row body, we're not at bottom.
2928        assert!(!v.is_at_bottom(&m, &idx));
2929        assert!(!v.note_motion_for_eof(true, &m, &idx));
2930    }
2931
2932    #[test]
2933    fn qae_second_quits_on_second_hit() {
2934        let (m, mut idx) = setup(b"a\n");
2935        let mut v = Viewport::new(20, 5, "f".into());
2936        v.set_quit_at_eof(QuitAtEof::Second);
2937        v.goto_bottom(&m, &mut idx);
2938        // 1st forward at EOF: count, don't quit.
2939        assert!(!v.note_motion_for_eof(true, &m, &idx));
2940        // 2nd forward at EOF: quit.
2941        assert!(v.note_motion_for_eof(true, &m, &idx));
2942    }
2943
2944    #[test]
2945    fn squeeze_collapses_consecutive_blanks() {
2946        // Source: a, blank, blank, blank, b.
2947        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
2948        let mut v = Viewport::new(10, 8, "f".into());
2949        v.set_squeeze_blanks(true);
2950        let f = v.frame(&m, &mut idx);
2951        // First non-empty body row chars (trimmed).
2952        let stringify = |row: &Vec<Cell>| -> String {
2953            row.iter().filter_map(|c| match c {
2954                Cell::Char { ch, .. } => Some(*ch),
2955                _ => None,
2956            }).collect::<String>().trim().to_string()
2957        };
2958        let rows: Vec<String> = f.body.iter().map(stringify).collect();
2959        // With squeeze: a, blank, b. Then padding.
2960        assert_eq!(&rows[0], "a");
2961        assert_eq!(&rows[1], "");
2962        assert_eq!(&rows[2], "b");
2963    }
2964
2965    #[test]
2966    fn header_pins_top_rows_when_scrolling() {
2967        // 12 lines, 6-row terminal → body_rows = 5. header=2 pins lines 0,1.
2968        let mut content = Vec::new();
2969        for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
2970        let (m, mut idx) = setup(&content);
2971        let mut v = Viewport::new(20, 6, "f".into());
2972        v.set_header(2, 0);
2973        // set_header floors top_line at header_lines, so we start showing
2974        // line2 in the scroll window. Scrolling down 5 advances by 5
2975        // logical lines from there.
2976        v.scroll_lines(5, &m, &mut idx);
2977        let f = v.frame(&m, &mut idx);
2978        let chs = |row: &Vec<Cell>| -> String {
2979            row.iter().filter_map(|c| match c {
2980                Cell::Char { ch, .. } => Some(*ch),
2981                _ => None,
2982            }).collect::<String>().trim().to_string()
2983        };
2984        // Rows 0 and 1 are the pinned header (line0, line1) regardless of scroll.
2985        assert_eq!(&chs(&f.body[0]), "line0");
2986        assert_eq!(&chs(&f.body[1]), "line1");
2987        // top_line is now 2 + 5 = 7; row 2 shows line7.
2988        assert_eq!(&chs(&f.body[2]), "line7");
2989    }
2990
2991    #[test]
2992    fn page_size_when_set_overrides_body_rows() {
2993        let mut content = Vec::new();
2994        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
2995        let (m, mut idx) = setup(&content);
2996        let mut v = Viewport::new(20, 10, "f".into());
2997        v.set_page_size(Some(3));
2998        let before = v.top_line();
2999        v.page_down(&m, &mut idx);
3000        assert_eq!(v.top_line(), before + 3);
3001        v.page_up(&m, &mut idx);
3002        assert_eq!(v.top_line(), before);
3003    }
3004
3005    #[test]
3006    fn page_size_unset_uses_body_rows() {
3007        let mut content = Vec::new();
3008        for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
3009        let (m, mut idx) = setup(&content);
3010        let mut v = Viewport::new(20, 10, "f".into());
3011        // body_rows = rows - 1 = 9.
3012        v.page_down(&m, &mut idx);
3013        assert_eq!(v.top_line(), 9);
3014    }
3015
3016    #[test]
3017    fn header_zero_lines_renders_like_no_header() {
3018        let mut content = Vec::new();
3019        for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
3020        let (m, mut idx) = setup(&content);
3021        let mut v = Viewport::new(20, 6, "f".into());
3022        v.set_header(0, 0);
3023        let f = v.frame(&m, &mut idx);
3024        let chs = |row: &Vec<Cell>| -> String {
3025            row.iter().filter_map(|c| match c {
3026                Cell::Char { ch, .. } => Some(*ch),
3027                _ => None,
3028            }).collect::<String>().trim().to_string()
3029        };
3030        assert_eq!(&chs(&f.body[0]), "line0");
3031        assert_eq!(&chs(&f.body[1]), "line1");
3032    }
3033
3034    #[test]
3035    fn squeeze_off_preserves_blanks() {
3036        let (m, mut idx) = setup(b"a\n\n\n\nb\n");
3037        let mut v = Viewport::new(10, 8, "f".into());
3038        // Default is off.
3039        let f = v.frame(&m, &mut idx);
3040        let stringify = |row: &Vec<Cell>| -> String {
3041            row.iter().filter_map(|c| match c {
3042                Cell::Char { ch, .. } => Some(*ch),
3043                _ => None,
3044            }).collect::<String>().trim().to_string()
3045        };
3046        let rows: Vec<String> = f.body.iter().map(stringify).collect();
3047        // Without squeeze: a, blank, blank, blank, b.
3048        assert_eq!(&rows[0], "a");
3049        assert_eq!(&rows[1], "");
3050        assert_eq!(&rows[2], "");
3051        assert_eq!(&rows[3], "");
3052        assert_eq!(&rows[4], "b");
3053    }
3054
3055    #[test]
3056    fn qae_second_resets_on_backward_motion() {
3057        let (m, mut idx) = setup(b"a\n");
3058        let mut v = Viewport::new(20, 5, "f".into());
3059        v.set_quit_at_eof(QuitAtEof::Second);
3060        v.goto_bottom(&m, &mut idx);
3061        assert!(!v.note_motion_for_eof(true, &m, &idx));
3062        // Backward motion clears the counter.
3063        v.note_motion_for_eof(false, &m, &idx);
3064        // Next forward starts fresh: counts, doesn't quit.
3065        assert!(!v.note_motion_for_eof(true, &m, &idx));
3066        // Now the second consecutive forward triggers quit.
3067        assert!(v.note_motion_for_eof(true, &m, &idx));
3068    }
3069
3070    #[test]
3071    fn flash_message_overrides_follow_suffix() {
3072        let (m, mut idx) = setup(b"a\nb\n");
3073        let mut v = Viewport::new(40, 5, "f".into());
3074        v.set_follow_mode(true);
3075        v.flash("(F reopened)", 3);
3076        let f = v.frame(&m, &mut idx);
3077        assert!(f.status.contains("(F reopened)"), "{}", f.status);
3078        assert!(!f.status.contains("(F idle)"));
3079    }
3080
3081    #[test]
3082    fn flash_countdown_clears() {
3083        let mut v = Viewport::new(10, 5, "f".into());
3084        v.flash("hello", 2);
3085        v.tick_flash();
3086        assert!(v.status_flash.is_some());
3087        v.tick_flash();
3088        assert!(v.status_flash.is_none());
3089    }
3090
3091    #[test]
3092    fn suspend_follow_if_off_is_noop() {
3093        let mut v = Viewport::new(10, 5, "f".into());
3094        v.set_follow_mode(true);
3095        v.suspend_follow_if(false);
3096        assert!(v.follow_mode());
3097    }
3098
3099    #[test]
3100    fn suspend_follow_if_on_flips_off() {
3101        let mut v = Viewport::new(10, 5, "f".into());
3102        v.set_follow_mode(true);
3103        v.suspend_follow_if(true);
3104        assert!(!v.follow_mode());
3105    }
3106
3107    #[test]
3108    fn case_mode_sensitive_returns_pattern_unchanged() {
3109        assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
3110        assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
3111    }
3112
3113    #[test]
3114    fn case_mode_insensitive_prepends_i_flag() {
3115        assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
3116        assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
3117    }
3118
3119    #[test]
3120    fn case_mode_smart_lowercase_is_insensitive() {
3121        assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
3122    }
3123
3124    #[test]
3125    fn case_mode_smart_with_uppercase_is_sensitive() {
3126        assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
3127        assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
3128    }
3129
3130    #[test]
3131    fn set_case_mode_recompiles_active_search() {
3132        let (m, mut idx) = setup(b"hello WORLD\n");
3133        let mut v = Viewport::new(40, 5, "f".into());
3134        v.set_search("world".into(), SearchDirection::Forward).unwrap();
3135        // Sensitive: no match for lowercase against WORLD.
3136        assert!(!v.search_repeat(&m, &mut idx, false));
3137        // Switch to insensitive — should re-compile and now match.
3138        v.set_case_mode(CaseMode::Insensitive);
3139        assert!(v.search_repeat(&m, &mut idx, false));
3140    }
3141
3142    #[test]
3143    fn status_shows_prettify_label_when_set() {
3144        let (m, mut idx) = setup(b"a\n");
3145        let mut v = Viewport::new(40, 5, "f".into());
3146        let frame_off = v.frame(&m, &mut idx);
3147        assert!(!frame_off.status.contains("[pretty"));
3148        v.set_prettify_label(Some("json".into()));
3149        let frame_on = v.frame(&m, &mut idx);
3150        assert!(frame_on.status.contains("[pretty:json]"),
3151            "expected [pretty:json] in status, got: {}", frame_on.status);
3152        v.set_prettify_label(Some("json:err".into()));
3153        let frame_err = v.frame(&m, &mut idx);
3154        assert!(frame_err.status.contains("[pretty:json:err]"),
3155            "expected [pretty:json:err] in status, got: {}", frame_err.status);
3156    }
3157
3158    #[test]
3159    fn status_shows_l_suffix_when_live_mode_on() {
3160        let (m, mut idx) = setup(b"a\nb\n");
3161        let mut v = Viewport::new(20, 5, "f".into());
3162        let frame_off = v.frame(&m, &mut idx);
3163        assert!(!frame_off.status.contains("(L)"));
3164        v.set_live_mode(true);
3165        let frame_on = v.frame(&m, &mut idx);
3166        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
3167    }
3168
3169    #[test]
3170    fn clamp_top_line_pulls_back_when_total_shrinks() {
3171        let mut v = Viewport::new(20, 5, "f".into());
3172        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
3173        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
3174        // Force top_line via a sequence; easiest: just call clamp directly.
3175        // We can't poke private state, but clamp works regardless of how we got there.
3176        v.clamp_top_line(100);  // total bigger than top_line=0, no change
3177        v.clamp_top_line(0);    // empty source: must reset
3178        // After clamp(0), line 0 is the floor.
3179        // (No public getter for top_line; we verify indirectly by going to top.)
3180        v.goto_top();
3181        // Just confirm no panic and no overflow on subsequent frame composition.
3182        let (m, mut idx) = setup(b"only\n");
3183        let _ = v.frame(&m, &mut idx);
3184    }
3185
3186    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
3187    /// when follow mode is on and the viewport is at the bottom.
3188    fn simulate_growth_tick(
3189        v: &mut Viewport,
3190        src: &MockSource,
3191        idx: &mut LineIndex,
3192    ) {
3193        if !v.follow_mode() { return; }
3194        let was_at_bottom = v.is_at_bottom(src, idx);
3195        let lines_before = idx.line_count();
3196        idx.notice_new_bytes(src);
3197        if idx.line_count() != lines_before && was_at_bottom {
3198            v.goto_bottom(src, idx);
3199        }
3200    }
3201
3202    #[test]
3203    fn auto_scroll_engages_when_at_bottom() {
3204        let m = MockSource::new();
3205        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
3206        let mut idx = LineIndex::new();
3207        let mut v = Viewport::new(10, 5, "f".into());
3208        v.set_follow_mode(true);
3209        idx.extend_to_end(&m);
3210        assert!(v.is_at_bottom(&m, &idx));
3211        let top_before = {
3212            let f = v.frame(&m, &mut idx);
3213            f.status.clone()  // unused, just exercise frame
3214        };
3215        let _ = top_before;
3216        // Simulate growth: source gains 4 more lines.
3217        m.append(b"5\n6\n7\n8\n");
3218        simulate_growth_tick(&mut v, &m, &mut idx);
3219        // After auto-scroll, top_line should have advanced so the new last line is in view.
3220        assert!(v.is_at_bottom(&m, &idx), "after auto-scroll, viewport should still be at bottom");
3221        let frame = v.frame(&m, &mut idx);
3222        // The bottom-most body row should now contain the last logical line ('8').
3223        // Find which row has '8'.
3224        let last_row = &frame.body[frame.body.len() - 1];
3225        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
3226    }
3227
3228    #[test]
3229    fn auto_scroll_suppressed_when_scrolled_up() {
3230        let m = MockSource::new();
3231        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
3232        let mut idx = LineIndex::new();
3233        let mut v = Viewport::new(10, 5, "f".into());  // body=4
3234        v.set_follow_mode(true);
3235        idx.extend_to_end(&m);
3236        v.goto_bottom(&m, &mut idx);
3237        // Now scroll up off the bottom.
3238        v.scroll_lines(-2, &m, &mut idx);
3239        assert!(!v.is_at_bottom(&m, &idx));
3240        let frame_before = v.frame(&m, &mut idx);
3241        let top_first_cell_before = frame_before.body[0][0].clone();
3242        // Simulate growth.
3243        m.append(b"9\n10\n");
3244        simulate_growth_tick(&mut v, &m, &mut idx);
3245        // Viewport should NOT have moved (auto-scroll suppressed).
3246        let frame_after = v.frame(&m, &mut idx);
3247        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
3248    }
3249
3250    // ----- Search -----
3251
3252    #[test]
3253    fn set_search_compiles_regex() {
3254        let mut v = Viewport::new(10, 5, "f".into());
3255        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
3256        assert!(v.search_active());
3257    }
3258
3259    #[test]
3260    fn set_search_rejects_bad_regex() {
3261        let mut v = Viewport::new(10, 5, "f".into());
3262        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
3263        assert!(!err.is_empty());
3264        assert!(!v.search_active(), "no search should be set on error");
3265    }
3266
3267    #[test]
3268    fn search_step_forward_finds_match_after_top() {
3269        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3270        let mut v = Viewport::new(20, 5, "f".into());
3271        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3272        let found = v.search_repeat(&m, &mut idx, false);
3273        assert!(found);
3274        // gamma is line 2 (0-indexed)
3275        assert_eq!(v.top_line, 2);
3276    }
3277
3278    #[test]
3279    fn search_step_backward_finds_match_before_top() {
3280        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
3281        let mut v = Viewport::new(20, 5, "f".into());
3282        v.scroll_lines(4, &m, &mut idx); // top_line = 4
3283        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
3284        let found = v.search_repeat(&m, &mut idx, false);
3285        assert!(found);
3286        assert_eq!(v.top_line, 0);
3287    }
3288
3289    #[test]
3290    fn search_wraps_at_end() {
3291        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3292        let mut v = Viewport::new(20, 5, "f".into());
3293        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
3294        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
3295        let found = v.search_repeat(&m, &mut idx, false);
3296        assert!(found, "search should wrap forward past EOF");
3297        assert_eq!(v.top_line, 0);
3298    }
3299
3300    #[test]
3301    fn search_no_match_returns_false_and_does_not_move() {
3302        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
3303        let mut v = Viewport::new(20, 5, "f".into());
3304        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
3305        let found = v.search_repeat(&m, &mut idx, false);
3306        assert!(!found);
3307        assert_eq!(v.top_line, 0);
3308    }
3309
3310    #[test]
3311    fn frame_records_highlight_ranges_for_matches() {
3312        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
3313        let mut v = Viewport::new(20, 5, "f".into());
3314        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
3315        let frame = v.frame(&m, &mut idx);
3316        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
3317        assert_eq!(frame.row_styles[0], RowStyle::Normal);
3318        assert!(frame.highlights[0].is_empty());
3319        assert!(frame.highlights[1].is_empty());
3320        assert_eq!(frame.highlights[2], vec![0..5]);
3321        assert!(frame.highlights[3].is_empty());
3322    }
3323
3324    #[test]
3325    fn frame_highlights_substring_inside_a_row() {
3326        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
3327        let mut v = Viewport::new(40, 5, "f".into());
3328        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3329        let frame = v.frame(&m, &mut idx);
3330        // "beta" starts at column 18 in the first row.
3331        assert_eq!(frame.highlights[0], vec![18..22]);
3332        assert!(frame.highlights[1].is_empty());
3333    }
3334
3335    #[test]
3336    fn search_highlight_with_filter_dim_keeps_row_dim() {
3337        // alpha matches filter → Normal. beta doesn't → Dim. Search for
3338        // "beta" should leave row style Dim and mark the substring 0..4.
3339        let (m, mut idx) = setup(b"alpha\nbeta\n");
3340        let mut v = Viewport::new(20, 5, "f".into());
3341        let fmt = crate::format::LogFormat::compile(
3342            "simple",
3343            r"^(?P<line>.+)$",
3344        )
3345        .unwrap();
3346        let f = crate::filter::CompiledFilter::compile(
3347            &fmt,
3348            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
3349            CaseMode::Sensitive,
3350        )
3351        .unwrap();
3352        v.set_filter(Some(f));
3353        v.set_dim_mode(true);
3354        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
3355        let frame = v.frame(&m, &mut idx);
3356        assert_eq!(frame.row_styles[0], RowStyle::Normal);
3357        assert_eq!(frame.row_styles[1], RowStyle::Dim);
3358        assert_eq!(frame.highlights[1], vec![0..4]);
3359    }
3360
3361    #[test]
3362    fn grep_only_hides_non_matching_lines() {
3363        use crate::grep::GrepPredicate;
3364        let src = crate::source::MockSource::new();
3365        src.append(b"keep this error\n");
3366        src.append(b"drop this one\n");
3367        src.append(b"another error line\n");
3368        src.finish();
3369        let mut idx = crate::line_index::LineIndex::new();
3370        idx.extend_to_end(&src);
3371
3372        let mut v = Viewport::new(40, 5, "test".into());
3373        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()], crate::viewport::CaseMode::Sensitive).unwrap()));
3374        v.extend_visible_lines(&idx, &src);
3375
3376        // Only the two "error" lines should be visible.
3377        let frame = v.frame(&src, &mut idx);
3378        let body_text: Vec<String> = frame.body.iter()
3379            .map(|row| row.iter().filter_map(|c| match c {
3380                crate::render::Cell::Char { ch, .. } => Some(*ch),
3381                _ => None,
3382            }).collect())
3383            .collect();
3384        assert!(body_text[0].contains("keep this error"));
3385        assert!(body_text[1].contains("another error line"));
3386        assert!(frame.status.contains("[grep]"));
3387    }
3388
3389    #[test]
3390    fn incsearch_preview_anchors_from_origin_not_previous_match() {
3391        // The valuable invariant: every preview restarts its scan from
3392        // `origin`, never from where the previous preview landed. We prove
3393        // this by previewing a far-below match, then previewing a second
3394        // pattern that has matches BOTH above and below the first match.
3395        // Anchoring from origin must pick the earlier (above) match; a scan
3396        // that continued forward from the previous match would pick the
3397        // later (below) one instead.
3398        let src = crate::source::MockSource::new();
3399        src.append(b"zero\n");    // line 0
3400        src.append(b"one\n");     // line 1
3401        src.append(b"origin\n");  // line 2 — the search origin (non-(0,0))
3402        src.append(b"three\n");   // line 3
3403        src.append(b"mark\n");    // line 4 — second pattern, ABOVE first match
3404        src.append(b"five\n");    // line 5
3405        src.append(b"six\n");     // line 6
3406        src.append(b"seven\n");   // line 7
3407        src.append(b"target\n");  // line 8 — first pattern's only match (below)
3408        src.append(b"mark\n");    // line 9 — second pattern, BELOW first match
3409        src.finish();
3410        let mut idx = crate::line_index::LineIndex::new();
3411
3412        let origin = (2usize, 0usize);
3413        let mut vp = Viewport::new(20, 4, "test".into()); // body = 3
3414        vp.set_top(origin.0, origin.1);
3415        assert_eq!(vp.top_line(), 2);
3416
3417        // Step 1: preview a match well below origin — jumps onto it.
3418        vp.incsearch_preview(&src, &mut idx, "target", SearchDirection::Forward, origin);
3419        assert_eq!(vp.top_line(), 8, "should land on the far-below match");
3420        assert_eq!(vp.top_row(), 0);
3421
3422        // Step 2: from the SAME origin, preview a pattern whose matches are at
3423        // line 4 (above the first match) and line 9 (below it). Because the
3424        // preview re-anchors at origin (line 2) before scanning forward, it
3425        // must land on line 4 — not line 9, which is what a scan continuing
3426        // forward from the previous match (line 8) would have found first.
3427        vp.incsearch_preview(&src, &mut idx, "mark", SearchDirection::Forward, origin);
3428        assert_eq!(
3429            vp.top_line(), 4,
3430            "preview must reset to origin before scanning, landing on the match \
3431             after origin rather than continuing forward from the previous match"
3432        );
3433        assert_eq!(vp.top_row(), 0);
3434    }
3435
3436    #[test]
3437    fn incsearch_preview_empty_or_invalid_is_noop() {
3438        let (src, mut idx) = setup(b"alpha\nbeta\n[unbalanced\n");
3439        let mut vp = Viewport::new(20, 4, "test".into());
3440        vp.set_top(1, 0);
3441        // Empty pattern: no-op, position unchanged.
3442        vp.incsearch_preview(&src, &mut idx, "", SearchDirection::Forward, (0, 0));
3443        assert_eq!(vp.top_line(), 1);
3444        // Invalid regex: silent, position reset to origin but no jump/panic.
3445        vp.incsearch_preview(&src, &mut idx, "(", SearchDirection::Forward, (0, 0));
3446        assert_eq!(vp.top_line(), 0);
3447    }
3448
3449    #[test]
3450    fn filter_and_grep_combine_with_and() {
3451        use crate::grep::GrepPredicate;
3452        let fmt = crate::format::LogFormat::compile(
3453            "simple",
3454            r"^(?P<level>\w+) (?P<msg>.+)$",
3455        ).unwrap();
3456        let f = crate::filter::CompiledFilter::compile(
3457            &fmt,
3458            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
3459            CaseMode::Sensitive,
3460        ).unwrap();
3461        let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3462
3463        let src = crate::source::MockSource::new();
3464        src.append(b"ERROR timeout connecting\n");      // matches both → keep
3465        src.append(b"ERROR file not found\n");          // matches filter only → drop
3466        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
3467        src.append(b"INFO all good\n");                 // matches neither → drop
3468        src.finish();
3469        let mut idx = crate::line_index::LineIndex::new();
3470        idx.extend_to_end(&src);
3471
3472        let mut v = Viewport::new(80, 5, "test".into());
3473        v.set_filter(Some(f));
3474        v.set_grep(Some(g));
3475        v.extend_visible_lines(&idx, &src);
3476        assert_eq!(v.visible_lines(), &[0usize]);
3477    }
3478
3479    #[test]
3480    fn search_status_shows_pattern() {
3481        let (m, mut idx) = setup(b"x\n");
3482        let mut v = Viewport::new(20, 5, "f".into());
3483        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3484        let frame = v.frame(&m, &mut idx);
3485        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
3486    }
3487
3488    #[test]
3489    fn repeat_search_after_first_match_advances() {
3490        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
3491        let mut v = Viewport::new(40, 5, "f".into());
3492        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3493        assert!(v.search_repeat(&m, &mut idx, false));
3494        assert_eq!(v.top_line, 1, "first foo");
3495        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
3496        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
3497        assert_eq!(v.top_line, 3, "should advance to next foo");
3498    }
3499
3500    #[test]
3501    fn auto_scroll_paused_when_follow_off() {
3502        let m = MockSource::new();
3503        m.append(b"1\n2\n3\n4\n");
3504        let mut idx = LineIndex::new();
3505        let mut v = Viewport::new(10, 5, "f".into());
3506        // Follow is off; viewport at top.
3507        idx.extend_to_end(&m);
3508        let frame_before = v.frame(&m, &mut idx);
3509        let top_first_cell = frame_before.body[0][0].clone();
3510        m.append(b"5\n6\n7\n8\n");
3511        simulate_growth_tick(&mut v, &m, &mut idx);
3512        let frame_after = v.frame(&m, &mut idx);
3513        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
3514    }
3515
3516    // ----- Records-mode search -----
3517
3518    #[test]
3519    fn search_jumps_to_next_matching_record() {
3520        let m = MockSource::new();
3521        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
3522        let mut idx = LineIndex::new();
3523        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3524        idx.extend_to_end(&m);
3525        let mut v = Viewport::new(40, 10, "f".into());
3526        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
3527        let hit = v.search_repeat(&m, &mut idx, false);
3528        assert!(hit, "should find 'charlie' in record 2");
3529        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
3530    }
3531
3532    #[test]
3533    fn search_finds_cross_line_match_in_record_with_s_flag() {
3534        let m = MockSource::new();
3535        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
3536        let mut idx = LineIndex::new();
3537        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3538        idx.extend_to_end(&m);
3539        let mut v = Viewport::new(40, 10, "f".into());
3540        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
3541        let hit = v.search_repeat(&m, &mut idx, false);
3542        assert!(hit, "should match across \\n inside record 0 with (?s)");
3543        assert_eq!(v.top_line(), 0);
3544    }
3545
3546    #[test]
3547    fn search_repeat_with_no_match_returns_false() {
3548        let m = MockSource::new();
3549        m.append(b"[1] alpha\n[2] bravo\n");
3550        let mut idx = LineIndex::new();
3551        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3552        idx.extend_to_end(&m);
3553        let mut v = Viewport::new(40, 10, "f".into());
3554        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
3555        let hit = v.search_repeat(&m, &mut idx, false);
3556        assert!(!hit);
3557    }
3558
3559    // ----- Records-mode filter/grep -----
3560
3561    #[test]
3562    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
3563        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
3564        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
3565        let m = MockSource::new();
3566        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
3567        let mut idx = LineIndex::new();
3568        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3569        idx.extend_to_end(&m);
3570        let grep = GrepPredicate::compile(&["cont a".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3571        let mut v = Viewport::new(40, 10, "f".into());
3572        v.set_grep(Some(grep));
3573        v.extend_visible_lines(&idx, &m);
3574        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
3575        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
3576        assert_eq!(v.visible_lines(), &[0usize, 1]);
3577    }
3578
3579    #[test]
3580    fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
3581        // The format regex is designed for the header line (it ends with `$`).
3582        // Applied to the full multi-line record bytes it would never match
3583        // because `$` doesn't match before a non-final `\n`. Records-mode
3584        // filter must evaluate against the first line of the record, then
3585        // include all of the record's lines when it matches.
3586        let m = MockSource::new();
3587        m.append(
3588            b"[1] kind=category\n  body a\n  body a2\n[2] kind=rule\n  body b\n",
3589        );
3590        let mut idx = LineIndex::new();
3591        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3592        idx.extend_to_end(&m);
3593        let fmt = crate::format::LogFormat::compile(
3594            "rec",
3595            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3596        )
3597        .unwrap();
3598        let f = crate::filter::CompiledFilter::compile(
3599            &fmt,
3600            vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
3601            CaseMode::Sensitive,
3602        )
3603        .unwrap();
3604        let mut v = Viewport::new(40, 10, "f".into());
3605        v.set_filter(Some(f));
3606        v.extend_visible_lines(&idx, &m);
3607        // Record 0 (lines 0, 1, 2) matches; record 1 (lines 3, 4) does not.
3608        assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
3609    }
3610
3611    #[test]
3612    fn grep_matches_across_record_newlines_in_records_mode() {
3613        // Pattern spans the record-header and a continuation line (needs (?s) for .).
3614        let m = MockSource::new();
3615        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
3616        let mut idx = LineIndex::new();
3617        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3618        idx.extend_to_end(&m);
3619        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()], crate::viewport::CaseMode::Sensitive).unwrap();
3620        let mut v = Viewport::new(40, 10, "f".into());
3621        v.set_grep(Some(grep));
3622        v.extend_visible_lines(&idx, &m);
3623        // Record 0 matches (cross-line); record 1 does not.
3624        assert_eq!(v.visible_lines(), &[0usize, 1]);
3625    }
3626
3627    #[test]
3628    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
3629        // All 4 lines stay in visible_lines (dim mode = no hiding).
3630        // Record 0 matches grep → Normal; record 1 does not → Dim.
3631        let m = MockSource::new();
3632        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
3633        let mut idx = LineIndex::new();
3634        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3635        idx.extend_to_end(&m);
3636        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()], CaseMode::Sensitive).unwrap();
3637        let mut v = Viewport::new(40, 10, "f".into());
3638        v.set_grep(Some(grep));
3639        v.set_dim_mode(true);
3640        v.extend_visible_lines(&idx, &m);
3641        // Dim mode: visible_lines stays empty (hide_mode() is false).
3642        assert_eq!(v.visible_lines(), &[] as &[usize]);
3643        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
3644        assert!(!v.should_dim_line(0, &idx, &m));
3645        assert!(!v.should_dim_line(1, &idx, &m));
3646        // Lines 2 and 3 belong to non-matching record → Dim.
3647        assert!(v.should_dim_line(2, &idx, &m));
3648        assert!(v.should_dim_line(3, &idx, &m));
3649    }
3650
3651    #[test]
3652    fn status_unchanged_when_records_inactive() {
3653        let (m, mut idx) = setup(b"a\nb\nc\n");
3654        let mut v = Viewport::new(20, 5, "f".into());
3655        let frame = v.frame(&m, &mut idx);
3656        let status = &frame.status;
3657        // Default format: <label>  <top>-<bot>/<total>  <pct>%
3658        assert!(status.contains("1-3/3"), "got: {status}");
3659        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
3660        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
3661    }
3662
3663    #[test]
3664    fn status_r_block_uses_real_lines_in_hide_mode() {
3665        // Regression: in hide mode `bottom` is a position in visible_lines
3666        // (i.e. a count of *visible* matches), not a logical line index.
3667        // The R-block was passing that position into `line_to_record`, which
3668        // resolved to whatever record contained logical line `bottom-1` —
3669        // typically a very early record, producing nonsense like `R290-8`
3670        // where the bottom record is *before* the top record on screen.
3671        // Build a scenario: many records, only the last few match the filter,
3672        // and the viewport is scrolled to the matching tail.
3673        let m = MockSource::new();
3674        // 10 records, two physical lines each. Record N's header has `kind=A`
3675        // for N < 8 and `kind=B` for N >= 8 (so only records 8 and 9 match).
3676        let mut buf = Vec::new();
3677        for n in 0..10 {
3678            let kind = if n >= 8 { "B" } else { "A" };
3679            buf.extend_from_slice(format!("[{}] kind={}\n  body {}\n", n, kind, n).as_bytes());
3680        }
3681        m.append(&buf);
3682        m.finish();
3683
3684        let mut idx = LineIndex::new();
3685        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3686        idx.extend_to_end(&m);
3687
3688        let fmt = crate::format::LogFormat::compile(
3689            "rec",
3690            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
3691        )
3692        .unwrap();
3693        let f = crate::filter::CompiledFilter::compile(
3694            &fmt,
3695            vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
3696            CaseMode::Sensitive,
3697        )
3698        .unwrap();
3699
3700        // 5-row terminal: 4 body rows + 1 status row. With 4 visible-matches
3701        // rows of body and 4 visible lines, the whole filtered set fits.
3702        let mut v = Viewport::new(80, 5, "f".into());
3703        v.set_filter(Some(f));
3704        v.extend_visible_lines(&idx, &m);
3705
3706        // Jump to the first matching record (record 8, 0-indexed).
3707        v.goto_record(8, &m, &mut idx);
3708
3709        let frame = v.frame(&m, &mut idx);
3710        // Records 8 (rec_top=9) and 9 (rec_bottom=10) are on screen.
3711        assert!(
3712            frame.status.contains("R9-10/10"),
3713            "expected R9-10/10 in status, got: {}",
3714            frame.status,
3715        );
3716    }
3717
3718    #[test]
3719    fn status_dual_readout_when_records_active() {
3720        let m = MockSource::new();
3721        m.append(b"[1] a\n  cont\n[2] b\n");
3722        m.finish();
3723        let mut idx = LineIndex::new();
3724        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
3725        idx.extend_to_end(&m);
3726        let mut v = Viewport::new(20, 5, "f".into());
3727        let frame = v.frame(&m, &mut idx);
3728        let status = &frame.status;
3729        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
3730        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
3731    }
3732
3733    #[test]
3734    fn format_status_uses_custom_template_when_set() {
3735        let m = MockSource::new();
3736        m.append(b"a\nb\nc\n");
3737        m.finish();
3738        let mut idx = LineIndex::new();
3739        idx.extend_to_end(&m);
3740        let mut v = Viewport::new(20, 5, "f".into());
3741        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
3742        v.set_prompt(Some(prompt));
3743        let frame = v.frame(&m, &mut idx);
3744        assert_eq!(frame.status, "f 100%");
3745    }
3746
3747    #[test]
3748    fn status_shows_preprocess_failed_tag_when_set() {
3749        let m = MockSource::new();
3750        m.append(b"a\n");
3751        let mut idx = LineIndex::new();
3752        idx.extend_to_end(&m);
3753        let mut v = Viewport::new(40, 5, "f".into());
3754        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
3755        let frame = v.frame(&m, &mut idx);
3756        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
3757                "got: {}", frame.status);
3758    }
3759
3760    #[test]
3761    fn default_status_includes_help_hint() {
3762        let (m, mut idx) = setup(b"a\nb\nc\n");
3763        let mut v = Viewport::new(80, 5, "f".into());
3764        let frame = v.frame(&m, &mut idx);
3765        assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
3766    }
3767
3768    #[test]
3769    fn custom_prompt_does_not_get_help_hint() {
3770        let (m, mut idx) = setup(b"a\nb\nc\n");
3771        let mut v = Viewport::new(80, 5, "f".into());
3772        v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
3773        let frame = v.frame(&m, &mut idx);
3774        assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
3775    }
3776
3777    #[test]
3778    fn status_shows_file_index_when_multifile() {
3779        let m = MockSource::new();
3780        m.append(b"a\n");
3781        let mut idx = LineIndex::new();
3782        idx.extend_to_end(&m);
3783        let mut v = Viewport::new(60, 5, "f.log".into());
3784        v.set_file_index(0, 3);
3785        let frame = v.frame(&m, &mut idx);
3786        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
3787    }
3788
3789    #[test]
3790    fn status_omits_file_index_when_single_file() {
3791        let m = MockSource::new();
3792        m.append(b"a\n");
3793        let mut idx = LineIndex::new();
3794        idx.extend_to_end(&m);
3795        let mut v = Viewport::new(60, 5, "f.log".into());
3796        v.set_file_index(0, 1);
3797        let frame = v.frame(&m, &mut idx);
3798        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
3799    }
3800
3801    #[test]
3802    fn status_shows_tag_active_when_multimatch() {
3803        let m = MockSource::new();
3804        m.append(b"a\n");
3805        let mut idx = LineIndex::new();
3806        idx.extend_to_end(&m);
3807        let mut v = Viewport::new(80, 5, "f.log".into());
3808        v.set_tag_active(Some(("foo".into(), 2, 3)));
3809        let frame = v.frame(&m, &mut idx);
3810        assert!(
3811            frame.status.contains("[tag: foo (2/3)]"),
3812            "got: {}",
3813            frame.status
3814        );
3815    }
3816
3817    #[test]
3818    fn status_omits_tag_active_when_single_match() {
3819        let m = MockSource::new();
3820        m.append(b"a\n");
3821        let mut idx = LineIndex::new();
3822        idx.extend_to_end(&m);
3823        let mut v = Viewport::new(80, 5, "f.log".into());
3824        v.set_tag_active(Some(("foo".into(), 1, 1)));
3825        let frame = v.frame(&m, &mut idx);
3826        assert!(
3827            !frame.status.contains("[tag:"),
3828            "should not show indicator for single match: {}",
3829            frame.status
3830        );
3831    }
3832
3833    #[test]
3834    fn hscroll_noop_when_wrapping() {
3835        let mut v = Viewport::new(80, 24, "t".into());
3836        // default is wrap ON → hscroll inactive
3837        v.hscroll_right_step();
3838        assert_eq!(v.left_col(), 0);
3839    }
3840
3841    #[test]
3842    fn hscroll_active_in_chop_and_clamps_at_zero() {
3843        let mut v = Viewport::new(80, 24, "t".into());
3844        v.toggle_chop(); // turn chop ON (wrap off)
3845        assert!(v.hscroll_active());
3846        v.hscroll_right_step();
3847        assert_eq!(v.left_col(), 8);
3848        v.hscroll_right_half();
3849        assert_eq!(v.left_col(), 8 + 40); // cols/2 = 40
3850        v.hscroll_left_half();
3851        assert_eq!(v.left_col(), 8);
3852        v.hscroll_left_half();
3853        assert_eq!(v.left_col(), 0); // clamps at 0
3854    }
3855
3856    #[test]
3857    fn hscroll_by_explicit_cols_moves_left_col() {
3858        // `--shift N` path: scroll right/left by an explicit column count.
3859        let mut v = Viewport::new(80, 24, "t".into());
3860        v.toggle_chop(); // chop on so hscroll is active
3861        v.hscroll_right_cols(12);
3862        assert_eq!(v.left_col(), 12);
3863        v.hscroll_right_cols(12);
3864        assert_eq!(v.left_col(), 24);
3865        v.hscroll_left_cols(12);
3866        assert_eq!(v.left_col(), 12);
3867        v.hscroll_left_cols(99);
3868        assert_eq!(v.left_col(), 0); // clamps at 0
3869    }
3870
3871    #[test]
3872    fn hscroll_resets_to_zero_when_wrap_turned_on() {
3873        let mut v = Viewport::new(80, 24, "t".into());
3874        v.toggle_chop();             // chop on
3875        v.hscroll_right_step();
3876        assert_eq!(v.left_col(), 8);
3877        v.toggle_chop();             // wrap back on → reset
3878        assert_eq!(v.left_col(), 0);
3879    }
3880
3881    #[test]
3882    fn reset_hscroll_zeroes_left_col() {
3883        // The new-file path (app::switch_file) calls this after goto_top.
3884        let mut v = Viewport::new(80, 24, "t".into());
3885        v.toggle_chop();
3886        v.hscroll_right_step();
3887        assert_eq!(v.left_col(), 8);
3888        v.reset_hscroll();
3889        assert_eq!(v.left_col(), 0);
3890    }
3891
3892    // ----- SGR state reconstruction tests -----
3893
3894    #[test]
3895    fn reconstruct_picks_up_state_from_prior_lines() {
3896        let m = MockSource::new();
3897        m.append(b"\x1b[31mline 1\n");
3898        m.append(b"line 2 (still red, no reset)\n");
3899        m.append(b"line 3\n");
3900        let mut idx = LineIndex::new();
3901        idx.extend_to_end(&m);
3902        let state = reconstruct_render_state(&m, &idx, 2);
3903        assert_eq!(
3904            state.style.fg,
3905            Some(crate::ansi::Color::Ansi(1)),
3906            "red SGR from line 0 should persist to line 2"
3907        );
3908    }
3909
3910    #[test]
3911    fn reconstruct_respects_reset_between_lines() {
3912        let m = MockSource::new();
3913        m.append(b"\x1b[31mline 1\x1b[0m\n");
3914        m.append(b"line 2 (default)\n");
3915        let mut idx = LineIndex::new();
3916        idx.extend_to_end(&m);
3917        let state = reconstruct_render_state(&m, &idx, 1);
3918        assert_eq!(state.style.fg, None);
3919    }
3920
3921    #[test]
3922    fn reconstruct_caps_walkback_at_max_lines() {
3923        let m = MockSource::new();
3924        m.append(b"\x1b[31mvery early\n");
3925        for _ in 0..300 {
3926            m.append(b"line\n");
3927        }
3928        let mut idx = LineIndex::new();
3929        idx.extend_to_end(&m);
3930        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
3931        // anchor we'd pick is line 34 (290 - 256), which is past the red.
3932        let state = reconstruct_render_state(&m, &idx, 290);
3933        assert_eq!(state.style.fg, None);
3934    }
3935
3936    #[test]
3937    fn or_groups_narrow_within_required_line_mode() {
3938        let mut raw = crate::or::OrSpecRaw::new();
3939        raw.add_grep(crate::or::DEFAULT_GROUP, "failed".into());
3940        raw.add_grep(crate::or::DEFAULT_GROUP, "denied".into());
3941        let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3942        let mut v = Viewport::new(80, 24, "t".into());
3943        v.set_or_groups(og);
3944        assert!(v.or_active());
3945        assert!(v.line_passes(b"login failed"));
3946        assert!(v.line_passes(b"access denied"));
3947        assert!(!v.line_passes(b"login ok"));
3948    }
3949
3950    #[test]
3951    fn status_shows_or_indicator_when_active() {
3952        let mut raw = crate::or::OrSpecRaw::new();
3953        raw.add_grep(crate::or::DEFAULT_GROUP, "x".into());
3954        let og = crate::or::OrGroups::compile(&raw, None, crate::viewport::CaseMode::Sensitive).unwrap();
3955        let (m, mut idx) = setup(b"x\ny\nx\n");
3956        idx.extend_to_end(&m);
3957        let mut v = Viewport::new(80, 5, "f".into());
3958        v.set_or_groups(og);
3959        v.extend_visible_lines(&idx, &m);
3960        let status = v.format_status(&idx, &m);
3961        assert!(status.contains("[or]"), "expected [or] in status: {status}");
3962        assert!(status.contains("[hide]"), "expected [hide] in status: {status}");
3963    }
3964
3965    #[test]
3966    fn status_shows_col_offset_when_scrolled() {
3967        // Long single line wider than the viewport; chop mode + scroll right.
3968        let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz\n";
3969        let (m, mut idx) = setup(content);
3970        let mut v = Viewport::new(10, 3, "t".into());
3971        v.toggle_chop();          // chop mode (wrap off) — hscroll active
3972        v.hscroll_right_step();   // left_col = HSCROLL_STEP (8)
3973        let f = v.frame(&m, &mut idx);
3974        assert!(
3975            f.status.contains('\u{00bb}'),
3976            "expected » in status after hscroll_right_step, got: {}",
3977            f.status
3978        );
3979    }
3980
3981    #[test]
3982    fn frame_text_horizontal_scroll_shifts_and_marks_left_edge() {
3983        // A single long line "ABCDEFGHIJKLMNOPQRSTUVWXYZ..." wider than cols,
3984        // in chop mode with a small viewport. Tests:
3985        //   1) At left_col==0: first body cell is 'A', no '<' marker.
3986        //   2) After hscroll_right_step(): first text-region cell is '<' (left
3987        //      marker), and a subsequent cell shows a char that was off-screen
3988        //      at offset 0 (proves the content shifted).
3989        // HSCROLL_STEP = 8, so after one step left_col == 8. Cell 0 becomes '<'
3990        // and cell 1 should be 'J' (0-indexed display column 9: A=0 … I=8, J=9).
3991        let content = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
3992        let (m, mut idx) = setup(content);
3993
3994        // 10 cols wide, 3 rows (body = 2). Default is wrap=true, so toggle chop.
3995        let mut v = Viewport::new(10, 3, "t".into());
3996        v.toggle_chop(); // chop mode = !wrap
3997
3998        // ---- pass 1: no horizontal scroll ----
3999        let frame0 = v.frame(&m, &mut idx);
4000        assert_eq!(
4001            frame0.body[0][0],
4002            Cell::Char { ch: 'A', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4003            "at left_col=0 first cell should be 'A'"
4004        );
4005        // No '<' marker at all
4006        assert!(
4007            !frame0.body[0].iter().any(|c| matches!(c, Cell::Char { ch: '<', .. })),
4008            "no left marker expected at left_col=0"
4009        );
4010
4011        // ---- pass 2: scroll right by one step (HSCROLL_STEP=8 columns) ----
4012        v.hscroll_right_step();
4013        assert_eq!(v.left_col(), 8, "left_col should be 8 after one right step");
4014
4015        let frame1 = v.frame(&m, &mut idx);
4016        // First content cell (col 0, no gutter) should be the '<' marker.
4017        assert_eq!(
4018            frame1.body[0][0],
4019            Cell::Char { ch: '<', width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None },
4020            "after scrolling right, first cell should be the '<' left marker"
4021        );
4022        // The cell at index 1 (display col 1 of the content region) was at
4023        // absolute column 9 = left_col(8) + 1. In "ABCDEFGHIJKL..." the char
4024        // at display column 9 (0-indexed) is 'J'. Verify content shifted.
4025        assert_eq!(
4026            frame1.body[0][1],
4027            Cell::Char { ch: 'J', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
4028            "second cell should be 'J' (display column left_col+1 = 9)"
4029        );
4030    }
4031}