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