Skip to main content

tess/
viewport.rs

1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::line_index::LineIndex;
8use crate::render::{count_rows, render_line, Cell, RenderOpts};
9use crate::source::Source;
10
11/// Maximum number of lines to walk backwards when reconstructing SGR state
12/// for a scroll-up. Picked to comfortably cover a screen-height plus
13/// headroom; bounds cost so that scrolling in huge files stays snappy.
14const MAX_RECONSTRUCT_LINES: usize = 256;
15
16/// Reconstruct the SGR state at the start of `target_line` by walking up
17/// to MAX_RECONSTRUCT_LINES lines back and replaying byte-by-byte through
18/// the ANSI parser. Lines beyond the cap are skipped: if there's an
19/// unclosed SGR more than 256 lines above the top, the reconstruction starts
20/// from default — first visible lines may render in default colors until a
21/// reset appears (rare for normal log files).
22fn reconstruct_render_state(
23    src: &dyn Source,
24    idx: &crate::line_index::LineIndex,
25    target_line: usize,
26) -> crate::render::RenderState {
27    let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
28    let mut state = crate::render::RenderState::default();
29    for line_no in start..target_line {
30        let range = idx.line_range(line_no, src);
31        let raw = src.bytes(range);
32        for &b in raw.as_ref() {
33            let _ = crate::ansi::step(
34                &mut state.parse,
35                &mut state.style,
36                &mut state.hyperlink,
37                b,
38            );
39        }
40    }
41    state
42}
43
44/// Build the rendered text of a display row plus a `starts` table mapping
45/// each char index in that text back to its starting cell column. The last
46/// entry is a sentinel pointing one past the row's width, so a match's
47/// `[char_start, char_end)` translates to the cell range
48/// `starts[char_start]..starts[char_end]`.
49fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
50    let mut text = String::new();
51    let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
52    for (col, cell) in row.iter().enumerate() {
53        match cell {
54            Cell::Char { ch, .. } => {
55                starts.push(col);
56                text.push(*ch);
57            }
58            Cell::Empty => {
59                starts.push(col);
60                text.push(' ');
61            }
62            Cell::Continuation => {}
63        }
64    }
65    starts.push(row.len());
66    (text, starts)
67}
68
69/// Find every regex match in the rendered text of a row, translating each
70/// to a cell column range. Empty matches are dropped. Trailing-padding
71/// spaces on a row would otherwise satisfy patterns like `\s+`; we trim
72/// those by clamping match ends to where actual content stops.
73fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
74    if row.is_empty() {
75        return Vec::new();
76    }
77    let last_content_col = row
78        .iter()
79        .enumerate()
80        .rev()
81        .find_map(|(c, cell)| match cell {
82            Cell::Char { width, .. } => Some(c + *width as usize),
83            Cell::Continuation => Some(c + 1),
84            Cell::Empty => None,
85        })
86        .unwrap_or(0);
87    if last_content_col == 0 {
88        return Vec::new();
89    }
90    let (text, starts) = row_text_and_starts(row);
91    let mut out = Vec::new();
92    for m in regex.find_iter(&text) {
93        if m.start() == m.end() {
94            continue;
95        }
96        let char_start = text[..m.start()].chars().count();
97        let char_end = text[..m.end()].chars().count();
98        if char_start >= starts.len() - 1 || char_end <= char_start {
99            continue;
100        }
101        let col_start = starts[char_start];
102        let col_end = starts[char_end].min(last_content_col);
103        if col_end > col_start {
104            out.push(col_start..col_end);
105        }
106    }
107    out
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum RowStyle {
112    Normal,
113    /// Render with a reduced-emphasis terminal attribute. Used by `--dim` to
114    /// keep filtered-out lines visible as context.
115    Dim,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SearchDirection {
120    Forward,
121    Backward,
122}
123
124#[derive(Debug, Clone)]
125pub struct SearchState {
126    pub raw: String,
127    pub regex: Regex,
128    pub direction: SearchDirection,
129}
130
131#[derive(Debug, Clone)]
132pub struct Frame {
133    pub body: Vec<Vec<Cell>>,        // exactly (rows-1) entries
134    pub row_styles: Vec<RowStyle>,   // parallel to body
135    /// Per-row column ranges to render with reverse-video. Used by `/`
136    /// search to highlight just the matched phrase rather than the whole row.
137    /// Indexed parallel to `body`; each inner Vec holds column ranges in
138    /// `[start, end)` form (cell columns).
139    pub highlights: Vec<Vec<std::ops::Range<usize>>>,
140    pub status: String,
141}
142
143pub struct Viewport {
144    top_line: usize,
145    top_row: usize,
146    cols: u16,
147    rows: u16,
148    pub opts: RenderOpts,
149    pub show_line_numbers: bool,
150    pub source_label: String,
151    follow_mode: bool,
152    live_mode: bool,
153    prettify_label: Option<String>,
154    format_label: Option<String>,
155    filter: Option<CompiledFilter>,
156    grep: Option<GrepPredicate>,
157    dim_mode: bool,
158    /// In hide mode (filter active, !dim), maps visible position → logical line
159    /// index. Empty otherwise.
160    visible_lines: Vec<usize>,
161    /// How many logical lines we've evaluated for filter membership. Used by
162    /// `extend_visible_lines` to avoid re-scanning lines on every tick.
163    visible_scanned: usize,
164    search: Option<SearchState>,
165    /// Active display template + format regex. When set, lines are rendered
166    /// through the template before being shown, searched, or counted for wraps.
167    /// Filtering still operates on the raw line (it uses captures, not text).
168    display: Option<crate::format::DisplayRenderer>,
169    hex_mode: bool,
170    /// Bytes per hex group in `--hex` mode. One of 1, 2, 4, 8, 16.
171    /// Default 2 (matches the historical `xxd` 2-byte / 4-char grouping).
172    hex_group_size: usize,
173    /// Custom status-line prompt template. When set, replaces the built-in
174    /// format_status output with the template rendered against PromptContext.
175    prompt: Option<crate::prompt::ParsedPrompt>,
176    /// Error message from a failed preprocessor run. When set, surfaces
177    /// a `[preprocess-failed: ...]` tag in the status line.
178    preprocess_failure: Option<String>,
179    /// When `count > 1`, status line shows `<label>  [current+1/count]`.
180    file_index: Option<(usize, usize)>,
181    /// When set, status line and prompt context include `[tag: <name> (N/M)]`.
182    tag_active: Option<(String, usize, usize)>,  // (name, cursor+1, total)
183    /// ANSI interpretation mode, resolved from --no-color / -r / env at startup.
184    ansi_mode: crate::render::AnsiMode,
185    /// Cached SGR/hyperlink state at the start of `render_state_for`.
186    /// Invalidated when top_line changes or source grows; reconstructed
187    /// by walking up to MAX_RECONSTRUCT_LINES lines back.
188    render_state: crate::render::RenderState,
189    /// Line number that `render_state` matches the start of. Sentinel
190    /// `usize::MAX` means "invalid, must reconstruct".
191    render_state_for: usize,
192}
193
194impl Viewport {
195    pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
196        let opts = RenderOpts { cols, ..RenderOpts::default() };
197        Self {
198            top_line: 0,
199            top_row: 0,
200            cols,
201            rows,
202            opts,
203            show_line_numbers: false,
204            source_label,
205            follow_mode: false,
206            live_mode: false,
207            prettify_label: None,
208            format_label: None,
209            filter: None,
210            grep: None,
211            dim_mode: false,
212            visible_lines: Vec::new(),
213            visible_scanned: 0,
214            search: None,
215            display: None,
216            hex_mode: false,
217            hex_group_size: 2,
218            prompt: None,
219            preprocess_failure: None,
220            file_index: None,
221            tag_active: None,
222            ansi_mode: crate::render::AnsiMode::Strict,
223            render_state: crate::render::RenderState::default(),
224            render_state_for: usize::MAX,
225        }
226    }
227
228    pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
229        self.display = renderer;
230    }
231
232    pub fn set_hex_mode(&mut self, on: bool) {
233        self.hex_mode = on;
234    }
235
236    /// Returns whether `--hex` rendering is active.
237    pub fn hex_mode(&self) -> bool {
238        self.hex_mode
239    }
240
241    /// Set bytes-per-group for `--hex` rendering. Accepts 1, 2, 4, 8, or 16.
242    /// Invalid values are ignored.
243    pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
244        if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
245            self.hex_group_size = bytes_per_group;
246        }
247    }
248
249    /// Current bytes-per-group for `--hex` rendering.
250    pub fn hex_group_size(&self) -> usize {
251        self.hex_group_size
252    }
253
254    pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
255        self.prompt = prompt;
256    }
257
258    pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
259        self.preprocess_failure = msg;
260    }
261
262    pub fn set_file_index(&mut self, current: usize, total: usize) {
263        self.file_index = if total > 1 {
264            Some((current, total))
265        } else {
266            None
267        };
268    }
269
270    pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
271        self.tag_active = info;
272    }
273
274    pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
275        self.ansi_mode = mode;
276    }
277
278    pub fn set_source_label(&mut self, label: String) {
279        self.source_label = label;
280    }
281
282    pub fn source_label_clone(&self) -> String {
283        self.source_label.clone()
284    }
285
286    /// Fetch a logical line's display bytes — rendered through the active
287    /// display template if one is set and the line parses against the format
288    /// regex, otherwise the raw bytes. Used everywhere the *visible* form of
289    /// the line matters: rendering, search, wrap-row counting.
290    fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
291        let range = idx.line_range(line_n, src);
292        let raw = src.bytes(range);
293        if let Some(r) = self.display.as_ref() {
294            if let Some(rendered) = r.render_line(&raw) {
295                return std::borrow::Cow::Owned(rendered.into_bytes());
296            }
297        }
298        raw
299    }
300
301    /// Compile and store a search pattern. Returns the parse error from the
302    /// regex crate if the pattern is invalid; the previous search (if any)
303    /// is preserved on error.
304    pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
305        let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
306        self.search = Some(SearchState { raw, regex, direction });
307        Ok(())
308    }
309
310    pub fn clear_search(&mut self) { self.search = None; }
311
312    pub fn search_active(&self) -> bool { self.search.is_some() }
313
314    pub fn search_direction(&self) -> SearchDirection {
315        self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
316    }
317
318    /// Jump to the next match of the active search, in `direction` (or its
319    /// reverse if `reverse` is true). Wraps at the end of the source.
320    /// Returns true iff a match was found and the viewport moved.
321    pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
322        if idx.records_mode() {
323            self.search_repeat_records(src, idx, reverse)
324        } else {
325            self.search_repeat_lines(src, idx, reverse)
326        }
327    }
328
329    /// Line-mode search: unchanged original logic.
330    fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
331        let Some(s) = self.search.as_ref() else { return false; };
332        let forward = matches!(
333            (s.direction, reverse),
334            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
335        );
336        idx.extend_to_end(src);
337        let pattern = s.regex.clone();
338        if self.hide_mode() {
339            self.extend_visible_lines(idx, src);
340            self.search_step_in_visible(&pattern, src, idx, forward)
341        } else {
342            self.search_step_in_logical(&pattern, src, idx, forward)
343        }
344    }
345
346    /// Records-mode search: iterate records, match against UTF-8-lossy decoded
347    /// record bytes (which may contain embedded `\n`s), and jump the viewport
348    /// to the first line of the matching record.
349    fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
350        let Some(s) = self.search.as_ref() else { return false; };
351        let forward = matches!(
352            (s.direction, reverse),
353            (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
354        );
355        let pattern = s.regex.clone();
356        idx.extend_to_end(src);
357
358        let total = idx.record_count();
359        if total == 0 { return false; }
360
361        let cur_record = idx.line_to_record(self.top_line);
362
363        let range: Box<dyn Iterator<Item = usize>> = if forward {
364            Box::new(((cur_record + 1)..total).chain(0..=cur_record))
365        } else {
366            let earlier: Vec<usize> = (0..cur_record).rev().collect();
367            let later: Vec<usize> = (cur_record..total).rev().collect();
368            Box::new(earlier.into_iter().chain(later))
369        };
370
371        for r in range {
372            let bytes = idx.record_bytes_stripped(r, src);
373            let text = String::from_utf8_lossy(&bytes);
374            if pattern.is_match(&text) {
375                let line_range = idx.record_line_range(r);
376                self.top_line = line_range.start;
377                self.top_row = 0;
378                return true;
379            }
380        }
381        false
382    }
383
384    fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
385        // Search runs against the *displayed* bytes so what the user sees is
386        // what they can find. With a template active, that's the rendered form;
387        // otherwise the raw line. ANSI color sequences are stripped so that
388        // `/error` finds a red `error` regardless of escape codes.
389        let display = self.line_display_bytes(src, idx, line_n);
390        let bytes = crate::ansi::strip_sgr(&display);
391        match std::str::from_utf8(&bytes) {
392            Ok(s) => pattern.is_match(s),
393            Err(_) => false,
394        }
395    }
396
397    fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
398        let total = idx.line_count();
399        if total == 0 { return false; }
400        let start = self.top_line;
401        // Walk every logical line once, starting from start+1 (or start-1)
402        // and wrapping at the end / beginning.
403        for offset in 1..=total {
404            let line_n = if forward {
405                (start + offset) % total
406            } else {
407                (start + total - offset) % total
408            };
409            if self.line_matches(pattern, src, idx, line_n) {
410                self.top_line = line_n;
411                self.top_row = 0;
412                return true;
413            }
414        }
415        false
416    }
417
418    fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
419        let total = self.visible_lines.len();
420        if total == 0 { return false; }
421        // Find current visible position for top_line.
422        let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
423        for offset in 1..=total {
424            let visible_idx = if forward {
425                (cur + offset) % total
426            } else {
427                (cur + total - offset) % total
428            };
429            let line_n = self.visible_lines[visible_idx];
430            if self.line_matches(pattern, src, idx, line_n) {
431                self.top_line = line_n;
432                self.top_row = 0;
433                return true;
434            }
435        }
436        false
437    }
438
439    pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
440        self.filter = filter;
441        self.visible_lines.clear();
442        self.visible_scanned = 0;
443        // Drop scroll state — line numbering may have changed under us.
444        self.top_line = 0;
445        self.top_row = 0;
446    }
447
448    pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
449        self.grep = grep;
450        self.visible_lines.clear();
451        self.visible_scanned = 0;
452        self.top_line = 0;
453        self.top_row = 0;
454    }
455
456    pub fn grep_active(&self) -> bool { self.grep.is_some() }
457
458    pub fn set_dim_mode(&mut self, on: bool) {
459        self.dim_mode = on;
460        // Hide mode is the only mode that needs visible_lines; clear when
461        // turning dim ON, and re-derive from scratch when turning dim OFF
462        // (next extend_visible_lines call rebuilds it).
463        self.visible_lines.clear();
464        self.visible_scanned = 0;
465    }
466
467    pub fn filter_active(&self) -> bool { self.filter.is_some() }
468
469    pub fn dim_mode(&self) -> bool { self.dim_mode }
470
471    fn hide_mode(&self) -> bool {
472        (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
473    }
474
475    /// Walk any newly indexed logical lines and append matching ones to
476    /// `visible_lines` if we're in hide mode. No-op otherwise. Cheap to call
477    /// every loop tick — keeps a `visible_scanned` cursor (line mode only;
478    /// records mode rebuilds from scratch each call).
479    pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
480        if !self.hide_mode() {
481            return;
482        }
483        if idx.records_mode() {
484            self.extend_visible_lines_records(idx, src);
485        } else {
486            self.extend_visible_lines_per_line(idx, src);
487        }
488    }
489
490    /// Line-mode: incrementally append newly indexed matching lines.
491    fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
492        let total = idx.line_count();
493        while self.visible_scanned < total {
494            let line_n = self.visible_scanned;
495            let bytes = idx.line_bytes_stripped(line_n, src);
496            if self.line_passes(&bytes) {
497                self.visible_lines.push(line_n);
498            }
499            self.visible_scanned += 1;
500        }
501    }
502
503    /// Records-mode: evaluate predicates once per record on the full record
504    /// bytes (which include embedded `\n`s). All physical lines of a matching
505    /// record are pushed to `visible_lines`; non-matching records are dropped
506    /// entirely (hide mode). Rebuilds from scratch on each call — O(records)
507    /// per frame but acceptable for current workloads; avoids the complexity
508    /// of tracking a records-scanned cursor alongside `visible_scanned`.
509    fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
510        self.visible_lines.clear();
511        self.visible_scanned = 0; // not used by records path; reset for clarity
512        let total_records = idx.record_count();
513        for r in 0..total_records {
514            if self.record_passes(idx, src, r) {
515                for line_n in idx.record_line_range(r) {
516                    self.visible_lines.push(line_n);
517                }
518            }
519        }
520    }
521
522    /// Combined predicate: bytes pass iff the (optional) filter matches AND
523    /// the (optional) grep matches. Missing predicates vacuously pass.
524    /// `bytes` is always a single logical line — records-mode callers go
525    /// through `record_passes` instead because the two predicates have
526    /// different granularity (filter = header line, grep = whole record).
527    fn line_passes(&self, line: &[u8]) -> bool {
528        let filter_ok = match self.filter.as_ref() {
529            Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
530            None => true,
531        };
532        let grep_ok = match self.grep.as_ref() {
533            Some(g) => g.matches(line),
534            None => true,
535        };
536        filter_ok && grep_ok
537    }
538
539    /// Records-mode predicate. Both filter and grep are evaluated against
540    /// the full multi-line record bytes. Filter uses the format regex with
541    /// dotall + multi-line semantics so greedy captures like
542    /// `(?P<message>.*)$` span the whole record body — `--filter
543    /// message~foo` matches when `foo` appears anywhere in the record, not
544    /// only on the header. Grep matches anywhere in the record bytes too,
545    /// so `(?s)foo.*bar` keeps working across continuation lines.
546    fn record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
547        let bytes = if self.filter.is_some() || self.grep.is_some() {
548            Some(idx.record_bytes_stripped(r, src))
549        } else {
550            None
551        };
552        let filter_ok = match self.filter.as_ref() {
553            Some(f) => matches!(
554                f.evaluate_record(bytes.as_deref().unwrap()),
555                FilterMatch::Matched,
556            ),
557            None => true,
558        };
559        let grep_ok = match self.grep.as_ref() {
560            Some(g) => g.matches(bytes.as_deref().unwrap()),
561            None => true,
562        };
563        filter_ok && grep_ok
564    }
565
566    /// Return true iff line `line_n` should be rendered dim. In records mode,
567    /// the match decision is made once per record and applied to all its
568    /// physical lines. In line mode, the decision is made per line.
569    fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
570        if !self.dim_mode {
571            return false;
572        }
573        if idx.records_mode() {
574            let r = idx.line_to_record(line_n);
575            !self.record_passes(idx, src, r)
576        } else {
577            let bytes = idx.line_bytes_stripped(line_n, src);
578            !self.line_passes(&bytes)
579        }
580    }
581
582    /// Logical line index of the *last* row drawn in the body, given the
583    /// current `top_line` and `body_rows`. In line mode this is just
584    /// `top_line + body_rows - 1` clamped to the indexed line count. In hide
585    /// mode it's the logical line that sits at the bottom of the visible
586    /// slice — i.e. `visible_lines[cur + body_rows - 1]`. Always returns a
587    /// value `>= self.top_line`, so callers passing it to `line_to_record`
588    /// never get a "bottom record < top record" inversion.
589    fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
590        let body_rows = self.body_rows() as usize;
591        if self.hide_mode() && !self.visible_lines.is_empty() {
592            let cur = self
593                .visible_lines
594                .iter()
595                .position(|&l| l >= self.top_line)
596                .unwrap_or(self.visible_lines.len().saturating_sub(1));
597            let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
598            return self.visible_lines[last_pos];
599        }
600        let total = idx.line_count();
601        if total == 0 {
602            return self.top_line;
603        }
604        (self.top_line + body_rows.saturating_sub(1)).min(total - 1)
605    }
606
607    pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
608
609    pub fn follow_mode(&self) -> bool { self.follow_mode }
610
611    pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
612
613    pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
614
615    pub fn live_mode(&self) -> bool { self.live_mode }
616
617    pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
618
619    /// Status-line label for active pretty-print state, e.g. `"json"` or
620    /// `"json:err"`. `None` means no indicator is shown.
621    pub fn set_prettify_label(&mut self, label: Option<String>) {
622        self.prettify_label = label;
623    }
624
625    /// Active --format name shown in <format-tag>. Set from main when a named
626    /// format is resolved; independent of whether --filter is also active.
627    pub fn set_format_label(&mut self, label: Option<String>) {
628        self.format_label = label;
629    }
630
631    /// Drop the per-line filter-membership cache without disturbing the filter
632    /// itself or scroll position. Used after a `--live` rebuild: line numbering
633    /// may have changed, so cached `visible_lines` is stale, but we want to
634    /// keep the same filter applied and let the user stay where they were.
635    pub fn invalidate_filter_cache(&mut self) {
636        self.visible_lines.clear();
637        self.visible_scanned = 0;
638    }
639
640    /// Clamp `top_line` so it doesn't fall past the new end of the source.
641    /// Pairs with `invalidate_filter_cache` after a content rewrite.
642    pub fn clamp_top_line(&mut self, line_count: usize) {
643        if line_count == 0 {
644            self.top_line = 0;
645            self.top_row = 0;
646        } else if self.top_line >= line_count {
647            self.top_line = line_count - 1;
648            self.top_row = 0;
649        }
650    }
651
652    /// True when the viewport's body window already covers the last line of
653    /// the source. New content added past this point should auto-scroll if
654    /// follow mode is on.
655    pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
656        let body = self.body_rows() as usize;
657        if self.hide_mode() {
658            // top_line is a logical line; find its position in visible_lines.
659            let pos = self
660                .visible_lines
661                .iter()
662                .position(|&l| l >= self.top_line)
663                .unwrap_or(self.visible_lines.len());
664            pos + body >= self.visible_lines.len()
665        } else {
666            self.top_line + body >= idx.line_count()
667        }
668    }
669
670    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
671    fn gutter_width(&self, idx: &LineIndex) -> u16 {
672        if !self.show_line_numbers { return 0; }
673        let n = idx.line_count().max(1);
674        let digits = (n as f64).log10().floor() as u16 + 1;
675        digits + 1
676    }
677
678    fn render_opts(&self, gutter: u16) -> RenderOpts {
679        let mut o = self.opts.clone();
680        o.cols = self.cols.saturating_sub(gutter);
681        o.mode = self.ansi_mode;
682        o
683    }
684
685    pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
686        if self.hex_mode {
687            return self.frame_hex(src);
688        }
689        let body_rows = self.body_rows() as usize;
690        idx.extend_to_line(self.top_line + body_rows + 1, src);
691
692        let gutter = self.gutter_width(idx);
693        let r_opts = self.render_opts(gutter);
694
695        // Reconstruct per-line SGR state for the start of the visible window so
696        // that unclosed SGR sequences on lines above top_line carry through.
697        // Only meaningful in Interpret mode; harmless (and cheap) to skip otherwise.
698        let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
699            reconstruct_render_state(src, idx, self.top_line)
700        } else {
701            crate::render::RenderState::default()
702        };
703        // Store in the struct field for future cache use; mark current top_line.
704        self.render_state = render_state.clone();
705        self.render_state_for = self.top_line;
706
707        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
708        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
709        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
710        // In hide mode we walk visible_lines; otherwise we walk logical lines.
711        let hide = self.hide_mode();
712        let total_lines = idx.line_count();
713
714        // For hide mode, find where the viewport starts in visible_lines.
715        let mut hide_pos = if hide {
716            self.visible_lines
717                .iter()
718                .position(|&l| l >= self.top_line)
719                .unwrap_or(self.visible_lines.len())
720        } else {
721            0
722        };
723        let mut line_n = if hide {
724            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
725        } else {
726            self.top_line
727        };
728        let mut skip = if hide { 0 } else { self.top_row };
729
730        while body.len() < body_rows {
731            if line_n >= total_lines {
732                let mut row = Vec::with_capacity(self.cols as usize);
733                if gutter > 0 {
734                    for _ in 0..gutter { row.push(Cell::Empty); }
735                }
736                while row.len() < self.cols as usize { row.push(Cell::Empty); }
737                body.push(row);
738                row_styles.push(RowStyle::Normal);
739                highlights.push(Vec::new());
740                line_n += 1;
741                continue;
742            }
743            // Filter evaluation runs on the raw line (it uses captures, not
744            // text), but rendering goes through the template if one is set.
745            let raw = src.bytes(idx.line_range(line_n, src));
746            let display_bytes = if let Some(r) = self.display.as_ref() {
747                match r.render_line(&raw) {
748                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
749                    None => raw.clone(),
750                }
751            } else {
752                raw.clone()
753            };
754            let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
755                Some(&mut render_state)
756            } else {
757                None
758            };
759            let rows = render_line(&display_bytes, &r_opts, state_arg);
760            let style = if self.filter.is_some() || self.grep.is_some() {
761                if self.dim_mode {
762                    if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
763                } else {
764                    // hide mode: only matching lines reach here
765                    RowStyle::Normal
766                }
767            } else {
768                RowStyle::Normal
769            };
770
771            for (i, mut content_row) in rows.into_iter().enumerate() {
772                if i < skip { continue; }
773                if body.len() >= body_rows { break; }
774                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
775                if gutter > 0 {
776                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
777                    for c in label.chars() {
778                        full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
779                    }
780                }
781                full.append(&mut content_row);
782                // Compute search highlights for this display row by running
783                // the regex against the row's rendered text. Each match's
784                // char range maps to a cell column range via `starts`.
785                let row_highlights = if let Some(s) = self.search.as_ref() {
786                    find_row_highlights(&full, &s.regex)
787                } else {
788                    Vec::new()
789                };
790                body.push(full);
791                row_styles.push(style);
792                highlights.push(row_highlights);
793            }
794            skip = 0;
795            // Advance to next line — visible-space if hiding, logical-space otherwise.
796            if hide {
797                hide_pos += 1;
798                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
799            } else {
800                line_n += 1;
801            }
802        }
803
804        // After walking through the frame, render_state has been advanced past
805        // top_line. Invalidate the cached sentinel so next frame re-reconstructs.
806        self.render_state_for = usize::MAX;
807
808        let status = self.format_status(idx, src);
809        Frame { body, row_styles, highlights, status }
810    }
811
812    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
813        if let Some(p) = self.prompt.as_ref() {
814            let ctx = self.build_prompt_context(idx, src);
815            return p.render(&ctx);
816        }
817        let body_rows = self.body_rows() as usize;
818        let total = idx.line_count();
819        // In hide mode, the line range and percentage refer to visible (matched)
820        // lines, not the underlying logical line count.
821        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
822            let visible_total = self.visible_lines.len();
823            // top_line is a logical line; find its visible index.
824            let cur = self
825                .visible_lines
826                .iter()
827                .position(|&l| l >= self.top_line)
828                .unwrap_or(visible_total);
829            let top = cur + 1;
830            let bottom = (cur + body_rows).min(visible_total.max(1));
831            let total_str = if src.is_complete() {
832                format!("{visible_total}/{total}")
833            } else {
834                format!("{visible_total}/{total}+")
835            };
836            (top, bottom, visible_total, total_str)
837        } else {
838            let top = self.top_line + 1;
839            let bottom = (self.top_line + body_rows).min(total.max(1));
840            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
841            (top, bottom, total, total_str)
842        };
843        let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
844        // In records mode, prefix line numbers with 'L' and append an 'R' record block.
845        // The R block always refers to logical lines on screen, which in hide
846        // mode is *not* the same as `bottom` (which counts visible matches).
847        let bottom_line = self.bottom_visible_line(idx);
848        let (line_prefix, records_block) = if idx.records_mode() {
849            let line_total = idx.line_count();
850            let rec_total = idx.record_count();
851            let rec_block = if line_total == 0 || rec_total == 0 {
852                format!("R0-0/{}", rec_total)
853            } else {
854                let rec_top = idx.line_to_record(self.top_line) + 1;
855                let rec_bottom = idx.line_to_record(bottom_line) + 1;
856                let (rec_top, rec_bottom) = if rec_bottom < rec_top {
857                    // Defensive: should be unreachable given `bottom_visible_line`
858                    // is always `>= self.top_line`, but guard against future
859                    // regressions producing nonsense like `R290-8/...`.
860                    (rec_top, rec_top)
861                } else {
862                    (rec_top, rec_bottom)
863                };
864                format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
865            };
866            ("L", Some(rec_block))
867        } else {
868            ("", None)
869        };
870        let middle = match records_block {
871            Some(ref rb) => format!("{}{}-{}/{}  {}  {}%", line_prefix, top, bottom, total_str, rb, pct),
872            None         => format!("{}-{}/{}  {}%", top, bottom, total_str, pct),
873        };
874        let label_with_index = match self.file_index {
875            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
876            None => self.source_label.clone(),
877        };
878        let mut s = format!("{}  {}", label_with_index, middle);
879        // Wrap-row offset: when scrolled inside a long wrapping line, surface
880        // the offset so the user knows scrolling is happening at sub-line
881        // granularity. Without this the line range above stays static while
882        // pressing `j` and the scroll is invisible on repeating content.
883        if !self.hide_mode() && self.top_row > 0 {
884            let line_rows = if total > 0 {
885                let bytes = self.line_display_bytes(src, idx, self.top_line);
886                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
887            } else { 1 };
888            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
889        }
890        if let Some(f) = self.filter.as_ref() {
891            s.push_str(&format!("  [{}]", f.format_name));
892        }
893        if self.grep.is_some() {
894            s.push_str("  [grep]");
895        }
896        if self.filter.is_some() || self.grep.is_some() {
897            s.push_str(if self.dim_mode { "  [dim]" } else { "  [hide]" });
898        }
899        if let Some(sr) = self.search.as_ref() {
900            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
901            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
902        }
903        if let Some(label) = self.prettify_label.as_ref() {
904            s.push_str(&format!("  [pretty:{label}]"));
905        }
906        if self.live_mode { s.push_str("  (L)"); }
907        if self.follow_mode { s.push_str("  (F)"); }
908        if let Some(msg) = self.preprocess_failure.as_ref() {
909            let first_line = msg.lines().next().unwrap_or("");
910            s.push_str(&format!("  [preprocess-failed: {}]", first_line));
911        }
912        let tag_suffix = match &self.tag_active {
913            Some((name, cur, total)) if *total > 1 => {
914                format!("  [tag: {name} ({cur}/{total})]")
915            }
916            _ => String::new(),
917        };
918        s.push_str(&tag_suffix);
919        // Right-aligned :help hint. If the existing status already overshoots
920        // the width, no pad — the renderer will clip on draw.
921        let used = s.chars().count();
922        let hint = ":help";
923        if (self.cols as usize) > used + 1 + hint.chars().count() {
924            let pad = self.cols as usize - used - hint.chars().count();
925            s.push_str(&" ".repeat(pad));
926            s.push_str(hint);
927        } else {
928            s.push(' ');
929            s.push_str(hint);
930        }
931        s
932    }
933
934    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
935        use crate::prompt::PromptContext;
936
937        let body_rows = self.body_rows() as usize;
938        let total = idx.line_count();
939        let top = self.top_line + 1;
940        let bottom = (self.top_line + body_rows).min(total.max(1));
941        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
942        let bottom_line = self.bottom_visible_line(idx);
943
944        let records_mode = idx.records_mode();
945        let (rec_top, rec_bottom, rec_total) = if records_mode {
946            let rt = idx.line_to_record(self.top_line) + 1;
947            let rb_raw = idx.line_to_record(bottom_line) + 1;
948            let rb = if rb_raw < rt { rt } else { rb_raw };
949            (rt, rb, idx.record_count())
950        } else {
951            (0, 0, 0)
952        };
953
954        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
955            let line_rows = if total > 0 {
956                let bytes = self.line_display_bytes(src, idx, self.top_line);
957                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
958            } else { 1 };
959            format!("+{}/{}", self.top_row, line_rows)
960        } else {
961            String::new()
962        };
963
964        let format_tag = self.format_label.as_ref()
965            .map(|n| format!("  [{}]", n))
966            .unwrap_or_default();
967        let filter_tag = self.filter.as_ref()
968            .map(|f| format!("  [{}]", f.format_name))
969            .unwrap_or_default();
970        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
971        let hide_tag = if self.filter.is_some() || self.grep.is_some() {
972            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
973        } else {
974            String::new()
975        };
976        let search_tag = self.search.as_ref()
977            .map(|s| {
978                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
979                format!("  [{}{}]", p, s.raw)
980            })
981            .unwrap_or_default();
982        let pretty_tag = self.prettify_label.as_ref()
983            .map(|l| format!("  [pretty:{l}]"))
984            .unwrap_or_default();
985        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
986        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
987        let preprocess_failed_tag = self.preprocess_failure.as_ref()
988            .map(|msg| {
989                let first_line = msg.lines().next().unwrap_or("");
990                format!("  [preprocess-failed: {}]", first_line)
991            })
992            .unwrap_or_default();
993
994        let file_index_tag = match self.file_index {
995            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
996            None => String::new(),
997        };
998
999        let tag_tag = match &self.tag_active {
1000            Some((name, cur, total)) if *total > 1 => {
1001                format!("  [tag: {name} ({cur}/{total})]")
1002            }
1003            _ => String::new(),
1004        };
1005
1006        PromptContext {
1007            label: self.source_label.clone(),
1008            top,
1009            bottom,
1010            total,
1011            pct: pct.min(100) as u8,
1012            rec_top,
1013            rec_bottom,
1014            rec_total,
1015            records_mode,
1016            wrap_offset,
1017            format_tag,
1018            filter_tag,
1019            grep_tag,
1020            hide_tag,
1021            search_tag,
1022            pretty_tag,
1023            live_tag,
1024            follow_tag,
1025            preprocess_failed_tag,
1026            file_index_tag,
1027            tag_tag,
1028        }
1029    }
1030
1031    fn frame_hex(&self, src: &dyn Source) -> Frame {
1032        use crate::hex::format_hex_row;
1033        use crate::render::{render_line, Cell, RenderOpts};
1034
1035        let body_rows = self.rows.saturating_sub(1) as usize;
1036        let total_bytes = src.len();
1037        let total_hex_rows = total_bytes.div_ceil(16);
1038
1039        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1040        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1041        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1042
1043        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
1044
1045        for row_idx in 0..body_rows {
1046            let hex_row = self.top_line + row_idx;
1047            if hex_row >= total_hex_rows {
1048                body.push(vec![Cell::Empty; self.cols as usize]);
1049            } else {
1050                let offset = hex_row * 16;
1051                let end = (offset + 16).min(total_bytes);
1052                let bytes_cow = src.bytes(offset..end);
1053                let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
1054                let rows = render_line(text.as_bytes(), &opts, None);
1055                body.push(rows.into_iter().next().unwrap_or_else(|| {
1056                    vec![Cell::Empty; self.cols as usize]
1057                }));
1058            }
1059            row_styles.push(RowStyle::Normal);
1060            highlights.push(Vec::new());
1061        }
1062
1063        let status = self.format_status_hex(src);
1064        Frame { body, row_styles, highlights, status }
1065    }
1066
1067    fn format_status_hex(&self, src: &dyn Source) -> String {
1068        let total_bytes = src.len();
1069        let body_rows = self.rows.saturating_sub(1) as usize;
1070        // Byte offset of the first visible byte (start of the top hex row).
1071        let top_byte = self.top_line * 16;
1072        // Byte offset just past the last visible byte. Clamped to total_bytes
1073        // so we never show a value past EOF.
1074        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1075        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1076        let label_with_index = match self.file_index {
1077            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1078            None => self.source_label.clone(),
1079        };
1080        let tag_suffix = match &self.tag_active {
1081            Some((name, cur, total)) if *total > 1 => {
1082                format!("  [tag: {name} ({cur}/{total})]")
1083            }
1084            _ => String::new(),
1085        };
1086        format!(
1087            "{}  off {}-{}/{}  {}%  [hex]{}",
1088            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1089        )
1090    }
1091
1092    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
1093    /// reset to 0 so the start of the destination line is at the top of
1094    /// the viewport. In hide mode this is equivalent to `scroll_lines`
1095    /// (which already moves by visible/logical lines).
1096    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1097        if delta == 0 { return; }
1098        if self.hide_mode() {
1099            self.scroll_lines(delta, src, idx);
1100            return;
1101        }
1102        if delta > 0 {
1103            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1104            let total = idx.line_count();
1105            if total == 0 { return; }
1106            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1107            self.top_line = target;
1108            self.top_row = 0;
1109        } else {
1110            let back = (-delta) as usize;
1111            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1112            // the start of the current line; only the remaining count goes to
1113            // previous lines. This matches the user's mental model of "jump
1114            // to the start of the previous line".
1115            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1116            let extra_back = back.saturating_sub(consumed_for_snap);
1117            self.top_line = self.top_line.saturating_sub(extra_back);
1118            self.top_row = 0;
1119        }
1120    }
1121
1122    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1123        if delta == 0 { return; }
1124        if self.hide_mode() {
1125            // Scroll by visible (matching) lines. We don't honor wrap rows in
1126            // hide mode — top_row stays 0. Each unit of `delta` advances or
1127            // retreats one visible line.
1128            self.extend_visible_lines(idx, src);
1129            let total = self.visible_lines.len();
1130            if total == 0 {
1131                self.top_line = 0;
1132                self.top_row = 0;
1133                return;
1134            }
1135            let cur = self
1136                .visible_lines
1137                .iter()
1138                .position(|&l| l >= self.top_line)
1139                .unwrap_or(total);
1140            let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1141            self.top_line = self.visible_lines[new];
1142            self.top_row = 0;
1143            return;
1144        }
1145        if delta > 0 {
1146            let mut remaining = delta as usize;
1147            while remaining > 0 {
1148                idx.extend_to_line(self.top_line + 1, src);
1149                let total = idx.line_count();
1150                if total == 0 { break; }
1151                let bytes = self.line_display_bytes(src, idx, self.top_line);
1152                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1153                if self.top_row + 1 < line_rows {
1154                    self.top_row += 1;
1155                } else if self.top_line + 1 < total {
1156                    self.top_row = 0;
1157                    self.top_line += 1;
1158                } else {
1159                    break;
1160                }
1161                remaining -= 1;
1162            }
1163        } else {
1164            let mut remaining = (-delta) as usize;
1165            while remaining > 0 {
1166                if self.top_row > 0 {
1167                    self.top_row -= 1;
1168                } else if self.top_line > 0 {
1169                    self.top_line -= 1;
1170                    let bytes = self.line_display_bytes(src, idx, self.top_line);
1171                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1172                    self.top_row = line_rows.saturating_sub(1);
1173                } else {
1174                    break;
1175                }
1176                remaining -= 1;
1177            }
1178        }
1179    }
1180
1181    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1182        let n = self.body_rows() as i64;
1183        self.scroll_lines(n, src, idx);
1184    }
1185
1186    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1187        let n = self.body_rows() as i64;
1188        self.scroll_lines(-n, src, idx);
1189    }
1190
1191    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1192        let n = (self.body_rows() / 2).max(1) as i64;
1193        self.scroll_lines(n, src, idx);
1194    }
1195
1196    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1197        let n = (self.body_rows() / 2).max(1) as i64;
1198        self.scroll_lines(-n, src, idx);
1199    }
1200
1201    pub fn goto_top(&mut self) {
1202        self.top_line = 0;
1203        self.top_row = 0;
1204    }
1205
1206    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1207        idx.extend_to_end(src);
1208        let body = self.body_rows() as usize;
1209        if self.hide_mode() {
1210            self.extend_visible_lines(idx, src);
1211            let total = self.visible_lines.len();
1212            let target_visible = total.saturating_sub(body);
1213            self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1214            self.top_row = 0;
1215        } else {
1216            let total = idx.line_count();
1217            self.top_line = total.saturating_sub(body);
1218            self.top_row = 0;
1219        }
1220    }
1221
1222    /// Position the viewport so line `n` (0-indexed) is the top visible line.
1223    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1224        idx.extend_to_line(n, src);
1225        let target = n.min(idx.line_count().saturating_sub(1));
1226        self.top_line = target;
1227        self.top_row = 0;
1228    }
1229
1230    /// Position the viewport at the start of record `n` (0-indexed).
1231    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1232        // Ensure the record exists by extending the index. Records can only
1233        // appear after their constituent lines are scanned; extend repeatedly
1234        // until the record exists or we hit EOF.
1235        while idx.record_count() <= n && idx.scanned_through() < src.len() {
1236            idx.extend_to_end(src);
1237        }
1238        if idx.record_count() == 0 {
1239            return;
1240        }
1241        let target = n.min(idx.record_count().saturating_sub(1));
1242        let line_range = idx.record_line_range(target);
1243        self.top_line = line_range.start;
1244        self.top_row = 0;
1245    }
1246
1247    /// Position the viewport at `p` percent through the file by bytes.
1248    /// `p` is clamped to 0..=100. p=100 lands at the last line.
1249    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1250        let p = p.min(100) as usize;
1251        let target_byte = src.len().saturating_mul(p) / 100;
1252        idx.extend_to_byte_for_query(src, target_byte);
1253        let line_n = idx.line_at_byte(target_byte)
1254            .or_else(|| {
1255                // target_byte at or past EOF: fall through to the last line.
1256                let lc = idx.line_count();
1257                if lc > 0 { Some(lc - 1) } else { None }
1258            })
1259            .unwrap_or(0);
1260        self.top_line = line_n;
1261        self.top_row = 0;
1262    }
1263
1264    /// Get the currently top-displayed physical line index.
1265    pub fn top_line(&self) -> usize {
1266        self.top_line
1267    }
1268
1269    pub fn resize(&mut self, cols: u16, rows: u16) {
1270        self.cols = cols.max(1);
1271        self.rows = rows.max(2);
1272        self.opts.cols = self.cols;
1273    }
1274
1275    pub fn toggle_line_numbers(&mut self) {
1276        self.show_line_numbers = !self.show_line_numbers;
1277    }
1278
1279    pub fn toggle_chop(&mut self) {
1280        self.opts.wrap = !self.opts.wrap;
1281    }
1282
1283    /// Return the current set of visible (matched) line indices. Non-empty only
1284    /// in hide mode (filter or grep active without --dim). Stable public accessor
1285    /// so integration tests and external tooling can inspect filter results.
1286    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292    use crate::source::MockSource;
1293
1294    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1295        let m = MockSource::new();
1296        m.append(content);
1297        m.finish();
1298        let idx = LineIndex::new();
1299        (m, idx)
1300    }
1301
1302    #[test]
1303    fn frame_renders_body_height_rows() {
1304        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1305        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
1306        let frame = v.frame(&m, &mut idx);
1307        assert_eq!(frame.body.len(), 4);
1308        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1309        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1310    }
1311
1312    #[test]
1313    fn scroll_down_advances_top_line() {
1314        let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1315        let mut v = Viewport::new(10, 5, "test".into());
1316        v.scroll_lines(2, &m, &mut idx);
1317        assert_eq!(v.top_line, 2);
1318        assert_eq!(v.top_row, 0);
1319    }
1320
1321    #[test]
1322    fn scroll_up_clamps_at_zero() {
1323        let (m, mut idx) = setup(b"a\nb\nc\n");
1324        let mut v = Viewport::new(10, 5, "test".into());
1325        v.scroll_lines(-5, &m, &mut idx);
1326        assert_eq!(v.top_line, 0);
1327        assert_eq!(v.top_row, 0);
1328    }
1329
1330    #[test]
1331    fn scroll_down_clamps_at_last_line() {
1332        let (m, mut idx) = setup(b"a\nb\nc\n");
1333        let mut v = Viewport::new(10, 5, "test".into());
1334        v.scroll_lines(50, &m, &mut idx);
1335        assert_eq!(v.top_line, 2);
1336    }
1337
1338    #[test]
1339    fn scroll_logical_lines_skips_wrap_rows() {
1340        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
1341        let mut content = vec![b'X'; 500];
1342        content.push(b'\n');
1343        content.extend_from_slice(b"second\n");
1344        content.extend_from_slice(b"third\n");
1345        let (m, mut idx) = setup(&content);
1346        let mut v = Viewport::new(10, 8, "f".into());
1347        v.scroll_logical_lines(1, &m, &mut idx);
1348        assert_eq!((v.top_line, v.top_row), (1, 0));
1349        v.scroll_logical_lines(1, &m, &mut idx);
1350        assert_eq!((v.top_line, v.top_row), (2, 0));
1351    }
1352
1353    #[test]
1354    fn scroll_logical_lines_back_snaps_to_line_start() {
1355        // Mid-wrap K should snap to start of current line first, then go back.
1356        let mut content = vec![b'A'; 50];
1357        content.push(b'\n');
1358        content.extend_from_slice(&[b'B'; 50]);
1359        content.push(b'\n');
1360        let (m, mut idx) = setup(&content);
1361        let mut v = Viewport::new(10, 8, "f".into());
1362        v.scroll_lines(7, &m, &mut idx);
1363        assert_eq!(v.top_line, 1, "should be on line 1");
1364        assert!(v.top_row > 0, "should be inside line 1's wraps");
1365        v.scroll_logical_lines(-1, &m, &mut idx);
1366        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1367        v.scroll_logical_lines(-1, &m, &mut idx);
1368        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1369    }
1370
1371    #[test]
1372    fn scroll_down_walks_wraps_of_last_line() {
1373        // Last line is 30 chars in a 10-col viewport → 3 wrap rows.
1374        let mut content = b"first\n".to_vec();
1375        content.extend_from_slice(&[b'X'; 30]);
1376        content.push(b'\n');
1377        let (m, mut idx) = setup(&content);
1378        let mut v = Viewport::new(10, 5, "f".into());
1379        v.scroll_lines(1, &m, &mut idx);
1380        assert_eq!((v.top_line, v.top_row), (1, 0));
1381        v.scroll_lines(1, &m, &mut idx);
1382        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1383        v.scroll_lines(1, &m, &mut idx);
1384        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1385    }
1386
1387    #[test]
1388    fn scroll_down_walks_wrap_rows_within_long_line() {
1389        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
1390        let mut content = vec![b'X'; 30];
1391        content.push(b'\n');
1392        content.extend_from_slice(b"second\n");
1393        let (m, mut idx) = setup(&content);
1394        let mut v = Viewport::new(10, 5, "f".into());
1395        v.scroll_lines(1, &m, &mut idx);
1396        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1397        v.scroll_lines(1, &m, &mut idx);
1398        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1399        v.scroll_lines(1, &m, &mut idx);
1400        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1401    }
1402
1403    #[test]
1404    fn status_line_shows_range_and_pct() {
1405        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1406        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
1407        let frame = v.frame(&m, &mut idx);
1408        assert!(frame.status.starts_with("f  1-4/10"));
1409    }
1410
1411    #[test]
1412    fn page_down_advances_by_body_rows() {
1413        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1414        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1415        v.page_down(&m, &mut idx);
1416        assert_eq!(v.top_line, 4);
1417    }
1418
1419    #[test]
1420    fn page_up_then_page_down_returns_to_start_when_no_resize() {
1421        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1422        let mut v = Viewport::new(10, 5, "f".into());
1423        v.page_down(&m, &mut idx);
1424        v.page_up(&m, &mut idx);
1425        assert_eq!(v.top_line, 0);
1426        assert_eq!(v.top_row, 0);
1427    }
1428
1429    #[test]
1430    fn half_page_down_advances_by_half_body() {
1431        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1432        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
1433        v.half_page_down(&m, &mut idx);
1434        assert_eq!(v.top_line, 3);
1435    }
1436
1437    #[test]
1438    fn goto_top_resets_position() {
1439        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1440        let mut v = Viewport::new(10, 5, "f".into());
1441        v.scroll_lines(2, &m, &mut idx);
1442        v.goto_top();
1443        assert_eq!(v.top_line, 0);
1444        assert_eq!(v.top_row, 0);
1445    }
1446
1447    #[test]
1448    fn goto_bottom_scrolls_to_last_page() {
1449        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1450        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1451        v.goto_bottom(&m, &mut idx);
1452        // Last page should show lines 7..=10 → top_line = 6.
1453        assert_eq!(v.top_line, 6);
1454    }
1455
1456    #[test]
1457    fn goto_line_positions_top_line() {
1458        let m = MockSource::new();
1459        m.append(b"a\nb\nc\nd\ne\n");
1460        let mut idx = LineIndex::new();
1461        idx.extend_to_end(&m);
1462        let mut v = Viewport::new(20, 5, "f".into());
1463        v.goto_line(3, &m, &mut idx);
1464        assert_eq!(v.top_line(), 3);
1465    }
1466
1467    #[test]
1468    fn goto_line_clamps_to_last_line() {
1469        let m = MockSource::new();
1470        m.append(b"a\nb\n");
1471        let mut idx = LineIndex::new();
1472        idx.extend_to_end(&m);
1473        let mut v = Viewport::new(20, 5, "f".into());
1474        v.goto_line(999, &m, &mut idx);
1475        assert_eq!(v.top_line(), 1);
1476    }
1477
1478    #[test]
1479    fn goto_record_positions_at_record_start_line() {
1480        let m = MockSource::new();
1481        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
1482        let mut idx = LineIndex::new();
1483        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1484        idx.extend_to_end(&m);
1485        let mut v = Viewport::new(20, 5, "f".into());
1486        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
1487        assert_eq!(v.top_line(), 2);
1488    }
1489
1490    #[test]
1491    fn goto_record_in_line_per_record_mode_equals_goto_line() {
1492        let m = MockSource::new();
1493        m.append(b"a\nb\nc\n");
1494        let mut idx = LineIndex::new();
1495        idx.extend_to_end(&m);
1496        let mut v = Viewport::new(20, 5, "f".into());
1497        v.goto_record(2, &m, &mut idx);
1498        assert_eq!(v.top_line(), 2);
1499    }
1500
1501    #[test]
1502    fn goto_percent_50_lands_in_middle() {
1503        let m = MockSource::new();
1504        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
1505        let mut idx = LineIndex::new();
1506        idx.extend_to_end(&m);
1507        let mut v = Viewport::new(20, 5, "f".into());
1508        v.goto_percent(50, &m, &mut idx);
1509        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
1510    }
1511
1512    #[test]
1513    fn goto_percent_100_lands_at_last_line() {
1514        let m = MockSource::new();
1515        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
1516        let mut idx = LineIndex::new();
1517        idx.extend_to_end(&m);
1518        let mut v = Viewport::new(20, 5, "f".into());
1519        v.goto_percent(100, &m, &mut idx);
1520        assert_eq!(v.top_line(), 2);
1521    }
1522
1523    #[test]
1524    fn goto_percent_0_lands_at_first_line() {
1525        let m = MockSource::new();
1526        m.append(b"a\nb\nc\n");
1527        let mut idx = LineIndex::new();
1528        idx.extend_to_end(&m);
1529        let mut v = Viewport::new(20, 5, "f".into());
1530        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
1531        assert_eq!(v.top_line(), 2);
1532        v.goto_percent(0, &m, &mut idx);
1533        assert_eq!(v.top_line(), 0);
1534    }
1535
1536    #[test]
1537    fn resize_updates_dimensions_and_render_opts() {
1538        let (m, mut idx) = setup(b"1\n2\n");
1539        let mut v = Viewport::new(10, 5, "f".into());
1540        v.resize(40, 12);
1541        assert_eq!(v.cols, 40);
1542        assert_eq!(v.rows, 12);
1543        assert_eq!(v.opts.cols, 40);
1544        let _ = v.frame(&m, &mut idx);
1545    }
1546
1547    #[test]
1548    fn toggle_line_numbers_changes_gutter() {
1549        let (m, mut idx) = setup(b"a\nb\nc\n");
1550        let mut v = Viewport::new(10, 5, "f".into());
1551        let frame_off = v.frame(&m, &mut idx);
1552        v.toggle_line_numbers();
1553        let frame_on = v.frame(&m, &mut idx);
1554        // With gutter, first cell is a digit or space, not 'a'.
1555        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1556        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1557    }
1558
1559    #[test]
1560    fn toggle_chop_changes_wrap_mode() {
1561        let (m, mut idx) = setup(b"abcdefghij\n");
1562        let mut v = Viewport::new(4, 5, "f".into());
1563        v.toggle_chop();
1564        let frame = v.frame(&m, &mut idx);
1565        // After toggle_chop, the line is one row, not wrapped.
1566        // Body row 0 is "abcd"; rows 1..3 are blank fill.
1567        assert_eq!(frame.body[0][..4],
1568            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1569             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1570             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1571             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1572        // Row 1 should be all-empty (no wrap continuation).
1573        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1574    }
1575
1576    // ----- Follow mode -----
1577
1578    #[test]
1579    fn is_at_bottom_initially_only_when_source_fits() {
1580        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
1581        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
1582        idx.extend_to_end(&m);
1583        assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1584    }
1585
1586    #[test]
1587    fn is_at_bottom_false_when_top_and_more_lines_below() {
1588        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1589        let v = Viewport::new(10, 5, "f".into());  // body = 4
1590        idx.extend_to_end(&m);
1591        assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1592    }
1593
1594    #[test]
1595    fn is_at_bottom_true_after_goto_bottom() {
1596        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1597        let mut v = Viewport::new(10, 5, "f".into());
1598        v.goto_bottom(&m, &mut idx);
1599        assert!(v.is_at_bottom(&idx));
1600    }
1601
1602    #[test]
1603    fn status_shows_follow_suffix_when_follow_mode_on() {
1604        let (m, mut idx) = setup(b"a\nb\n");
1605        let mut v = Viewport::new(20, 5, "f".into());
1606        let frame_off = v.frame(&m, &mut idx);
1607        assert!(!frame_off.status.contains("(F)"));
1608        v.set_follow_mode(true);
1609        let frame_on = v.frame(&m, &mut idx);
1610        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1611    }
1612
1613    #[test]
1614    fn toggle_follow_flips_state() {
1615        let mut v = Viewport::new(10, 5, "f".into());
1616        assert!(!v.follow_mode());
1617        v.toggle_follow();
1618        assert!(v.follow_mode());
1619        v.toggle_follow();
1620        assert!(!v.follow_mode());
1621    }
1622
1623    #[test]
1624    fn status_shows_prettify_label_when_set() {
1625        let (m, mut idx) = setup(b"a\n");
1626        let mut v = Viewport::new(40, 5, "f".into());
1627        let frame_off = v.frame(&m, &mut idx);
1628        assert!(!frame_off.status.contains("[pretty"));
1629        v.set_prettify_label(Some("json".into()));
1630        let frame_on = v.frame(&m, &mut idx);
1631        assert!(frame_on.status.contains("[pretty:json]"),
1632            "expected [pretty:json] in status, got: {}", frame_on.status);
1633        v.set_prettify_label(Some("json:err".into()));
1634        let frame_err = v.frame(&m, &mut idx);
1635        assert!(frame_err.status.contains("[pretty:json:err]"),
1636            "expected [pretty:json:err] in status, got: {}", frame_err.status);
1637    }
1638
1639    #[test]
1640    fn status_shows_l_suffix_when_live_mode_on() {
1641        let (m, mut idx) = setup(b"a\nb\n");
1642        let mut v = Viewport::new(20, 5, "f".into());
1643        let frame_off = v.frame(&m, &mut idx);
1644        assert!(!frame_off.status.contains("(L)"));
1645        v.set_live_mode(true);
1646        let frame_on = v.frame(&m, &mut idx);
1647        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1648    }
1649
1650    #[test]
1651    fn clamp_top_line_pulls_back_when_total_shrinks() {
1652        let mut v = Viewport::new(20, 5, "f".into());
1653        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
1654        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
1655        // Force top_line via a sequence; easiest: just call clamp directly.
1656        // We can't poke private state, but clamp works regardless of how we got there.
1657        v.clamp_top_line(100);  // total bigger than top_line=0, no change
1658        v.clamp_top_line(0);    // empty source: must reset
1659        // After clamp(0), line 0 is the floor.
1660        // (No public getter for top_line; we verify indirectly by going to top.)
1661        v.goto_top();
1662        // Just confirm no panic and no overflow on subsequent frame composition.
1663        let (m, mut idx) = setup(b"only\n");
1664        let _ = v.frame(&m, &mut idx);
1665    }
1666
1667    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
1668    /// when follow mode is on and the viewport is at the bottom.
1669    fn simulate_growth_tick(
1670        v: &mut Viewport,
1671        src: &MockSource,
1672        idx: &mut LineIndex,
1673    ) {
1674        if !v.follow_mode() { return; }
1675        let was_at_bottom = v.is_at_bottom(idx);
1676        let lines_before = idx.line_count();
1677        idx.notice_new_bytes(src);
1678        if idx.line_count() != lines_before && was_at_bottom {
1679            v.goto_bottom(src, idx);
1680        }
1681    }
1682
1683    #[test]
1684    fn auto_scroll_engages_when_at_bottom() {
1685        let m = MockSource::new();
1686        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
1687        let mut idx = LineIndex::new();
1688        let mut v = Viewport::new(10, 5, "f".into());
1689        v.set_follow_mode(true);
1690        idx.extend_to_end(&m);
1691        assert!(v.is_at_bottom(&idx));
1692        let top_before = {
1693            let f = v.frame(&m, &mut idx);
1694            f.status.clone()  // unused, just exercise frame
1695        };
1696        let _ = top_before;
1697        // Simulate growth: source gains 4 more lines.
1698        m.append(b"5\n6\n7\n8\n");
1699        simulate_growth_tick(&mut v, &m, &mut idx);
1700        // After auto-scroll, top_line should have advanced so the new last line is in view.
1701        assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1702        let frame = v.frame(&m, &mut idx);
1703        // The bottom-most body row should now contain the last logical line ('8').
1704        // Find which row has '8'.
1705        let last_row = &frame.body[frame.body.len() - 1];
1706        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1707    }
1708
1709    #[test]
1710    fn auto_scroll_suppressed_when_scrolled_up() {
1711        let m = MockSource::new();
1712        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1713        let mut idx = LineIndex::new();
1714        let mut v = Viewport::new(10, 5, "f".into());  // body=4
1715        v.set_follow_mode(true);
1716        idx.extend_to_end(&m);
1717        v.goto_bottom(&m, &mut idx);
1718        // Now scroll up off the bottom.
1719        v.scroll_lines(-2, &m, &mut idx);
1720        assert!(!v.is_at_bottom(&idx));
1721        let frame_before = v.frame(&m, &mut idx);
1722        let top_first_cell_before = frame_before.body[0][0].clone();
1723        // Simulate growth.
1724        m.append(b"9\n10\n");
1725        simulate_growth_tick(&mut v, &m, &mut idx);
1726        // Viewport should NOT have moved (auto-scroll suppressed).
1727        let frame_after = v.frame(&m, &mut idx);
1728        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1729    }
1730
1731    // ----- Search -----
1732
1733    #[test]
1734    fn set_search_compiles_regex() {
1735        let mut v = Viewport::new(10, 5, "f".into());
1736        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1737        assert!(v.search_active());
1738    }
1739
1740    #[test]
1741    fn set_search_rejects_bad_regex() {
1742        let mut v = Viewport::new(10, 5, "f".into());
1743        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1744        assert!(!err.is_empty());
1745        assert!(!v.search_active(), "no search should be set on error");
1746    }
1747
1748    #[test]
1749    fn search_step_forward_finds_match_after_top() {
1750        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1751        let mut v = Viewport::new(20, 5, "f".into());
1752        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1753        let found = v.search_repeat(&m, &mut idx, false);
1754        assert!(found);
1755        // gamma is line 2 (0-indexed)
1756        assert_eq!(v.top_line, 2);
1757    }
1758
1759    #[test]
1760    fn search_step_backward_finds_match_before_top() {
1761        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1762        let mut v = Viewport::new(20, 5, "f".into());
1763        v.scroll_lines(4, &m, &mut idx); // top_line = 4
1764        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1765        let found = v.search_repeat(&m, &mut idx, false);
1766        assert!(found);
1767        assert_eq!(v.top_line, 0);
1768    }
1769
1770    #[test]
1771    fn search_wraps_at_end() {
1772        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1773        let mut v = Viewport::new(20, 5, "f".into());
1774        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
1775        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1776        let found = v.search_repeat(&m, &mut idx, false);
1777        assert!(found, "search should wrap forward past EOF");
1778        assert_eq!(v.top_line, 0);
1779    }
1780
1781    #[test]
1782    fn search_no_match_returns_false_and_does_not_move() {
1783        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1784        let mut v = Viewport::new(20, 5, "f".into());
1785        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1786        let found = v.search_repeat(&m, &mut idx, false);
1787        assert!(!found);
1788        assert_eq!(v.top_line, 0);
1789    }
1790
1791    #[test]
1792    fn frame_records_highlight_ranges_for_matches() {
1793        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1794        let mut v = Viewport::new(20, 5, "f".into());
1795        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1796        let frame = v.frame(&m, &mut idx);
1797        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
1798        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1799        assert!(frame.highlights[0].is_empty());
1800        assert!(frame.highlights[1].is_empty());
1801        assert_eq!(frame.highlights[2], vec![0..5]);
1802        assert!(frame.highlights[3].is_empty());
1803    }
1804
1805    #[test]
1806    fn frame_highlights_substring_inside_a_row() {
1807        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1808        let mut v = Viewport::new(40, 5, "f".into());
1809        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1810        let frame = v.frame(&m, &mut idx);
1811        // "beta" starts at column 18 in the first row.
1812        assert_eq!(frame.highlights[0], vec![18..22]);
1813        assert!(frame.highlights[1].is_empty());
1814    }
1815
1816    #[test]
1817    fn search_highlight_with_filter_dim_keeps_row_dim() {
1818        // alpha matches filter → Normal. beta doesn't → Dim. Search for
1819        // "beta" should leave row style Dim and mark the substring 0..4.
1820        let (m, mut idx) = setup(b"alpha\nbeta\n");
1821        let mut v = Viewport::new(20, 5, "f".into());
1822        let fmt = crate::format::LogFormat::compile(
1823            "simple",
1824            r"^(?P<line>.+)$",
1825        )
1826        .unwrap();
1827        let f = crate::filter::CompiledFilter::compile(
1828            &fmt,
1829            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1830        )
1831        .unwrap();
1832        v.set_filter(Some(f));
1833        v.set_dim_mode(true);
1834        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1835        let frame = v.frame(&m, &mut idx);
1836        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1837        assert_eq!(frame.row_styles[1], RowStyle::Dim);
1838        assert_eq!(frame.highlights[1], vec![0..4]);
1839    }
1840
1841    #[test]
1842    fn grep_only_hides_non_matching_lines() {
1843        use crate::grep::GrepPredicate;
1844        let src = crate::source::MockSource::new();
1845        src.append(b"keep this error\n");
1846        src.append(b"drop this one\n");
1847        src.append(b"another error line\n");
1848        src.finish();
1849        let mut idx = crate::line_index::LineIndex::new();
1850        idx.extend_to_end(&src);
1851
1852        let mut v = Viewport::new(40, 5, "test".into());
1853        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1854        v.extend_visible_lines(&idx, &src);
1855
1856        // Only the two "error" lines should be visible.
1857        let frame = v.frame(&src, &mut idx);
1858        let body_text: Vec<String> = frame.body.iter()
1859            .map(|row| row.iter().filter_map(|c| match c {
1860                crate::render::Cell::Char { ch, .. } => Some(*ch),
1861                _ => None,
1862            }).collect())
1863            .collect();
1864        assert!(body_text[0].contains("keep this error"));
1865        assert!(body_text[1].contains("another error line"));
1866        assert!(frame.status.contains("[grep]"));
1867    }
1868
1869    #[test]
1870    fn filter_and_grep_combine_with_and() {
1871        use crate::grep::GrepPredicate;
1872        let fmt = crate::format::LogFormat::compile(
1873            "simple",
1874            r"^(?P<level>\w+) (?P<msg>.+)$",
1875        ).unwrap();
1876        let f = crate::filter::CompiledFilter::compile(
1877            &fmt,
1878            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1879        ).unwrap();
1880        let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1881
1882        let src = crate::source::MockSource::new();
1883        src.append(b"ERROR timeout connecting\n");      // matches both → keep
1884        src.append(b"ERROR file not found\n");          // matches filter only → drop
1885        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
1886        src.append(b"INFO all good\n");                 // matches neither → drop
1887        src.finish();
1888        let mut idx = crate::line_index::LineIndex::new();
1889        idx.extend_to_end(&src);
1890
1891        let mut v = Viewport::new(80, 5, "test".into());
1892        v.set_filter(Some(f));
1893        v.set_grep(Some(g));
1894        v.extend_visible_lines(&idx, &src);
1895        assert_eq!(v.visible_lines(), &[0usize]);
1896    }
1897
1898    #[test]
1899    fn search_status_shows_pattern() {
1900        let (m, mut idx) = setup(b"x\n");
1901        let mut v = Viewport::new(20, 5, "f".into());
1902        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1903        let frame = v.frame(&m, &mut idx);
1904        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1905    }
1906
1907    #[test]
1908    fn repeat_search_after_first_match_advances() {
1909        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1910        let mut v = Viewport::new(40, 5, "f".into());
1911        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1912        assert!(v.search_repeat(&m, &mut idx, false));
1913        assert_eq!(v.top_line, 1, "first foo");
1914        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1915        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1916        assert_eq!(v.top_line, 3, "should advance to next foo");
1917    }
1918
1919    #[test]
1920    fn auto_scroll_paused_when_follow_off() {
1921        let m = MockSource::new();
1922        m.append(b"1\n2\n3\n4\n");
1923        let mut idx = LineIndex::new();
1924        let mut v = Viewport::new(10, 5, "f".into());
1925        // Follow is off; viewport at top.
1926        idx.extend_to_end(&m);
1927        let frame_before = v.frame(&m, &mut idx);
1928        let top_first_cell = frame_before.body[0][0].clone();
1929        m.append(b"5\n6\n7\n8\n");
1930        simulate_growth_tick(&mut v, &m, &mut idx);
1931        let frame_after = v.frame(&m, &mut idx);
1932        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1933    }
1934
1935    // ----- Records-mode search -----
1936
1937    #[test]
1938    fn search_jumps_to_next_matching_record() {
1939        let m = MockSource::new();
1940        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
1941        let mut idx = LineIndex::new();
1942        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1943        idx.extend_to_end(&m);
1944        let mut v = Viewport::new(40, 10, "f".into());
1945        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1946        let hit = v.search_repeat(&m, &mut idx, false);
1947        assert!(hit, "should find 'charlie' in record 2");
1948        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
1949    }
1950
1951    #[test]
1952    fn search_finds_cross_line_match_in_record_with_s_flag() {
1953        let m = MockSource::new();
1954        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
1955        let mut idx = LineIndex::new();
1956        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1957        idx.extend_to_end(&m);
1958        let mut v = Viewport::new(40, 10, "f".into());
1959        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1960        let hit = v.search_repeat(&m, &mut idx, false);
1961        assert!(hit, "should match across \\n inside record 0 with (?s)");
1962        assert_eq!(v.top_line(), 0);
1963    }
1964
1965    #[test]
1966    fn search_repeat_with_no_match_returns_false() {
1967        let m = MockSource::new();
1968        m.append(b"[1] alpha\n[2] bravo\n");
1969        let mut idx = LineIndex::new();
1970        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1971        idx.extend_to_end(&m);
1972        let mut v = Viewport::new(40, 10, "f".into());
1973        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1974        let hit = v.search_repeat(&m, &mut idx, false);
1975        assert!(!hit);
1976    }
1977
1978    // ----- Records-mode filter/grep -----
1979
1980    #[test]
1981    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1982        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
1983        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
1984        let m = MockSource::new();
1985        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
1986        let mut idx = LineIndex::new();
1987        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1988        idx.extend_to_end(&m);
1989        let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1990        let mut v = Viewport::new(40, 10, "f".into());
1991        v.set_grep(Some(grep));
1992        v.extend_visible_lines(&idx, &m);
1993        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
1994        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
1995        assert_eq!(v.visible_lines(), &[0usize, 1]);
1996    }
1997
1998    #[test]
1999    fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
2000        // The format regex is designed for the header line (it ends with `$`).
2001        // Applied to the full multi-line record bytes it would never match
2002        // because `$` doesn't match before a non-final `\n`. Records-mode
2003        // filter must evaluate against the first line of the record, then
2004        // include all of the record's lines when it matches.
2005        let m = MockSource::new();
2006        m.append(
2007            b"[1] kind=category\n  body a\n  body a2\n[2] kind=rule\n  body b\n",
2008        );
2009        let mut idx = LineIndex::new();
2010        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2011        idx.extend_to_end(&m);
2012        let fmt = crate::format::LogFormat::compile(
2013            "rec",
2014            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2015        )
2016        .unwrap();
2017        let f = crate::filter::CompiledFilter::compile(
2018            &fmt,
2019            vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
2020        )
2021        .unwrap();
2022        let mut v = Viewport::new(40, 10, "f".into());
2023        v.set_filter(Some(f));
2024        v.extend_visible_lines(&idx, &m);
2025        // Record 0 (lines 0, 1, 2) matches; record 1 (lines 3, 4) does not.
2026        assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2027    }
2028
2029    #[test]
2030    fn grep_matches_across_record_newlines_in_records_mode() {
2031        // Pattern spans the record-header and a continuation line (needs (?s) for .).
2032        let m = MockSource::new();
2033        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
2034        let mut idx = LineIndex::new();
2035        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2036        idx.extend_to_end(&m);
2037        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
2038        let mut v = Viewport::new(40, 10, "f".into());
2039        v.set_grep(Some(grep));
2040        v.extend_visible_lines(&idx, &m);
2041        // Record 0 matches (cross-line); record 1 does not.
2042        assert_eq!(v.visible_lines(), &[0usize, 1]);
2043    }
2044
2045    #[test]
2046    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2047        // All 4 lines stay in visible_lines (dim mode = no hiding).
2048        // Record 0 matches grep → Normal; record 1 does not → Dim.
2049        let m = MockSource::new();
2050        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
2051        let mut idx = LineIndex::new();
2052        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2053        idx.extend_to_end(&m);
2054        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
2055        let mut v = Viewport::new(40, 10, "f".into());
2056        v.set_grep(Some(grep));
2057        v.set_dim_mode(true);
2058        v.extend_visible_lines(&idx, &m);
2059        // Dim mode: visible_lines stays empty (hide_mode() is false).
2060        assert_eq!(v.visible_lines(), &[] as &[usize]);
2061        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
2062        assert!(!v.should_dim_line(0, &idx, &m));
2063        assert!(!v.should_dim_line(1, &idx, &m));
2064        // Lines 2 and 3 belong to non-matching record → Dim.
2065        assert!(v.should_dim_line(2, &idx, &m));
2066        assert!(v.should_dim_line(3, &idx, &m));
2067    }
2068
2069    #[test]
2070    fn status_unchanged_when_records_inactive() {
2071        let (m, mut idx) = setup(b"a\nb\nc\n");
2072        let mut v = Viewport::new(20, 5, "f".into());
2073        let frame = v.frame(&m, &mut idx);
2074        let status = &frame.status;
2075        // Default format: <label>  <top>-<bot>/<total>  <pct>%
2076        assert!(status.contains("1-3/3"), "got: {status}");
2077        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2078        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2079    }
2080
2081    #[test]
2082    fn status_r_block_uses_real_lines_in_hide_mode() {
2083        // Regression: in hide mode `bottom` is a position in visible_lines
2084        // (i.e. a count of *visible* matches), not a logical line index.
2085        // The R-block was passing that position into `line_to_record`, which
2086        // resolved to whatever record contained logical line `bottom-1` —
2087        // typically a very early record, producing nonsense like `R290-8`
2088        // where the bottom record is *before* the top record on screen.
2089        // Build a scenario: many records, only the last few match the filter,
2090        // and the viewport is scrolled to the matching tail.
2091        let m = MockSource::new();
2092        // 10 records, two physical lines each. Record N's header has `kind=A`
2093        // for N < 8 and `kind=B` for N >= 8 (so only records 8 and 9 match).
2094        let mut buf = Vec::new();
2095        for n in 0..10 {
2096            let kind = if n >= 8 { "B" } else { "A" };
2097            buf.extend_from_slice(format!("[{}] kind={}\n  body {}\n", n, kind, n).as_bytes());
2098        }
2099        m.append(&buf);
2100        m.finish();
2101
2102        let mut idx = LineIndex::new();
2103        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2104        idx.extend_to_end(&m);
2105
2106        let fmt = crate::format::LogFormat::compile(
2107            "rec",
2108            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2109        )
2110        .unwrap();
2111        let f = crate::filter::CompiledFilter::compile(
2112            &fmt,
2113            vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2114        )
2115        .unwrap();
2116
2117        // 5-row terminal: 4 body rows + 1 status row. With 4 visible-matches
2118        // rows of body and 4 visible lines, the whole filtered set fits.
2119        let mut v = Viewport::new(80, 5, "f".into());
2120        v.set_filter(Some(f));
2121        v.extend_visible_lines(&idx, &m);
2122
2123        // Jump to the first matching record (record 8, 0-indexed).
2124        v.goto_record(8, &m, &mut idx);
2125
2126        let frame = v.frame(&m, &mut idx);
2127        // Records 8 (rec_top=9) and 9 (rec_bottom=10) are on screen.
2128        assert!(
2129            frame.status.contains("R9-10/10"),
2130            "expected R9-10/10 in status, got: {}",
2131            frame.status,
2132        );
2133    }
2134
2135    #[test]
2136    fn status_dual_readout_when_records_active() {
2137        let m = MockSource::new();
2138        m.append(b"[1] a\n  cont\n[2] b\n");
2139        m.finish();
2140        let mut idx = LineIndex::new();
2141        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2142        idx.extend_to_end(&m);
2143        let mut v = Viewport::new(20, 5, "f".into());
2144        let frame = v.frame(&m, &mut idx);
2145        let status = &frame.status;
2146        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2147        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2148    }
2149
2150    #[test]
2151    fn format_status_uses_custom_template_when_set() {
2152        let m = MockSource::new();
2153        m.append(b"a\nb\nc\n");
2154        m.finish();
2155        let mut idx = LineIndex::new();
2156        idx.extend_to_end(&m);
2157        let mut v = Viewport::new(20, 5, "f".into());
2158        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2159        v.set_prompt(Some(prompt));
2160        let frame = v.frame(&m, &mut idx);
2161        assert_eq!(frame.status, "f 100%");
2162    }
2163
2164    #[test]
2165    fn status_shows_preprocess_failed_tag_when_set() {
2166        let m = MockSource::new();
2167        m.append(b"a\n");
2168        let mut idx = LineIndex::new();
2169        idx.extend_to_end(&m);
2170        let mut v = Viewport::new(40, 5, "f".into());
2171        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2172        let frame = v.frame(&m, &mut idx);
2173        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2174                "got: {}", frame.status);
2175    }
2176
2177    #[test]
2178    fn default_status_includes_help_hint() {
2179        let (m, mut idx) = setup(b"a\nb\nc\n");
2180        let mut v = Viewport::new(80, 5, "f".into());
2181        let frame = v.frame(&m, &mut idx);
2182        assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2183    }
2184
2185    #[test]
2186    fn custom_prompt_does_not_get_help_hint() {
2187        let (m, mut idx) = setup(b"a\nb\nc\n");
2188        let mut v = Viewport::new(80, 5, "f".into());
2189        v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2190        let frame = v.frame(&m, &mut idx);
2191        assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2192    }
2193
2194    #[test]
2195    fn status_shows_file_index_when_multifile() {
2196        let m = MockSource::new();
2197        m.append(b"a\n");
2198        let mut idx = LineIndex::new();
2199        idx.extend_to_end(&m);
2200        let mut v = Viewport::new(60, 5, "f.log".into());
2201        v.set_file_index(0, 3);
2202        let frame = v.frame(&m, &mut idx);
2203        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
2204    }
2205
2206    #[test]
2207    fn status_omits_file_index_when_single_file() {
2208        let m = MockSource::new();
2209        m.append(b"a\n");
2210        let mut idx = LineIndex::new();
2211        idx.extend_to_end(&m);
2212        let mut v = Viewport::new(60, 5, "f.log".into());
2213        v.set_file_index(0, 1);
2214        let frame = v.frame(&m, &mut idx);
2215        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2216    }
2217
2218    #[test]
2219    fn status_shows_tag_active_when_multimatch() {
2220        let m = MockSource::new();
2221        m.append(b"a\n");
2222        let mut idx = LineIndex::new();
2223        idx.extend_to_end(&m);
2224        let mut v = Viewport::new(80, 5, "f.log".into());
2225        v.set_tag_active(Some(("foo".into(), 2, 3)));
2226        let frame = v.frame(&m, &mut idx);
2227        assert!(
2228            frame.status.contains("[tag: foo (2/3)]"),
2229            "got: {}",
2230            frame.status
2231        );
2232    }
2233
2234    #[test]
2235    fn status_omits_tag_active_when_single_match() {
2236        let m = MockSource::new();
2237        m.append(b"a\n");
2238        let mut idx = LineIndex::new();
2239        idx.extend_to_end(&m);
2240        let mut v = Viewport::new(80, 5, "f.log".into());
2241        v.set_tag_active(Some(("foo".into(), 1, 1)));
2242        let frame = v.frame(&m, &mut idx);
2243        assert!(
2244            !frame.status.contains("[tag:"),
2245            "should not show indicator for single match: {}",
2246            frame.status
2247        );
2248    }
2249
2250    // ----- SGR state reconstruction tests -----
2251
2252    #[test]
2253    fn reconstruct_picks_up_state_from_prior_lines() {
2254        let m = MockSource::new();
2255        m.append(b"\x1b[31mline 1\n");
2256        m.append(b"line 2 (still red, no reset)\n");
2257        m.append(b"line 3\n");
2258        let mut idx = LineIndex::new();
2259        idx.extend_to_end(&m);
2260        let state = reconstruct_render_state(&m, &idx, 2);
2261        assert_eq!(
2262            state.style.fg,
2263            Some(crate::ansi::Color::Ansi(1)),
2264            "red SGR from line 0 should persist to line 2"
2265        );
2266    }
2267
2268    #[test]
2269    fn reconstruct_respects_reset_between_lines() {
2270        let m = MockSource::new();
2271        m.append(b"\x1b[31mline 1\x1b[0m\n");
2272        m.append(b"line 2 (default)\n");
2273        let mut idx = LineIndex::new();
2274        idx.extend_to_end(&m);
2275        let state = reconstruct_render_state(&m, &idx, 1);
2276        assert_eq!(state.style.fg, None);
2277    }
2278
2279    #[test]
2280    fn reconstruct_caps_walkback_at_max_lines() {
2281        let m = MockSource::new();
2282        m.append(b"\x1b[31mvery early\n");
2283        for _ in 0..300 {
2284            m.append(b"line\n");
2285        }
2286        let mut idx = LineIndex::new();
2287        idx.extend_to_end(&m);
2288        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
2289        // anchor we'd pick is line 34 (290 - 256), which is past the red.
2290        let state = reconstruct_render_state(&m, &idx, 290);
2291        assert_eq!(state.style.fg, None);
2292    }
2293}