Skip to main content

tess/
viewport.rs

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