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