Skip to main content

tess/
viewport.rs

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