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        // Right-aligned :help hint. If the existing status already overshoots
898        // the width, no pad — the renderer will clip on draw.
899        let used = s.chars().count();
900        let hint = ":help";
901        if (self.cols as usize) > used + 1 + hint.chars().count() {
902            let pad = self.cols as usize - used - hint.chars().count();
903            s.push_str(&" ".repeat(pad));
904            s.push_str(hint);
905        } else {
906            s.push(' ');
907            s.push_str(hint);
908        }
909        s
910    }
911
912    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
913        use crate::prompt::PromptContext;
914
915        let body_rows = self.body_rows() as usize;
916        let total = idx.line_count();
917        let top = self.top_line + 1;
918        let bottom = (self.top_line + body_rows).min(total.max(1));
919        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
920        let bottom_line = self.bottom_visible_line(idx);
921
922        let records_mode = idx.records_mode();
923        let (rec_top, rec_bottom, rec_total) = if records_mode {
924            let rt = idx.line_to_record(self.top_line) + 1;
925            let rb_raw = idx.line_to_record(bottom_line) + 1;
926            let rb = if rb_raw < rt { rt } else { rb_raw };
927            (rt, rb, idx.record_count())
928        } else {
929            (0, 0, 0)
930        };
931
932        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
933            let line_rows = if total > 0 {
934                let bytes = self.line_display_bytes(src, idx, self.top_line);
935                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
936            } else { 1 };
937            format!("+{}/{}", self.top_row, line_rows)
938        } else {
939            String::new()
940        };
941
942        let format_tag = self.format_label.as_ref()
943            .map(|n| format!("  [{}]", n))
944            .unwrap_or_default();
945        let filter_tag = self.filter.as_ref()
946            .map(|f| format!("  [{}]", f.format_name))
947            .unwrap_or_default();
948        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
949        let hide_tag = if self.filter.is_some() || self.grep.is_some() {
950            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
951        } else {
952            String::new()
953        };
954        let search_tag = self.search.as_ref()
955            .map(|s| {
956                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
957                format!("  [{}{}]", p, s.raw)
958            })
959            .unwrap_or_default();
960        let pretty_tag = self.prettify_label.as_ref()
961            .map(|l| format!("  [pretty:{l}]"))
962            .unwrap_or_default();
963        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
964        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
965        let preprocess_failed_tag = self.preprocess_failure.as_ref()
966            .map(|msg| {
967                let first_line = msg.lines().next().unwrap_or("");
968                format!("  [preprocess-failed: {}]", first_line)
969            })
970            .unwrap_or_default();
971
972        let file_index_tag = match self.file_index {
973            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
974            None => String::new(),
975        };
976
977        let tag_tag = match &self.tag_active {
978            Some((name, cur, total)) if *total > 1 => {
979                format!("  [tag: {name} ({cur}/{total})]")
980            }
981            _ => String::new(),
982        };
983
984        PromptContext {
985            label: self.source_label.clone(),
986            top,
987            bottom,
988            total,
989            pct: pct.min(100) as u8,
990            rec_top,
991            rec_bottom,
992            rec_total,
993            records_mode,
994            wrap_offset,
995            format_tag,
996            filter_tag,
997            grep_tag,
998            hide_tag,
999            search_tag,
1000            pretty_tag,
1001            live_tag,
1002            follow_tag,
1003            preprocess_failed_tag,
1004            file_index_tag,
1005            tag_tag,
1006        }
1007    }
1008
1009    fn frame_hex(&self, src: &dyn Source) -> Frame {
1010        use crate::hex::format_hex_row;
1011        use crate::render::{render_line, Cell, RenderOpts};
1012
1013        let body_rows = self.rows.saturating_sub(1) as usize;
1014        let total_bytes = src.len();
1015        let total_hex_rows = total_bytes.div_ceil(16);
1016
1017        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
1018        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
1019        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
1020
1021        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
1022
1023        for row_idx in 0..body_rows {
1024            let hex_row = self.top_line + row_idx;
1025            if hex_row >= total_hex_rows {
1026                body.push(vec![Cell::Empty; self.cols as usize]);
1027            } else {
1028                let offset = hex_row * 16;
1029                let end = (offset + 16).min(total_bytes);
1030                let bytes_cow = src.bytes(offset..end);
1031                let text = format_hex_row(offset, &bytes_cow);
1032                let rows = render_line(text.as_bytes(), &opts, None);
1033                body.push(rows.into_iter().next().unwrap_or_else(|| {
1034                    vec![Cell::Empty; self.cols as usize]
1035                }));
1036            }
1037            row_styles.push(RowStyle::Normal);
1038            highlights.push(Vec::new());
1039        }
1040
1041        let status = self.format_status_hex(src);
1042        Frame { body, row_styles, highlights, status }
1043    }
1044
1045    fn format_status_hex(&self, src: &dyn Source) -> String {
1046        let total_bytes = src.len();
1047        let body_rows = self.rows.saturating_sub(1) as usize;
1048        // Byte offset of the first visible byte (start of the top hex row).
1049        let top_byte = self.top_line * 16;
1050        // Byte offset just past the last visible byte. Clamped to total_bytes
1051        // so we never show a value past EOF.
1052        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
1053        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
1054        let label_with_index = match self.file_index {
1055            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
1056            None => self.source_label.clone(),
1057        };
1058        let tag_suffix = match &self.tag_active {
1059            Some((name, cur, total)) if *total > 1 => {
1060                format!("  [tag: {name} ({cur}/{total})]")
1061            }
1062            _ => String::new(),
1063        };
1064        format!(
1065            "{}  off {}-{}/{}  {}%  [hex]{}",
1066            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
1067        )
1068    }
1069
1070    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
1071    /// reset to 0 so the start of the destination line is at the top of
1072    /// the viewport. In hide mode this is equivalent to `scroll_lines`
1073    /// (which already moves by visible/logical lines).
1074    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1075        if delta == 0 { return; }
1076        if self.hide_mode() {
1077            self.scroll_lines(delta, src, idx);
1078            return;
1079        }
1080        if delta > 0 {
1081            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1082            let total = idx.line_count();
1083            if total == 0 { return; }
1084            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1085            self.top_line = target;
1086            self.top_row = 0;
1087        } else {
1088            let back = (-delta) as usize;
1089            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1090            // the start of the current line; only the remaining count goes to
1091            // previous lines. This matches the user's mental model of "jump
1092            // to the start of the previous line".
1093            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1094            let extra_back = back.saturating_sub(consumed_for_snap);
1095            self.top_line = self.top_line.saturating_sub(extra_back);
1096            self.top_row = 0;
1097        }
1098    }
1099
1100    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1101        if delta == 0 { return; }
1102        if self.hide_mode() {
1103            // Scroll by visible (matching) lines. We don't honor wrap rows in
1104            // hide mode — top_row stays 0. Each unit of `delta` advances or
1105            // retreats one visible line.
1106            self.extend_visible_lines(idx, src);
1107            let total = self.visible_lines.len();
1108            if total == 0 {
1109                self.top_line = 0;
1110                self.top_row = 0;
1111                return;
1112            }
1113            let cur = self
1114                .visible_lines
1115                .iter()
1116                .position(|&l| l >= self.top_line)
1117                .unwrap_or(total);
1118            let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1119            self.top_line = self.visible_lines[new];
1120            self.top_row = 0;
1121            return;
1122        }
1123        if delta > 0 {
1124            let mut remaining = delta as usize;
1125            while remaining > 0 {
1126                idx.extend_to_line(self.top_line + 1, src);
1127                let total = idx.line_count();
1128                if total == 0 { break; }
1129                let bytes = self.line_display_bytes(src, idx, self.top_line);
1130                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1131                if self.top_row + 1 < line_rows {
1132                    self.top_row += 1;
1133                } else if self.top_line + 1 < total {
1134                    self.top_row = 0;
1135                    self.top_line += 1;
1136                } else {
1137                    break;
1138                }
1139                remaining -= 1;
1140            }
1141        } else {
1142            let mut remaining = (-delta) as usize;
1143            while remaining > 0 {
1144                if self.top_row > 0 {
1145                    self.top_row -= 1;
1146                } else if self.top_line > 0 {
1147                    self.top_line -= 1;
1148                    let bytes = self.line_display_bytes(src, idx, self.top_line);
1149                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1150                    self.top_row = line_rows.saturating_sub(1);
1151                } else {
1152                    break;
1153                }
1154                remaining -= 1;
1155            }
1156        }
1157    }
1158
1159    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1160        let n = self.body_rows() as i64;
1161        self.scroll_lines(n, src, idx);
1162    }
1163
1164    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1165        let n = self.body_rows() as i64;
1166        self.scroll_lines(-n, src, idx);
1167    }
1168
1169    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1170        let n = (self.body_rows() / 2).max(1) as i64;
1171        self.scroll_lines(n, src, idx);
1172    }
1173
1174    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1175        let n = (self.body_rows() / 2).max(1) as i64;
1176        self.scroll_lines(-n, src, idx);
1177    }
1178
1179    pub fn goto_top(&mut self) {
1180        self.top_line = 0;
1181        self.top_row = 0;
1182    }
1183
1184    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1185        idx.extend_to_end(src);
1186        let body = self.body_rows() as usize;
1187        if self.hide_mode() {
1188            self.extend_visible_lines(idx, src);
1189            let total = self.visible_lines.len();
1190            let target_visible = total.saturating_sub(body);
1191            self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1192            self.top_row = 0;
1193        } else {
1194            let total = idx.line_count();
1195            self.top_line = total.saturating_sub(body);
1196            self.top_row = 0;
1197        }
1198    }
1199
1200    /// Position the viewport so line `n` (0-indexed) is the top visible line.
1201    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1202        idx.extend_to_line(n, src);
1203        let target = n.min(idx.line_count().saturating_sub(1));
1204        self.top_line = target;
1205        self.top_row = 0;
1206    }
1207
1208    /// Position the viewport at the start of record `n` (0-indexed).
1209    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1210        // Ensure the record exists by extending the index. Records can only
1211        // appear after their constituent lines are scanned; extend repeatedly
1212        // until the record exists or we hit EOF.
1213        while idx.record_count() <= n && idx.scanned_through() < src.len() {
1214            idx.extend_to_end(src);
1215        }
1216        if idx.record_count() == 0 {
1217            return;
1218        }
1219        let target = n.min(idx.record_count().saturating_sub(1));
1220        let line_range = idx.record_line_range(target);
1221        self.top_line = line_range.start;
1222        self.top_row = 0;
1223    }
1224
1225    /// Position the viewport at `p` percent through the file by bytes.
1226    /// `p` is clamped to 0..=100. p=100 lands at the last line.
1227    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1228        let p = p.min(100) as usize;
1229        let target_byte = src.len().saturating_mul(p) / 100;
1230        idx.extend_to_byte_for_query(src, target_byte);
1231        let line_n = idx.line_at_byte(target_byte)
1232            .or_else(|| {
1233                // target_byte at or past EOF: fall through to the last line.
1234                let lc = idx.line_count();
1235                if lc > 0 { Some(lc - 1) } else { None }
1236            })
1237            .unwrap_or(0);
1238        self.top_line = line_n;
1239        self.top_row = 0;
1240    }
1241
1242    /// Get the currently top-displayed physical line index.
1243    pub fn top_line(&self) -> usize {
1244        self.top_line
1245    }
1246
1247    pub fn resize(&mut self, cols: u16, rows: u16) {
1248        self.cols = cols.max(1);
1249        self.rows = rows.max(2);
1250        self.opts.cols = self.cols;
1251    }
1252
1253    pub fn toggle_line_numbers(&mut self) {
1254        self.show_line_numbers = !self.show_line_numbers;
1255    }
1256
1257    pub fn toggle_chop(&mut self) {
1258        self.opts.wrap = !self.opts.wrap;
1259    }
1260
1261    /// Return the current set of visible (matched) line indices. Non-empty only
1262    /// in hide mode (filter or grep active without --dim). Stable public accessor
1263    /// so integration tests and external tooling can inspect filter results.
1264    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270    use crate::source::MockSource;
1271
1272    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1273        let m = MockSource::new();
1274        m.append(content);
1275        m.finish();
1276        let idx = LineIndex::new();
1277        (m, idx)
1278    }
1279
1280    #[test]
1281    fn frame_renders_body_height_rows() {
1282        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1283        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
1284        let frame = v.frame(&m, &mut idx);
1285        assert_eq!(frame.body.len(), 4);
1286        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1287        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1288    }
1289
1290    #[test]
1291    fn scroll_down_advances_top_line() {
1292        let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1293        let mut v = Viewport::new(10, 5, "test".into());
1294        v.scroll_lines(2, &m, &mut idx);
1295        assert_eq!(v.top_line, 2);
1296        assert_eq!(v.top_row, 0);
1297    }
1298
1299    #[test]
1300    fn scroll_up_clamps_at_zero() {
1301        let (m, mut idx) = setup(b"a\nb\nc\n");
1302        let mut v = Viewport::new(10, 5, "test".into());
1303        v.scroll_lines(-5, &m, &mut idx);
1304        assert_eq!(v.top_line, 0);
1305        assert_eq!(v.top_row, 0);
1306    }
1307
1308    #[test]
1309    fn scroll_down_clamps_at_last_line() {
1310        let (m, mut idx) = setup(b"a\nb\nc\n");
1311        let mut v = Viewport::new(10, 5, "test".into());
1312        v.scroll_lines(50, &m, &mut idx);
1313        assert_eq!(v.top_line, 2);
1314    }
1315
1316    #[test]
1317    fn scroll_logical_lines_skips_wrap_rows() {
1318        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
1319        let mut content = vec![b'X'; 500];
1320        content.push(b'\n');
1321        content.extend_from_slice(b"second\n");
1322        content.extend_from_slice(b"third\n");
1323        let (m, mut idx) = setup(&content);
1324        let mut v = Viewport::new(10, 8, "f".into());
1325        v.scroll_logical_lines(1, &m, &mut idx);
1326        assert_eq!((v.top_line, v.top_row), (1, 0));
1327        v.scroll_logical_lines(1, &m, &mut idx);
1328        assert_eq!((v.top_line, v.top_row), (2, 0));
1329    }
1330
1331    #[test]
1332    fn scroll_logical_lines_back_snaps_to_line_start() {
1333        // Mid-wrap K should snap to start of current line first, then go back.
1334        let mut content = vec![b'A'; 50];
1335        content.push(b'\n');
1336        content.extend_from_slice(&[b'B'; 50]);
1337        content.push(b'\n');
1338        let (m, mut idx) = setup(&content);
1339        let mut v = Viewport::new(10, 8, "f".into());
1340        v.scroll_lines(7, &m, &mut idx);
1341        assert_eq!(v.top_line, 1, "should be on line 1");
1342        assert!(v.top_row > 0, "should be inside line 1's wraps");
1343        v.scroll_logical_lines(-1, &m, &mut idx);
1344        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1345        v.scroll_logical_lines(-1, &m, &mut idx);
1346        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1347    }
1348
1349    #[test]
1350    fn scroll_down_walks_wraps_of_last_line() {
1351        // Last line is 30 chars in a 10-col viewport → 3 wrap rows.
1352        let mut content = b"first\n".to_vec();
1353        content.extend_from_slice(&[b'X'; 30]);
1354        content.push(b'\n');
1355        let (m, mut idx) = setup(&content);
1356        let mut v = Viewport::new(10, 5, "f".into());
1357        v.scroll_lines(1, &m, &mut idx);
1358        assert_eq!((v.top_line, v.top_row), (1, 0));
1359        v.scroll_lines(1, &m, &mut idx);
1360        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1361        v.scroll_lines(1, &m, &mut idx);
1362        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1363    }
1364
1365    #[test]
1366    fn scroll_down_walks_wrap_rows_within_long_line() {
1367        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
1368        let mut content = vec![b'X'; 30];
1369        content.push(b'\n');
1370        content.extend_from_slice(b"second\n");
1371        let (m, mut idx) = setup(&content);
1372        let mut v = Viewport::new(10, 5, "f".into());
1373        v.scroll_lines(1, &m, &mut idx);
1374        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1375        v.scroll_lines(1, &m, &mut idx);
1376        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1377        v.scroll_lines(1, &m, &mut idx);
1378        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1379    }
1380
1381    #[test]
1382    fn status_line_shows_range_and_pct() {
1383        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1384        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
1385        let frame = v.frame(&m, &mut idx);
1386        assert!(frame.status.starts_with("f  1-4/10"));
1387    }
1388
1389    #[test]
1390    fn page_down_advances_by_body_rows() {
1391        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1392        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1393        v.page_down(&m, &mut idx);
1394        assert_eq!(v.top_line, 4);
1395    }
1396
1397    #[test]
1398    fn page_up_then_page_down_returns_to_start_when_no_resize() {
1399        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1400        let mut v = Viewport::new(10, 5, "f".into());
1401        v.page_down(&m, &mut idx);
1402        v.page_up(&m, &mut idx);
1403        assert_eq!(v.top_line, 0);
1404        assert_eq!(v.top_row, 0);
1405    }
1406
1407    #[test]
1408    fn half_page_down_advances_by_half_body() {
1409        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1410        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
1411        v.half_page_down(&m, &mut idx);
1412        assert_eq!(v.top_line, 3);
1413    }
1414
1415    #[test]
1416    fn goto_top_resets_position() {
1417        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1418        let mut v = Viewport::new(10, 5, "f".into());
1419        v.scroll_lines(2, &m, &mut idx);
1420        v.goto_top();
1421        assert_eq!(v.top_line, 0);
1422        assert_eq!(v.top_row, 0);
1423    }
1424
1425    #[test]
1426    fn goto_bottom_scrolls_to_last_page() {
1427        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1428        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1429        v.goto_bottom(&m, &mut idx);
1430        // Last page should show lines 7..=10 → top_line = 6.
1431        assert_eq!(v.top_line, 6);
1432    }
1433
1434    #[test]
1435    fn goto_line_positions_top_line() {
1436        let m = MockSource::new();
1437        m.append(b"a\nb\nc\nd\ne\n");
1438        let mut idx = LineIndex::new();
1439        idx.extend_to_end(&m);
1440        let mut v = Viewport::new(20, 5, "f".into());
1441        v.goto_line(3, &m, &mut idx);
1442        assert_eq!(v.top_line(), 3);
1443    }
1444
1445    #[test]
1446    fn goto_line_clamps_to_last_line() {
1447        let m = MockSource::new();
1448        m.append(b"a\nb\n");
1449        let mut idx = LineIndex::new();
1450        idx.extend_to_end(&m);
1451        let mut v = Viewport::new(20, 5, "f".into());
1452        v.goto_line(999, &m, &mut idx);
1453        assert_eq!(v.top_line(), 1);
1454    }
1455
1456    #[test]
1457    fn goto_record_positions_at_record_start_line() {
1458        let m = MockSource::new();
1459        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
1460        let mut idx = LineIndex::new();
1461        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1462        idx.extend_to_end(&m);
1463        let mut v = Viewport::new(20, 5, "f".into());
1464        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
1465        assert_eq!(v.top_line(), 2);
1466    }
1467
1468    #[test]
1469    fn goto_record_in_line_per_record_mode_equals_goto_line() {
1470        let m = MockSource::new();
1471        m.append(b"a\nb\nc\n");
1472        let mut idx = LineIndex::new();
1473        idx.extend_to_end(&m);
1474        let mut v = Viewport::new(20, 5, "f".into());
1475        v.goto_record(2, &m, &mut idx);
1476        assert_eq!(v.top_line(), 2);
1477    }
1478
1479    #[test]
1480    fn goto_percent_50_lands_in_middle() {
1481        let m = MockSource::new();
1482        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
1483        let mut idx = LineIndex::new();
1484        idx.extend_to_end(&m);
1485        let mut v = Viewport::new(20, 5, "f".into());
1486        v.goto_percent(50, &m, &mut idx);
1487        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
1488    }
1489
1490    #[test]
1491    fn goto_percent_100_lands_at_last_line() {
1492        let m = MockSource::new();
1493        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
1494        let mut idx = LineIndex::new();
1495        idx.extend_to_end(&m);
1496        let mut v = Viewport::new(20, 5, "f".into());
1497        v.goto_percent(100, &m, &mut idx);
1498        assert_eq!(v.top_line(), 2);
1499    }
1500
1501    #[test]
1502    fn goto_percent_0_lands_at_first_line() {
1503        let m = MockSource::new();
1504        m.append(b"a\nb\nc\n");
1505        let mut idx = LineIndex::new();
1506        idx.extend_to_end(&m);
1507        let mut v = Viewport::new(20, 5, "f".into());
1508        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
1509        assert_eq!(v.top_line(), 2);
1510        v.goto_percent(0, &m, &mut idx);
1511        assert_eq!(v.top_line(), 0);
1512    }
1513
1514    #[test]
1515    fn resize_updates_dimensions_and_render_opts() {
1516        let (m, mut idx) = setup(b"1\n2\n");
1517        let mut v = Viewport::new(10, 5, "f".into());
1518        v.resize(40, 12);
1519        assert_eq!(v.cols, 40);
1520        assert_eq!(v.rows, 12);
1521        assert_eq!(v.opts.cols, 40);
1522        let _ = v.frame(&m, &mut idx);
1523    }
1524
1525    #[test]
1526    fn toggle_line_numbers_changes_gutter() {
1527        let (m, mut idx) = setup(b"a\nb\nc\n");
1528        let mut v = Viewport::new(10, 5, "f".into());
1529        let frame_off = v.frame(&m, &mut idx);
1530        v.toggle_line_numbers();
1531        let frame_on = v.frame(&m, &mut idx);
1532        // With gutter, first cell is a digit or space, not 'a'.
1533        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1534        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1535    }
1536
1537    #[test]
1538    fn toggle_chop_changes_wrap_mode() {
1539        let (m, mut idx) = setup(b"abcdefghij\n");
1540        let mut v = Viewport::new(4, 5, "f".into());
1541        v.toggle_chop();
1542        let frame = v.frame(&m, &mut idx);
1543        // After toggle_chop, the line is one row, not wrapped.
1544        // Body row 0 is "abcd"; rows 1..3 are blank fill.
1545        assert_eq!(frame.body[0][..4],
1546            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1547             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1548             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1549             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1550        // Row 1 should be all-empty (no wrap continuation).
1551        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1552    }
1553
1554    // ----- Follow mode -----
1555
1556    #[test]
1557    fn is_at_bottom_initially_only_when_source_fits() {
1558        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
1559        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
1560        idx.extend_to_end(&m);
1561        assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1562    }
1563
1564    #[test]
1565    fn is_at_bottom_false_when_top_and_more_lines_below() {
1566        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1567        let v = Viewport::new(10, 5, "f".into());  // body = 4
1568        idx.extend_to_end(&m);
1569        assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1570    }
1571
1572    #[test]
1573    fn is_at_bottom_true_after_goto_bottom() {
1574        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1575        let mut v = Viewport::new(10, 5, "f".into());
1576        v.goto_bottom(&m, &mut idx);
1577        assert!(v.is_at_bottom(&idx));
1578    }
1579
1580    #[test]
1581    fn status_shows_follow_suffix_when_follow_mode_on() {
1582        let (m, mut idx) = setup(b"a\nb\n");
1583        let mut v = Viewport::new(20, 5, "f".into());
1584        let frame_off = v.frame(&m, &mut idx);
1585        assert!(!frame_off.status.contains("(F)"));
1586        v.set_follow_mode(true);
1587        let frame_on = v.frame(&m, &mut idx);
1588        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1589    }
1590
1591    #[test]
1592    fn toggle_follow_flips_state() {
1593        let mut v = Viewport::new(10, 5, "f".into());
1594        assert!(!v.follow_mode());
1595        v.toggle_follow();
1596        assert!(v.follow_mode());
1597        v.toggle_follow();
1598        assert!(!v.follow_mode());
1599    }
1600
1601    #[test]
1602    fn status_shows_prettify_label_when_set() {
1603        let (m, mut idx) = setup(b"a\n");
1604        let mut v = Viewport::new(40, 5, "f".into());
1605        let frame_off = v.frame(&m, &mut idx);
1606        assert!(!frame_off.status.contains("[pretty"));
1607        v.set_prettify_label(Some("json".into()));
1608        let frame_on = v.frame(&m, &mut idx);
1609        assert!(frame_on.status.contains("[pretty:json]"),
1610            "expected [pretty:json] in status, got: {}", frame_on.status);
1611        v.set_prettify_label(Some("json:err".into()));
1612        let frame_err = v.frame(&m, &mut idx);
1613        assert!(frame_err.status.contains("[pretty:json:err]"),
1614            "expected [pretty:json:err] in status, got: {}", frame_err.status);
1615    }
1616
1617    #[test]
1618    fn status_shows_l_suffix_when_live_mode_on() {
1619        let (m, mut idx) = setup(b"a\nb\n");
1620        let mut v = Viewport::new(20, 5, "f".into());
1621        let frame_off = v.frame(&m, &mut idx);
1622        assert!(!frame_off.status.contains("(L)"));
1623        v.set_live_mode(true);
1624        let frame_on = v.frame(&m, &mut idx);
1625        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1626    }
1627
1628    #[test]
1629    fn clamp_top_line_pulls_back_when_total_shrinks() {
1630        let mut v = Viewport::new(20, 5, "f".into());
1631        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
1632        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
1633        // Force top_line via a sequence; easiest: just call clamp directly.
1634        // We can't poke private state, but clamp works regardless of how we got there.
1635        v.clamp_top_line(100);  // total bigger than top_line=0, no change
1636        v.clamp_top_line(0);    // empty source: must reset
1637        // After clamp(0), line 0 is the floor.
1638        // (No public getter for top_line; we verify indirectly by going to top.)
1639        v.goto_top();
1640        // Just confirm no panic and no overflow on subsequent frame composition.
1641        let (m, mut idx) = setup(b"only\n");
1642        let _ = v.frame(&m, &mut idx);
1643    }
1644
1645    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
1646    /// when follow mode is on and the viewport is at the bottom.
1647    fn simulate_growth_tick(
1648        v: &mut Viewport,
1649        src: &MockSource,
1650        idx: &mut LineIndex,
1651    ) {
1652        if !v.follow_mode() { return; }
1653        let was_at_bottom = v.is_at_bottom(idx);
1654        let lines_before = idx.line_count();
1655        idx.notice_new_bytes(src);
1656        if idx.line_count() != lines_before && was_at_bottom {
1657            v.goto_bottom(src, idx);
1658        }
1659    }
1660
1661    #[test]
1662    fn auto_scroll_engages_when_at_bottom() {
1663        let m = MockSource::new();
1664        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
1665        let mut idx = LineIndex::new();
1666        let mut v = Viewport::new(10, 5, "f".into());
1667        v.set_follow_mode(true);
1668        idx.extend_to_end(&m);
1669        assert!(v.is_at_bottom(&idx));
1670        let top_before = {
1671            let f = v.frame(&m, &mut idx);
1672            f.status.clone()  // unused, just exercise frame
1673        };
1674        let _ = top_before;
1675        // Simulate growth: source gains 4 more lines.
1676        m.append(b"5\n6\n7\n8\n");
1677        simulate_growth_tick(&mut v, &m, &mut idx);
1678        // After auto-scroll, top_line should have advanced so the new last line is in view.
1679        assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1680        let frame = v.frame(&m, &mut idx);
1681        // The bottom-most body row should now contain the last logical line ('8').
1682        // Find which row has '8'.
1683        let last_row = &frame.body[frame.body.len() - 1];
1684        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1685    }
1686
1687    #[test]
1688    fn auto_scroll_suppressed_when_scrolled_up() {
1689        let m = MockSource::new();
1690        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1691        let mut idx = LineIndex::new();
1692        let mut v = Viewport::new(10, 5, "f".into());  // body=4
1693        v.set_follow_mode(true);
1694        idx.extend_to_end(&m);
1695        v.goto_bottom(&m, &mut idx);
1696        // Now scroll up off the bottom.
1697        v.scroll_lines(-2, &m, &mut idx);
1698        assert!(!v.is_at_bottom(&idx));
1699        let frame_before = v.frame(&m, &mut idx);
1700        let top_first_cell_before = frame_before.body[0][0].clone();
1701        // Simulate growth.
1702        m.append(b"9\n10\n");
1703        simulate_growth_tick(&mut v, &m, &mut idx);
1704        // Viewport should NOT have moved (auto-scroll suppressed).
1705        let frame_after = v.frame(&m, &mut idx);
1706        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1707    }
1708
1709    // ----- Search -----
1710
1711    #[test]
1712    fn set_search_compiles_regex() {
1713        let mut v = Viewport::new(10, 5, "f".into());
1714        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1715        assert!(v.search_active());
1716    }
1717
1718    #[test]
1719    fn set_search_rejects_bad_regex() {
1720        let mut v = Viewport::new(10, 5, "f".into());
1721        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1722        assert!(!err.is_empty());
1723        assert!(!v.search_active(), "no search should be set on error");
1724    }
1725
1726    #[test]
1727    fn search_step_forward_finds_match_after_top() {
1728        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1729        let mut v = Viewport::new(20, 5, "f".into());
1730        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1731        let found = v.search_repeat(&m, &mut idx, false);
1732        assert!(found);
1733        // gamma is line 2 (0-indexed)
1734        assert_eq!(v.top_line, 2);
1735    }
1736
1737    #[test]
1738    fn search_step_backward_finds_match_before_top() {
1739        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1740        let mut v = Viewport::new(20, 5, "f".into());
1741        v.scroll_lines(4, &m, &mut idx); // top_line = 4
1742        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1743        let found = v.search_repeat(&m, &mut idx, false);
1744        assert!(found);
1745        assert_eq!(v.top_line, 0);
1746    }
1747
1748    #[test]
1749    fn search_wraps_at_end() {
1750        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1751        let mut v = Viewport::new(20, 5, "f".into());
1752        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
1753        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1754        let found = v.search_repeat(&m, &mut idx, false);
1755        assert!(found, "search should wrap forward past EOF");
1756        assert_eq!(v.top_line, 0);
1757    }
1758
1759    #[test]
1760    fn search_no_match_returns_false_and_does_not_move() {
1761        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1762        let mut v = Viewport::new(20, 5, "f".into());
1763        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1764        let found = v.search_repeat(&m, &mut idx, false);
1765        assert!(!found);
1766        assert_eq!(v.top_line, 0);
1767    }
1768
1769    #[test]
1770    fn frame_records_highlight_ranges_for_matches() {
1771        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1772        let mut v = Viewport::new(20, 5, "f".into());
1773        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1774        let frame = v.frame(&m, &mut idx);
1775        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
1776        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1777        assert!(frame.highlights[0].is_empty());
1778        assert!(frame.highlights[1].is_empty());
1779        assert_eq!(frame.highlights[2], vec![0..5]);
1780        assert!(frame.highlights[3].is_empty());
1781    }
1782
1783    #[test]
1784    fn frame_highlights_substring_inside_a_row() {
1785        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1786        let mut v = Viewport::new(40, 5, "f".into());
1787        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1788        let frame = v.frame(&m, &mut idx);
1789        // "beta" starts at column 18 in the first row.
1790        assert_eq!(frame.highlights[0], vec![18..22]);
1791        assert!(frame.highlights[1].is_empty());
1792    }
1793
1794    #[test]
1795    fn search_highlight_with_filter_dim_keeps_row_dim() {
1796        // alpha matches filter → Normal. beta doesn't → Dim. Search for
1797        // "beta" should leave row style Dim and mark the substring 0..4.
1798        let (m, mut idx) = setup(b"alpha\nbeta\n");
1799        let mut v = Viewport::new(20, 5, "f".into());
1800        let fmt = crate::format::LogFormat::compile(
1801            "simple",
1802            r"^(?P<line>.+)$",
1803        )
1804        .unwrap();
1805        let f = crate::filter::CompiledFilter::compile(
1806            &fmt,
1807            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1808        )
1809        .unwrap();
1810        v.set_filter(Some(f));
1811        v.set_dim_mode(true);
1812        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1813        let frame = v.frame(&m, &mut idx);
1814        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1815        assert_eq!(frame.row_styles[1], RowStyle::Dim);
1816        assert_eq!(frame.highlights[1], vec![0..4]);
1817    }
1818
1819    #[test]
1820    fn grep_only_hides_non_matching_lines() {
1821        use crate::grep::GrepPredicate;
1822        let src = crate::source::MockSource::new();
1823        src.append(b"keep this error\n");
1824        src.append(b"drop this one\n");
1825        src.append(b"another error line\n");
1826        src.finish();
1827        let mut idx = crate::line_index::LineIndex::new();
1828        idx.extend_to_end(&src);
1829
1830        let mut v = Viewport::new(40, 5, "test".into());
1831        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1832        v.extend_visible_lines(&idx, &src);
1833
1834        // Only the two "error" lines should be visible.
1835        let frame = v.frame(&src, &mut idx);
1836        let body_text: Vec<String> = frame.body.iter()
1837            .map(|row| row.iter().filter_map(|c| match c {
1838                crate::render::Cell::Char { ch, .. } => Some(*ch),
1839                _ => None,
1840            }).collect())
1841            .collect();
1842        assert!(body_text[0].contains("keep this error"));
1843        assert!(body_text[1].contains("another error line"));
1844        assert!(frame.status.contains("[grep]"));
1845    }
1846
1847    #[test]
1848    fn filter_and_grep_combine_with_and() {
1849        use crate::grep::GrepPredicate;
1850        let fmt = crate::format::LogFormat::compile(
1851            "simple",
1852            r"^(?P<level>\w+) (?P<msg>.+)$",
1853        ).unwrap();
1854        let f = crate::filter::CompiledFilter::compile(
1855            &fmt,
1856            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1857        ).unwrap();
1858        let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1859
1860        let src = crate::source::MockSource::new();
1861        src.append(b"ERROR timeout connecting\n");      // matches both → keep
1862        src.append(b"ERROR file not found\n");          // matches filter only → drop
1863        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
1864        src.append(b"INFO all good\n");                 // matches neither → drop
1865        src.finish();
1866        let mut idx = crate::line_index::LineIndex::new();
1867        idx.extend_to_end(&src);
1868
1869        let mut v = Viewport::new(80, 5, "test".into());
1870        v.set_filter(Some(f));
1871        v.set_grep(Some(g));
1872        v.extend_visible_lines(&idx, &src);
1873        assert_eq!(v.visible_lines(), &[0usize]);
1874    }
1875
1876    #[test]
1877    fn search_status_shows_pattern() {
1878        let (m, mut idx) = setup(b"x\n");
1879        let mut v = Viewport::new(20, 5, "f".into());
1880        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1881        let frame = v.frame(&m, &mut idx);
1882        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1883    }
1884
1885    #[test]
1886    fn repeat_search_after_first_match_advances() {
1887        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1888        let mut v = Viewport::new(40, 5, "f".into());
1889        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1890        assert!(v.search_repeat(&m, &mut idx, false));
1891        assert_eq!(v.top_line, 1, "first foo");
1892        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1893        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1894        assert_eq!(v.top_line, 3, "should advance to next foo");
1895    }
1896
1897    #[test]
1898    fn auto_scroll_paused_when_follow_off() {
1899        let m = MockSource::new();
1900        m.append(b"1\n2\n3\n4\n");
1901        let mut idx = LineIndex::new();
1902        let mut v = Viewport::new(10, 5, "f".into());
1903        // Follow is off; viewport at top.
1904        idx.extend_to_end(&m);
1905        let frame_before = v.frame(&m, &mut idx);
1906        let top_first_cell = frame_before.body[0][0].clone();
1907        m.append(b"5\n6\n7\n8\n");
1908        simulate_growth_tick(&mut v, &m, &mut idx);
1909        let frame_after = v.frame(&m, &mut idx);
1910        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1911    }
1912
1913    // ----- Records-mode search -----
1914
1915    #[test]
1916    fn search_jumps_to_next_matching_record() {
1917        let m = MockSource::new();
1918        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
1919        let mut idx = LineIndex::new();
1920        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1921        idx.extend_to_end(&m);
1922        let mut v = Viewport::new(40, 10, "f".into());
1923        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1924        let hit = v.search_repeat(&m, &mut idx, false);
1925        assert!(hit, "should find 'charlie' in record 2");
1926        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
1927    }
1928
1929    #[test]
1930    fn search_finds_cross_line_match_in_record_with_s_flag() {
1931        let m = MockSource::new();
1932        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
1933        let mut idx = LineIndex::new();
1934        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1935        idx.extend_to_end(&m);
1936        let mut v = Viewport::new(40, 10, "f".into());
1937        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1938        let hit = v.search_repeat(&m, &mut idx, false);
1939        assert!(hit, "should match across \\n inside record 0 with (?s)");
1940        assert_eq!(v.top_line(), 0);
1941    }
1942
1943    #[test]
1944    fn search_repeat_with_no_match_returns_false() {
1945        let m = MockSource::new();
1946        m.append(b"[1] alpha\n[2] bravo\n");
1947        let mut idx = LineIndex::new();
1948        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1949        idx.extend_to_end(&m);
1950        let mut v = Viewport::new(40, 10, "f".into());
1951        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1952        let hit = v.search_repeat(&m, &mut idx, false);
1953        assert!(!hit);
1954    }
1955
1956    // ----- Records-mode filter/grep -----
1957
1958    #[test]
1959    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1960        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
1961        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
1962        let m = MockSource::new();
1963        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
1964        let mut idx = LineIndex::new();
1965        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1966        idx.extend_to_end(&m);
1967        let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1968        let mut v = Viewport::new(40, 10, "f".into());
1969        v.set_grep(Some(grep));
1970        v.extend_visible_lines(&idx, &m);
1971        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
1972        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
1973        assert_eq!(v.visible_lines(), &[0usize, 1]);
1974    }
1975
1976    #[test]
1977    fn filter_in_records_mode_keeps_whole_record_when_header_matches() {
1978        // The format regex is designed for the header line (it ends with `$`).
1979        // Applied to the full multi-line record bytes it would never match
1980        // because `$` doesn't match before a non-final `\n`. Records-mode
1981        // filter must evaluate against the first line of the record, then
1982        // include all of the record's lines when it matches.
1983        let m = MockSource::new();
1984        m.append(
1985            b"[1] kind=category\n  body a\n  body a2\n[2] kind=rule\n  body b\n",
1986        );
1987        let mut idx = LineIndex::new();
1988        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1989        idx.extend_to_end(&m);
1990        let fmt = crate::format::LogFormat::compile(
1991            "rec",
1992            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
1993        )
1994        .unwrap();
1995        let f = crate::filter::CompiledFilter::compile(
1996            &fmt,
1997            vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
1998        )
1999        .unwrap();
2000        let mut v = Viewport::new(40, 10, "f".into());
2001        v.set_filter(Some(f));
2002        v.extend_visible_lines(&idx, &m);
2003        // Record 0 (lines 0, 1, 2) matches; record 1 (lines 3, 4) does not.
2004        assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
2005    }
2006
2007    #[test]
2008    fn grep_matches_across_record_newlines_in_records_mode() {
2009        // Pattern spans the record-header and a continuation line (needs (?s) for .).
2010        let m = MockSource::new();
2011        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
2012        let mut idx = LineIndex::new();
2013        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2014        idx.extend_to_end(&m);
2015        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
2016        let mut v = Viewport::new(40, 10, "f".into());
2017        v.set_grep(Some(grep));
2018        v.extend_visible_lines(&idx, &m);
2019        // Record 0 matches (cross-line); record 1 does not.
2020        assert_eq!(v.visible_lines(), &[0usize, 1]);
2021    }
2022
2023    #[test]
2024    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
2025        // All 4 lines stay in visible_lines (dim mode = no hiding).
2026        // Record 0 matches grep → Normal; record 1 does not → Dim.
2027        let m = MockSource::new();
2028        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
2029        let mut idx = LineIndex::new();
2030        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2031        idx.extend_to_end(&m);
2032        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
2033        let mut v = Viewport::new(40, 10, "f".into());
2034        v.set_grep(Some(grep));
2035        v.set_dim_mode(true);
2036        v.extend_visible_lines(&idx, &m);
2037        // Dim mode: visible_lines stays empty (hide_mode() is false).
2038        assert_eq!(v.visible_lines(), &[] as &[usize]);
2039        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
2040        assert!(!v.should_dim_line(0, &idx, &m));
2041        assert!(!v.should_dim_line(1, &idx, &m));
2042        // Lines 2 and 3 belong to non-matching record → Dim.
2043        assert!(v.should_dim_line(2, &idx, &m));
2044        assert!(v.should_dim_line(3, &idx, &m));
2045    }
2046
2047    #[test]
2048    fn status_unchanged_when_records_inactive() {
2049        let (m, mut idx) = setup(b"a\nb\nc\n");
2050        let mut v = Viewport::new(20, 5, "f".into());
2051        let frame = v.frame(&m, &mut idx);
2052        let status = &frame.status;
2053        // Default format: <label>  <top>-<bot>/<total>  <pct>%
2054        assert!(status.contains("1-3/3"), "got: {status}");
2055        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
2056        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
2057    }
2058
2059    #[test]
2060    fn status_r_block_uses_real_lines_in_hide_mode() {
2061        // Regression: in hide mode `bottom` is a position in visible_lines
2062        // (i.e. a count of *visible* matches), not a logical line index.
2063        // The R-block was passing that position into `line_to_record`, which
2064        // resolved to whatever record contained logical line `bottom-1` —
2065        // typically a very early record, producing nonsense like `R290-8`
2066        // where the bottom record is *before* the top record on screen.
2067        // Build a scenario: many records, only the last few match the filter,
2068        // and the viewport is scrolled to the matching tail.
2069        let m = MockSource::new();
2070        // 10 records, two physical lines each. Record N's header has `kind=A`
2071        // for N < 8 and `kind=B` for N >= 8 (so only records 8 and 9 match).
2072        let mut buf = Vec::new();
2073        for n in 0..10 {
2074            let kind = if n >= 8 { "B" } else { "A" };
2075            buf.extend_from_slice(format!("[{}] kind={}\n  body {}\n", n, kind, n).as_bytes());
2076        }
2077        m.append(&buf);
2078        m.finish();
2079
2080        let mut idx = LineIndex::new();
2081        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2082        idx.extend_to_end(&m);
2083
2084        let fmt = crate::format::LogFormat::compile(
2085            "rec",
2086            r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
2087        )
2088        .unwrap();
2089        let f = crate::filter::CompiledFilter::compile(
2090            &fmt,
2091            vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
2092        )
2093        .unwrap();
2094
2095        // 5-row terminal: 4 body rows + 1 status row. With 4 visible-matches
2096        // rows of body and 4 visible lines, the whole filtered set fits.
2097        let mut v = Viewport::new(80, 5, "f".into());
2098        v.set_filter(Some(f));
2099        v.extend_visible_lines(&idx, &m);
2100
2101        // Jump to the first matching record (record 8, 0-indexed).
2102        v.goto_record(8, &m, &mut idx);
2103
2104        let frame = v.frame(&m, &mut idx);
2105        // Records 8 (rec_top=9) and 9 (rec_bottom=10) are on screen.
2106        assert!(
2107            frame.status.contains("R9-10/10"),
2108            "expected R9-10/10 in status, got: {}",
2109            frame.status,
2110        );
2111    }
2112
2113    #[test]
2114    fn status_dual_readout_when_records_active() {
2115        let m = MockSource::new();
2116        m.append(b"[1] a\n  cont\n[2] b\n");
2117        m.finish();
2118        let mut idx = LineIndex::new();
2119        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
2120        idx.extend_to_end(&m);
2121        let mut v = Viewport::new(20, 5, "f".into());
2122        let frame = v.frame(&m, &mut idx);
2123        let status = &frame.status;
2124        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
2125        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
2126    }
2127
2128    #[test]
2129    fn format_status_uses_custom_template_when_set() {
2130        let m = MockSource::new();
2131        m.append(b"a\nb\nc\n");
2132        m.finish();
2133        let mut idx = LineIndex::new();
2134        idx.extend_to_end(&m);
2135        let mut v = Viewport::new(20, 5, "f".into());
2136        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
2137        v.set_prompt(Some(prompt));
2138        let frame = v.frame(&m, &mut idx);
2139        assert_eq!(frame.status, "f 100%");
2140    }
2141
2142    #[test]
2143    fn status_shows_preprocess_failed_tag_when_set() {
2144        let m = MockSource::new();
2145        m.append(b"a\n");
2146        let mut idx = LineIndex::new();
2147        idx.extend_to_end(&m);
2148        let mut v = Viewport::new(40, 5, "f".into());
2149        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
2150        let frame = v.frame(&m, &mut idx);
2151        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
2152                "got: {}", frame.status);
2153    }
2154
2155    #[test]
2156    fn default_status_includes_help_hint() {
2157        let (m, mut idx) = setup(b"a\nb\nc\n");
2158        let mut v = Viewport::new(80, 5, "f".into());
2159        let frame = v.frame(&m, &mut idx);
2160        assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
2161    }
2162
2163    #[test]
2164    fn custom_prompt_does_not_get_help_hint() {
2165        let (m, mut idx) = setup(b"a\nb\nc\n");
2166        let mut v = Viewport::new(80, 5, "f".into());
2167        v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
2168        let frame = v.frame(&m, &mut idx);
2169        assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
2170    }
2171
2172    #[test]
2173    fn status_shows_file_index_when_multifile() {
2174        let m = MockSource::new();
2175        m.append(b"a\n");
2176        let mut idx = LineIndex::new();
2177        idx.extend_to_end(&m);
2178        let mut v = Viewport::new(60, 5, "f.log".into());
2179        v.set_file_index(0, 3);
2180        let frame = v.frame(&m, &mut idx);
2181        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
2182    }
2183
2184    #[test]
2185    fn status_omits_file_index_when_single_file() {
2186        let m = MockSource::new();
2187        m.append(b"a\n");
2188        let mut idx = LineIndex::new();
2189        idx.extend_to_end(&m);
2190        let mut v = Viewport::new(60, 5, "f.log".into());
2191        v.set_file_index(0, 1);
2192        let frame = v.frame(&m, &mut idx);
2193        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2194    }
2195
2196    #[test]
2197    fn status_shows_tag_active_when_multimatch() {
2198        let m = MockSource::new();
2199        m.append(b"a\n");
2200        let mut idx = LineIndex::new();
2201        idx.extend_to_end(&m);
2202        let mut v = Viewport::new(80, 5, "f.log".into());
2203        v.set_tag_active(Some(("foo".into(), 2, 3)));
2204        let frame = v.frame(&m, &mut idx);
2205        assert!(
2206            frame.status.contains("[tag: foo (2/3)]"),
2207            "got: {}",
2208            frame.status
2209        );
2210    }
2211
2212    #[test]
2213    fn status_omits_tag_active_when_single_match() {
2214        let m = MockSource::new();
2215        m.append(b"a\n");
2216        let mut idx = LineIndex::new();
2217        idx.extend_to_end(&m);
2218        let mut v = Viewport::new(80, 5, "f.log".into());
2219        v.set_tag_active(Some(("foo".into(), 1, 1)));
2220        let frame = v.frame(&m, &mut idx);
2221        assert!(
2222            !frame.status.contains("[tag:"),
2223            "should not show indicator for single match: {}",
2224            frame.status
2225        );
2226    }
2227
2228    // ----- SGR state reconstruction tests -----
2229
2230    #[test]
2231    fn reconstruct_picks_up_state_from_prior_lines() {
2232        let m = MockSource::new();
2233        m.append(b"\x1b[31mline 1\n");
2234        m.append(b"line 2 (still red, no reset)\n");
2235        m.append(b"line 3\n");
2236        let mut idx = LineIndex::new();
2237        idx.extend_to_end(&m);
2238        let state = reconstruct_render_state(&m, &idx, 2);
2239        assert_eq!(
2240            state.style.fg,
2241            Some(crate::ansi::Color::Ansi(1)),
2242            "red SGR from line 0 should persist to line 2"
2243        );
2244    }
2245
2246    #[test]
2247    fn reconstruct_respects_reset_between_lines() {
2248        let m = MockSource::new();
2249        m.append(b"\x1b[31mline 1\x1b[0m\n");
2250        m.append(b"line 2 (default)\n");
2251        let mut idx = LineIndex::new();
2252        idx.extend_to_end(&m);
2253        let state = reconstruct_render_state(&m, &idx, 1);
2254        assert_eq!(state.style.fg, None);
2255    }
2256
2257    #[test]
2258    fn reconstruct_caps_walkback_at_max_lines() {
2259        let m = MockSource::new();
2260        m.append(b"\x1b[31mvery early\n");
2261        for _ in 0..300 {
2262            m.append(b"line\n");
2263        }
2264        let mut idx = LineIndex::new();
2265        idx.extend_to_end(&m);
2266        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
2267        // anchor we'd pick is line 34 (290 - 256), which is past the red.
2268        let state = reconstruct_render_state(&m, &idx, 290);
2269        assert_eq!(state.style.fg, None);
2270    }
2271}