Skip to main content

tess/
viewport.rs

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