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