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