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            let bytes = idx.record_bytes_stripped(r, src);
493            if self.line_passes(&bytes) {
494                for line_n in idx.record_line_range(r) {
495                    self.visible_lines.push(line_n);
496                }
497            }
498        }
499    }
500
501    /// Combined predicate: bytes pass iff the (optional) filter matches AND
502    /// the (optional) grep matches. Missing predicates vacuously pass.
503    /// In line mode, `bytes` is a single line. In records mode, `bytes` is
504    /// the full record (with embedded `\n`s) — callers are responsible for
505    /// passing the right granularity.
506    fn line_passes(&self, line: &[u8]) -> bool {
507        let filter_ok = match self.filter.as_ref() {
508            Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
509            None => true,
510        };
511        let grep_ok = match self.grep.as_ref() {
512            Some(g) => g.matches(line),
513            None => true,
514        };
515        filter_ok && grep_ok
516    }
517
518    /// Return true iff line `line_n` should be rendered dim. In records mode,
519    /// the match decision is made once per record and applied to all its
520    /// physical lines. In line mode, the decision is made per line.
521    fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
522        if !self.dim_mode {
523            return false;
524        }
525        if idx.records_mode() {
526            let r = idx.line_to_record(line_n);
527            let bytes = idx.record_bytes_stripped(r, src);
528            !self.line_passes(&bytes)
529        } else {
530            let bytes = idx.line_bytes_stripped(line_n, src);
531            !self.line_passes(&bytes)
532        }
533    }
534
535    pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
536
537    pub fn follow_mode(&self) -> bool { self.follow_mode }
538
539    pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
540
541    pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
542
543    pub fn live_mode(&self) -> bool { self.live_mode }
544
545    pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
546
547    /// Status-line label for active pretty-print state, e.g. `"json"` or
548    /// `"json:err"`. `None` means no indicator is shown.
549    pub fn set_prettify_label(&mut self, label: Option<String>) {
550        self.prettify_label = label;
551    }
552
553    /// Active --format name shown in <format-tag>. Set from main when a named
554    /// format is resolved; independent of whether --filter is also active.
555    pub fn set_format_label(&mut self, label: Option<String>) {
556        self.format_label = label;
557    }
558
559    /// Drop the per-line filter-membership cache without disturbing the filter
560    /// itself or scroll position. Used after a `--live` rebuild: line numbering
561    /// may have changed, so cached `visible_lines` is stale, but we want to
562    /// keep the same filter applied and let the user stay where they were.
563    pub fn invalidate_filter_cache(&mut self) {
564        self.visible_lines.clear();
565        self.visible_scanned = 0;
566    }
567
568    /// Clamp `top_line` so it doesn't fall past the new end of the source.
569    /// Pairs with `invalidate_filter_cache` after a content rewrite.
570    pub fn clamp_top_line(&mut self, line_count: usize) {
571        if line_count == 0 {
572            self.top_line = 0;
573            self.top_row = 0;
574        } else if self.top_line >= line_count {
575            self.top_line = line_count - 1;
576            self.top_row = 0;
577        }
578    }
579
580    /// True when the viewport's body window already covers the last line of
581    /// the source. New content added past this point should auto-scroll if
582    /// follow mode is on.
583    pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
584        let body = self.body_rows() as usize;
585        if self.hide_mode() {
586            // top_line is a logical line; find its position in visible_lines.
587            let pos = self
588                .visible_lines
589                .iter()
590                .position(|&l| l >= self.top_line)
591                .unwrap_or(self.visible_lines.len());
592            pos + body >= self.visible_lines.len()
593        } else {
594            self.top_line + body >= idx.line_count()
595        }
596    }
597
598    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
599    fn gutter_width(&self, idx: &LineIndex) -> u16 {
600        if !self.show_line_numbers { return 0; }
601        let n = idx.line_count().max(1);
602        let digits = (n as f64).log10().floor() as u16 + 1;
603        digits + 1
604    }
605
606    fn render_opts(&self, gutter: u16) -> RenderOpts {
607        let mut o = self.opts.clone();
608        o.cols = self.cols.saturating_sub(gutter);
609        o.mode = self.ansi_mode;
610        o
611    }
612
613    pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
614        if self.hex_mode {
615            return self.frame_hex(src);
616        }
617        let body_rows = self.body_rows() as usize;
618        idx.extend_to_line(self.top_line + body_rows + 1, src);
619
620        let gutter = self.gutter_width(idx);
621        let r_opts = self.render_opts(gutter);
622
623        // Reconstruct per-line SGR state for the start of the visible window so
624        // that unclosed SGR sequences on lines above top_line carry through.
625        // Only meaningful in Interpret mode; harmless (and cheap) to skip otherwise.
626        let mut render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
627            reconstruct_render_state(src, idx, self.top_line)
628        } else {
629            crate::render::RenderState::default()
630        };
631        // Store in the struct field for future cache use; mark current top_line.
632        self.render_state = render_state.clone();
633        self.render_state_for = self.top_line;
634
635        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
636        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
637        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
638        // In hide mode we walk visible_lines; otherwise we walk logical lines.
639        let hide = self.hide_mode();
640        let total_lines = idx.line_count();
641
642        // For hide mode, find where the viewport starts in visible_lines.
643        let mut hide_pos = if hide {
644            self.visible_lines
645                .iter()
646                .position(|&l| l >= self.top_line)
647                .unwrap_or(self.visible_lines.len())
648        } else {
649            0
650        };
651        let mut line_n = if hide {
652            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
653        } else {
654            self.top_line
655        };
656        let mut skip = if hide { 0 } else { self.top_row };
657
658        while body.len() < body_rows {
659            if line_n >= total_lines {
660                let mut row = Vec::with_capacity(self.cols as usize);
661                if gutter > 0 {
662                    for _ in 0..gutter { row.push(Cell::Empty); }
663                }
664                while row.len() < self.cols as usize { row.push(Cell::Empty); }
665                body.push(row);
666                row_styles.push(RowStyle::Normal);
667                highlights.push(Vec::new());
668                line_n += 1;
669                continue;
670            }
671            // Filter evaluation runs on the raw line (it uses captures, not
672            // text), but rendering goes through the template if one is set.
673            let raw = src.bytes(idx.line_range(line_n, src));
674            let display_bytes = if let Some(r) = self.display.as_ref() {
675                match r.render_line(&raw) {
676                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
677                    None => raw.clone(),
678                }
679            } else {
680                raw.clone()
681            };
682            let state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
683                Some(&mut render_state)
684            } else {
685                None
686            };
687            let rows = render_line(&display_bytes, &r_opts, state_arg);
688            let style = if self.filter.is_some() || self.grep.is_some() {
689                if self.dim_mode {
690                    if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
691                } else {
692                    // hide mode: only matching lines reach here
693                    RowStyle::Normal
694                }
695            } else {
696                RowStyle::Normal
697            };
698
699            for (i, mut content_row) in rows.into_iter().enumerate() {
700                if i < skip { continue; }
701                if body.len() >= body_rows { break; }
702                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
703                if gutter > 0 {
704                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
705                    for c in label.chars() {
706                        full.push(Cell::Char { ch: c, width: 1, style: crate::ansi::Style::default(), hyperlink: None });
707                    }
708                }
709                full.append(&mut content_row);
710                // Compute search highlights for this display row by running
711                // the regex against the row's rendered text. Each match's
712                // char range maps to a cell column range via `starts`.
713                let row_highlights = if let Some(s) = self.search.as_ref() {
714                    find_row_highlights(&full, &s.regex)
715                } else {
716                    Vec::new()
717                };
718                body.push(full);
719                row_styles.push(style);
720                highlights.push(row_highlights);
721            }
722            skip = 0;
723            // Advance to next line — visible-space if hiding, logical-space otherwise.
724            if hide {
725                hide_pos += 1;
726                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
727            } else {
728                line_n += 1;
729            }
730        }
731
732        // After walking through the frame, render_state has been advanced past
733        // top_line. Invalidate the cached sentinel so next frame re-reconstructs.
734        self.render_state_for = usize::MAX;
735
736        let status = self.format_status(idx, src);
737        Frame { body, row_styles, highlights, status }
738    }
739
740    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
741        if let Some(p) = self.prompt.as_ref() {
742            let ctx = self.build_prompt_context(idx, src);
743            return p.render(&ctx);
744        }
745        let body_rows = self.body_rows() as usize;
746        let total = idx.line_count();
747        // In hide mode, the line range and percentage refer to visible (matched)
748        // lines, not the underlying logical line count.
749        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
750            let visible_total = self.visible_lines.len();
751            // top_line is a logical line; find its visible index.
752            let cur = self
753                .visible_lines
754                .iter()
755                .position(|&l| l >= self.top_line)
756                .unwrap_or(visible_total);
757            let top = cur + 1;
758            let bottom = (cur + body_rows).min(visible_total.max(1));
759            let total_str = if src.is_complete() {
760                format!("{visible_total}/{total}")
761            } else {
762                format!("{visible_total}/{total}+")
763            };
764            (top, bottom, visible_total, total_str)
765        } else {
766            let top = self.top_line + 1;
767            let bottom = (self.top_line + body_rows).min(total.max(1));
768            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
769            (top, bottom, total, total_str)
770        };
771        let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
772        // In records mode, prefix line numbers with 'L' and append an 'R' record block.
773        let (line_prefix, records_block) = if idx.records_mode() {
774            let line_total = idx.line_count();
775            let rec_total = idx.record_count();
776            let rec_block = if line_total == 0 || rec_total == 0 {
777                format!("R0-0/{}", rec_total)
778            } else {
779                let rec_top = idx.line_to_record(self.top_line) + 1;
780                let rec_bottom = idx.line_to_record(bottom.saturating_sub(1)) + 1;
781                format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
782            };
783            ("L", Some(rec_block))
784        } else {
785            ("", None)
786        };
787        let middle = match records_block {
788            Some(ref rb) => format!("{}{}-{}/{}  {}  {}%", line_prefix, top, bottom, total_str, rb, pct),
789            None         => format!("{}-{}/{}  {}%", top, bottom, total_str, pct),
790        };
791        let label_with_index = match self.file_index {
792            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
793            None => self.source_label.clone(),
794        };
795        let mut s = format!("{}  {}", label_with_index, middle);
796        // Wrap-row offset: when scrolled inside a long wrapping line, surface
797        // the offset so the user knows scrolling is happening at sub-line
798        // granularity. Without this the line range above stays static while
799        // pressing `j` and the scroll is invisible on repeating content.
800        if !self.hide_mode() && self.top_row > 0 {
801            let line_rows = if total > 0 {
802                let bytes = self.line_display_bytes(src, idx, self.top_line);
803                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
804            } else { 1 };
805            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
806        }
807        if let Some(f) = self.filter.as_ref() {
808            s.push_str(&format!("  [{}]", f.format_name));
809        }
810        if self.grep.is_some() {
811            s.push_str("  [grep]");
812        }
813        if self.filter.is_some() || self.grep.is_some() {
814            s.push_str(if self.dim_mode { "  [dim]" } else { "  [hide]" });
815        }
816        if let Some(sr) = self.search.as_ref() {
817            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
818            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
819        }
820        if let Some(label) = self.prettify_label.as_ref() {
821            s.push_str(&format!("  [pretty:{label}]"));
822        }
823        if self.live_mode { s.push_str("  (L)"); }
824        if self.follow_mode { s.push_str("  (F)"); }
825        if let Some(msg) = self.preprocess_failure.as_ref() {
826            let first_line = msg.lines().next().unwrap_or("");
827            s.push_str(&format!("  [preprocess-failed: {}]", first_line));
828        }
829        let tag_suffix = match &self.tag_active {
830            Some((name, cur, total)) if *total > 1 => {
831                format!("  [tag: {name} ({cur}/{total})]")
832            }
833            _ => String::new(),
834        };
835        s.push_str(&tag_suffix);
836        s
837    }
838
839    fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
840        use crate::prompt::PromptContext;
841
842        let body_rows = self.body_rows() as usize;
843        let total = idx.line_count();
844        let top = self.top_line + 1;
845        let bottom = (self.top_line + body_rows).min(total.max(1));
846        let pct = (bottom * 100).checked_div(total).unwrap_or(0);
847
848        let records_mode = idx.records_mode();
849        let (rec_top, rec_bottom, rec_total) = if records_mode {
850            let rt = idx.line_to_record(self.top_line) + 1;
851            let rb = idx.line_to_record(bottom.saturating_sub(1)) + 1;
852            (rt, rb, idx.record_count())
853        } else {
854            (0, 0, 0)
855        };
856
857        let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
858            let line_rows = if total > 0 {
859                let bytes = self.line_display_bytes(src, idx, self.top_line);
860                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None)
861            } else { 1 };
862            format!("+{}/{}", self.top_row, line_rows)
863        } else {
864            String::new()
865        };
866
867        let format_tag = self.format_label.as_ref()
868            .map(|n| format!("  [{}]", n))
869            .unwrap_or_default();
870        let filter_tag = self.filter.as_ref()
871            .map(|f| format!("  [{}]", f.format_name))
872            .unwrap_or_default();
873        let grep_tag = if self.grep.is_some() { "  [grep]".to_string() } else { String::new() };
874        let hide_tag = if self.filter.is_some() || self.grep.is_some() {
875            if self.dim_mode { "  [dim]".to_string() } else { "  [hide]".to_string() }
876        } else {
877            String::new()
878        };
879        let search_tag = self.search.as_ref()
880            .map(|s| {
881                let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
882                format!("  [{}{}]", p, s.raw)
883            })
884            .unwrap_or_default();
885        let pretty_tag = self.prettify_label.as_ref()
886            .map(|l| format!("  [pretty:{l}]"))
887            .unwrap_or_default();
888        let live_tag = if self.live_mode { "  (L)".to_string() } else { String::new() };
889        let follow_tag = if self.follow_mode { "  (F)".to_string() } else { String::new() };
890        let preprocess_failed_tag = self.preprocess_failure.as_ref()
891            .map(|msg| {
892                let first_line = msg.lines().next().unwrap_or("");
893                format!("  [preprocess-failed: {}]", first_line)
894            })
895            .unwrap_or_default();
896
897        let file_index_tag = match self.file_index {
898            Some((current, total)) => format!("  [{}/{}]", current + 1, total),
899            None => String::new(),
900        };
901
902        let tag_tag = match &self.tag_active {
903            Some((name, cur, total)) if *total > 1 => {
904                format!("  [tag: {name} ({cur}/{total})]")
905            }
906            _ => String::new(),
907        };
908
909        PromptContext {
910            label: self.source_label.clone(),
911            top,
912            bottom,
913            total,
914            pct: pct.min(100) as u8,
915            rec_top,
916            rec_bottom,
917            rec_total,
918            records_mode,
919            wrap_offset,
920            format_tag,
921            filter_tag,
922            grep_tag,
923            hide_tag,
924            search_tag,
925            pretty_tag,
926            live_tag,
927            follow_tag,
928            preprocess_failed_tag,
929            file_index_tag,
930            tag_tag,
931        }
932    }
933
934    fn frame_hex(&self, src: &dyn Source) -> Frame {
935        use crate::hex::format_hex_row;
936        use crate::render::{render_line, Cell, RenderOpts};
937
938        let body_rows = self.rows.saturating_sub(1) as usize;
939        let total_bytes = src.len();
940        let total_hex_rows = total_bytes.div_ceil(16);
941
942        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
943        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
944        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
945
946        let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict };
947
948        for row_idx in 0..body_rows {
949            let hex_row = self.top_line + row_idx;
950            if hex_row >= total_hex_rows {
951                body.push(vec![Cell::Empty; self.cols as usize]);
952            } else {
953                let offset = hex_row * 16;
954                let end = (offset + 16).min(total_bytes);
955                let bytes_cow = src.bytes(offset..end);
956                let text = format_hex_row(offset, &bytes_cow);
957                let rows = render_line(text.as_bytes(), &opts, None);
958                body.push(rows.into_iter().next().unwrap_or_else(|| {
959                    vec![Cell::Empty; self.cols as usize]
960                }));
961            }
962            row_styles.push(RowStyle::Normal);
963            highlights.push(Vec::new());
964        }
965
966        let status = self.format_status_hex(src);
967        Frame { body, row_styles, highlights, status }
968    }
969
970    fn format_status_hex(&self, src: &dyn Source) -> String {
971        let total_bytes = src.len();
972        let body_rows = self.rows.saturating_sub(1) as usize;
973        // Byte offset of the first visible byte (start of the top hex row).
974        let top_byte = self.top_line * 16;
975        // Byte offset just past the last visible byte. Clamped to total_bytes
976        // so we never show a value past EOF.
977        let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
978        let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
979        let label_with_index = match self.file_index {
980            Some((current, total)) => format!("{}  [{}/{}]", self.source_label, current + 1, total),
981            None => self.source_label.clone(),
982        };
983        let tag_suffix = match &self.tag_active {
984            Some((name, cur, total)) if *total > 1 => {
985                format!("  [tag: {name} ({cur}/{total})]")
986            }
987            _ => String::new(),
988        };
989        format!(
990            "{}  off {}-{}/{}  {}%  [hex]{}",
991            label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
992        )
993    }
994
995    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
996    /// reset to 0 so the start of the destination line is at the top of
997    /// the viewport. In hide mode this is equivalent to `scroll_lines`
998    /// (which already moves by visible/logical lines).
999    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1000        if delta == 0 { return; }
1001        if self.hide_mode() {
1002            self.scroll_lines(delta, src, idx);
1003            return;
1004        }
1005        if delta > 0 {
1006            idx.extend_to_line(self.top_line + delta as usize + 1, src);
1007            let total = idx.line_count();
1008            if total == 0 { return; }
1009            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
1010            self.top_line = target;
1011            self.top_row = 0;
1012        } else {
1013            let back = (-delta) as usize;
1014            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
1015            // the start of the current line; only the remaining count goes to
1016            // previous lines. This matches the user's mental model of "jump
1017            // to the start of the previous line".
1018            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
1019            let extra_back = back.saturating_sub(consumed_for_snap);
1020            self.top_line = self.top_line.saturating_sub(extra_back);
1021            self.top_row = 0;
1022        }
1023    }
1024
1025    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
1026        if delta == 0 { return; }
1027        if self.hide_mode() {
1028            // Scroll by visible (matching) lines. We don't honor wrap rows in
1029            // hide mode — top_row stays 0. Each unit of `delta` advances or
1030            // retreats one visible line.
1031            self.extend_visible_lines(idx, src);
1032            let total = self.visible_lines.len();
1033            if total == 0 {
1034                self.top_line = 0;
1035                self.top_row = 0;
1036                return;
1037            }
1038            let cur = self
1039                .visible_lines
1040                .iter()
1041                .position(|&l| l >= self.top_line)
1042                .unwrap_or(total);
1043            let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
1044            self.top_line = self.visible_lines[new];
1045            self.top_row = 0;
1046            return;
1047        }
1048        if delta > 0 {
1049            let mut remaining = delta as usize;
1050            while remaining > 0 {
1051                idx.extend_to_line(self.top_line + 1, src);
1052                let total = idx.line_count();
1053                if total == 0 { break; }
1054                let bytes = self.line_display_bytes(src, idx, self.top_line);
1055                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1056                if self.top_row + 1 < line_rows {
1057                    self.top_row += 1;
1058                } else if self.top_line + 1 < total {
1059                    self.top_row = 0;
1060                    self.top_line += 1;
1061                } else {
1062                    break;
1063                }
1064                remaining -= 1;
1065            }
1066        } else {
1067            let mut remaining = (-delta) as usize;
1068            while remaining > 0 {
1069                if self.top_row > 0 {
1070                    self.top_row -= 1;
1071                } else if self.top_line > 0 {
1072                    self.top_line -= 1;
1073                    let bytes = self.line_display_bytes(src, idx, self.top_line);
1074                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)), None);
1075                    self.top_row = line_rows.saturating_sub(1);
1076                } else {
1077                    break;
1078                }
1079                remaining -= 1;
1080            }
1081        }
1082    }
1083
1084    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1085        let n = self.body_rows() as i64;
1086        self.scroll_lines(n, src, idx);
1087    }
1088
1089    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1090        let n = self.body_rows() as i64;
1091        self.scroll_lines(-n, src, idx);
1092    }
1093
1094    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1095        let n = (self.body_rows() / 2).max(1) as i64;
1096        self.scroll_lines(n, src, idx);
1097    }
1098
1099    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1100        let n = (self.body_rows() / 2).max(1) as i64;
1101        self.scroll_lines(-n, src, idx);
1102    }
1103
1104    pub fn goto_top(&mut self) {
1105        self.top_line = 0;
1106        self.top_row = 0;
1107    }
1108
1109    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1110        idx.extend_to_end(src);
1111        let body = self.body_rows() as usize;
1112        if self.hide_mode() {
1113            self.extend_visible_lines(idx, src);
1114            let total = self.visible_lines.len();
1115            let target_visible = total.saturating_sub(body);
1116            self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1117            self.top_row = 0;
1118        } else {
1119            let total = idx.line_count();
1120            self.top_line = total.saturating_sub(body);
1121            self.top_row = 0;
1122        }
1123    }
1124
1125    /// Position the viewport so line `n` (0-indexed) is the top visible line.
1126    pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1127        idx.extend_to_line(n, src);
1128        let target = n.min(idx.line_count().saturating_sub(1));
1129        self.top_line = target;
1130        self.top_row = 0;
1131    }
1132
1133    /// Position the viewport at the start of record `n` (0-indexed).
1134    pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1135        // Ensure the record exists by extending the index. Records can only
1136        // appear after their constituent lines are scanned; extend repeatedly
1137        // until the record exists or we hit EOF.
1138        while idx.record_count() <= n && idx.scanned_through() < src.len() {
1139            idx.extend_to_end(src);
1140        }
1141        if idx.record_count() == 0 {
1142            return;
1143        }
1144        let target = n.min(idx.record_count().saturating_sub(1));
1145        let line_range = idx.record_line_range(target);
1146        self.top_line = line_range.start;
1147        self.top_row = 0;
1148    }
1149
1150    /// Position the viewport at `p` percent through the file by bytes.
1151    /// `p` is clamped to 0..=100. p=100 lands at the last line.
1152    pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1153        let p = p.min(100) as usize;
1154        let target_byte = src.len().saturating_mul(p) / 100;
1155        idx.extend_to_byte_for_query(src, target_byte);
1156        let line_n = idx.line_at_byte(target_byte)
1157            .or_else(|| {
1158                // target_byte at or past EOF: fall through to the last line.
1159                let lc = idx.line_count();
1160                if lc > 0 { Some(lc - 1) } else { None }
1161            })
1162            .unwrap_or(0);
1163        self.top_line = line_n;
1164        self.top_row = 0;
1165    }
1166
1167    /// Get the currently top-displayed physical line index.
1168    pub fn top_line(&self) -> usize {
1169        self.top_line
1170    }
1171
1172    pub fn resize(&mut self, cols: u16, rows: u16) {
1173        self.cols = cols.max(1);
1174        self.rows = rows.max(2);
1175        self.opts.cols = self.cols;
1176    }
1177
1178    pub fn toggle_line_numbers(&mut self) {
1179        self.show_line_numbers = !self.show_line_numbers;
1180    }
1181
1182    pub fn toggle_chop(&mut self) {
1183        self.opts.wrap = !self.opts.wrap;
1184    }
1185
1186    /// Return the current set of visible (matched) line indices. Non-empty only
1187    /// in hide mode (filter or grep active without --dim). Stable public accessor
1188    /// so integration tests and external tooling can inspect filter results.
1189    pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195    use crate::source::MockSource;
1196
1197    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1198        let m = MockSource::new();
1199        m.append(content);
1200        m.finish();
1201        let idx = LineIndex::new();
1202        (m, idx)
1203    }
1204
1205    #[test]
1206    fn frame_renders_body_height_rows() {
1207        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1208        let mut v = Viewport::new(10, 5, "test".into());  // body = 4
1209        let frame = v.frame(&m, &mut idx);
1210        assert_eq!(frame.body.len(), 4);
1211        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1212        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1213    }
1214
1215    #[test]
1216    fn scroll_down_advances_top_line() {
1217        let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1218        let mut v = Viewport::new(10, 5, "test".into());
1219        v.scroll_lines(2, &m, &mut idx);
1220        assert_eq!(v.top_line, 2);
1221        assert_eq!(v.top_row, 0);
1222    }
1223
1224    #[test]
1225    fn scroll_up_clamps_at_zero() {
1226        let (m, mut idx) = setup(b"a\nb\nc\n");
1227        let mut v = Viewport::new(10, 5, "test".into());
1228        v.scroll_lines(-5, &m, &mut idx);
1229        assert_eq!(v.top_line, 0);
1230        assert_eq!(v.top_row, 0);
1231    }
1232
1233    #[test]
1234    fn scroll_down_clamps_at_last_line() {
1235        let (m, mut idx) = setup(b"a\nb\nc\n");
1236        let mut v = Viewport::new(10, 5, "test".into());
1237        v.scroll_lines(50, &m, &mut idx);
1238        assert_eq!(v.top_line, 2);
1239    }
1240
1241    #[test]
1242    fn scroll_logical_lines_skips_wrap_rows() {
1243        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
1244        let mut content = vec![b'X'; 500];
1245        content.push(b'\n');
1246        content.extend_from_slice(b"second\n");
1247        content.extend_from_slice(b"third\n");
1248        let (m, mut idx) = setup(&content);
1249        let mut v = Viewport::new(10, 8, "f".into());
1250        v.scroll_logical_lines(1, &m, &mut idx);
1251        assert_eq!((v.top_line, v.top_row), (1, 0));
1252        v.scroll_logical_lines(1, &m, &mut idx);
1253        assert_eq!((v.top_line, v.top_row), (2, 0));
1254    }
1255
1256    #[test]
1257    fn scroll_logical_lines_back_snaps_to_line_start() {
1258        // Mid-wrap K should snap to start of current line first, then go back.
1259        let mut content = vec![b'A'; 50];
1260        content.push(b'\n');
1261        content.extend_from_slice(&[b'B'; 50]);
1262        content.push(b'\n');
1263        let (m, mut idx) = setup(&content);
1264        let mut v = Viewport::new(10, 8, "f".into());
1265        v.scroll_lines(7, &m, &mut idx);
1266        assert_eq!(v.top_line, 1, "should be on line 1");
1267        assert!(v.top_row > 0, "should be inside line 1's wraps");
1268        v.scroll_logical_lines(-1, &m, &mut idx);
1269        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1270        v.scroll_logical_lines(-1, &m, &mut idx);
1271        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1272    }
1273
1274    #[test]
1275    fn scroll_down_walks_wraps_of_last_line() {
1276        // Last line is 30 chars in a 10-col viewport → 3 wrap rows.
1277        let mut content = b"first\n".to_vec();
1278        content.extend_from_slice(&[b'X'; 30]);
1279        content.push(b'\n');
1280        let (m, mut idx) = setup(&content);
1281        let mut v = Viewport::new(10, 5, "f".into());
1282        v.scroll_lines(1, &m, &mut idx);
1283        assert_eq!((v.top_line, v.top_row), (1, 0));
1284        v.scroll_lines(1, &m, &mut idx);
1285        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1286        v.scroll_lines(1, &m, &mut idx);
1287        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1288    }
1289
1290    #[test]
1291    fn scroll_down_walks_wrap_rows_within_long_line() {
1292        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
1293        let mut content = vec![b'X'; 30];
1294        content.push(b'\n');
1295        content.extend_from_slice(b"second\n");
1296        let (m, mut idx) = setup(&content);
1297        let mut v = Viewport::new(10, 5, "f".into());
1298        v.scroll_lines(1, &m, &mut idx);
1299        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1300        v.scroll_lines(1, &m, &mut idx);
1301        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1302        v.scroll_lines(1, &m, &mut idx);
1303        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1304    }
1305
1306    #[test]
1307    fn status_line_shows_range_and_pct() {
1308        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1309        let mut v = Viewport::new(20, 5, "f".into());  // body = 4
1310        let frame = v.frame(&m, &mut idx);
1311        assert!(frame.status.starts_with("f  1-4/10"));
1312    }
1313
1314    #[test]
1315    fn page_down_advances_by_body_rows() {
1316        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1317        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1318        v.page_down(&m, &mut idx);
1319        assert_eq!(v.top_line, 4);
1320    }
1321
1322    #[test]
1323    fn page_up_then_page_down_returns_to_start_when_no_resize() {
1324        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1325        let mut v = Viewport::new(10, 5, "f".into());
1326        v.page_down(&m, &mut idx);
1327        v.page_up(&m, &mut idx);
1328        assert_eq!(v.top_line, 0);
1329        assert_eq!(v.top_row, 0);
1330    }
1331
1332    #[test]
1333    fn half_page_down_advances_by_half_body() {
1334        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1335        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
1336        v.half_page_down(&m, &mut idx);
1337        assert_eq!(v.top_line, 3);
1338    }
1339
1340    #[test]
1341    fn goto_top_resets_position() {
1342        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1343        let mut v = Viewport::new(10, 5, "f".into());
1344        v.scroll_lines(2, &m, &mut idx);
1345        v.goto_top();
1346        assert_eq!(v.top_line, 0);
1347        assert_eq!(v.top_row, 0);
1348    }
1349
1350    #[test]
1351    fn goto_bottom_scrolls_to_last_page() {
1352        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1353        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
1354        v.goto_bottom(&m, &mut idx);
1355        // Last page should show lines 7..=10 → top_line = 6.
1356        assert_eq!(v.top_line, 6);
1357    }
1358
1359    #[test]
1360    fn goto_line_positions_top_line() {
1361        let m = MockSource::new();
1362        m.append(b"a\nb\nc\nd\ne\n");
1363        let mut idx = LineIndex::new();
1364        idx.extend_to_end(&m);
1365        let mut v = Viewport::new(20, 5, "f".into());
1366        v.goto_line(3, &m, &mut idx);
1367        assert_eq!(v.top_line(), 3);
1368    }
1369
1370    #[test]
1371    fn goto_line_clamps_to_last_line() {
1372        let m = MockSource::new();
1373        m.append(b"a\nb\n");
1374        let mut idx = LineIndex::new();
1375        idx.extend_to_end(&m);
1376        let mut v = Viewport::new(20, 5, "f".into());
1377        v.goto_line(999, &m, &mut idx);
1378        assert_eq!(v.top_line(), 1);
1379    }
1380
1381    #[test]
1382    fn goto_record_positions_at_record_start_line() {
1383        let m = MockSource::new();
1384        m.append(b"[1] a\n  cont\n[2] b\n[3] c\n");
1385        let mut idx = LineIndex::new();
1386        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1387        idx.extend_to_end(&m);
1388        let mut v = Viewport::new(20, 5, "f".into());
1389        v.goto_record(1, &m, &mut idx);  // record 1 starts at line 2 ("[2] b")
1390        assert_eq!(v.top_line(), 2);
1391    }
1392
1393    #[test]
1394    fn goto_record_in_line_per_record_mode_equals_goto_line() {
1395        let m = MockSource::new();
1396        m.append(b"a\nb\nc\n");
1397        let mut idx = LineIndex::new();
1398        idx.extend_to_end(&m);
1399        let mut v = Viewport::new(20, 5, "f".into());
1400        v.goto_record(2, &m, &mut idx);
1401        assert_eq!(v.top_line(), 2);
1402    }
1403
1404    #[test]
1405    fn goto_percent_50_lands_in_middle() {
1406        let m = MockSource::new();
1407        m.append(b"a\nb\nc\nd\ne\n");  // 10 bytes
1408        let mut idx = LineIndex::new();
1409        idx.extend_to_end(&m);
1410        let mut v = Viewport::new(20, 5, "f".into());
1411        v.goto_percent(50, &m, &mut idx);
1412        assert_eq!(v.top_line(), 2);  // byte 5 → line 2
1413    }
1414
1415    #[test]
1416    fn goto_percent_100_lands_at_last_line() {
1417        let m = MockSource::new();
1418        m.append(b"a\nb\nc\n");  // 6 bytes, 3 lines
1419        let mut idx = LineIndex::new();
1420        idx.extend_to_end(&m);
1421        let mut v = Viewport::new(20, 5, "f".into());
1422        v.goto_percent(100, &m, &mut idx);
1423        assert_eq!(v.top_line(), 2);
1424    }
1425
1426    #[test]
1427    fn goto_percent_0_lands_at_first_line() {
1428        let m = MockSource::new();
1429        m.append(b"a\nb\nc\n");
1430        let mut idx = LineIndex::new();
1431        idx.extend_to_end(&m);
1432        let mut v = Viewport::new(20, 5, "f".into());
1433        v.goto_record(2, &m, &mut idx);  // first jump elsewhere
1434        assert_eq!(v.top_line(), 2);
1435        v.goto_percent(0, &m, &mut idx);
1436        assert_eq!(v.top_line(), 0);
1437    }
1438
1439    #[test]
1440    fn resize_updates_dimensions_and_render_opts() {
1441        let (m, mut idx) = setup(b"1\n2\n");
1442        let mut v = Viewport::new(10, 5, "f".into());
1443        v.resize(40, 12);
1444        assert_eq!(v.cols, 40);
1445        assert_eq!(v.rows, 12);
1446        assert_eq!(v.opts.cols, 40);
1447        let _ = v.frame(&m, &mut idx);
1448    }
1449
1450    #[test]
1451    fn toggle_line_numbers_changes_gutter() {
1452        let (m, mut idx) = setup(b"a\nb\nc\n");
1453        let mut v = Viewport::new(10, 5, "f".into());
1454        let frame_off = v.frame(&m, &mut idx);
1455        v.toggle_line_numbers();
1456        let frame_on = v.frame(&m, &mut idx);
1457        // With gutter, first cell is a digit or space, not 'a'.
1458        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1459        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1460    }
1461
1462    #[test]
1463    fn toggle_chop_changes_wrap_mode() {
1464        let (m, mut idx) = setup(b"abcdefghij\n");
1465        let mut v = Viewport::new(4, 5, "f".into());
1466        v.toggle_chop();
1467        let frame = v.frame(&m, &mut idx);
1468        // After toggle_chop, the line is one row, not wrapped.
1469        // Body row 0 is "abcd"; rows 1..3 are blank fill.
1470        assert_eq!(frame.body[0][..4],
1471            [Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1472             Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1473             Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
1474             Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
1475        // Row 1 should be all-empty (no wrap continuation).
1476        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1477    }
1478
1479    // ----- Follow mode -----
1480
1481    #[test]
1482    fn is_at_bottom_initially_only_when_source_fits() {
1483        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
1484        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
1485        idx.extend_to_end(&m);
1486        assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1487    }
1488
1489    #[test]
1490    fn is_at_bottom_false_when_top_and_more_lines_below() {
1491        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1492        let v = Viewport::new(10, 5, "f".into());  // body = 4
1493        idx.extend_to_end(&m);
1494        assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1495    }
1496
1497    #[test]
1498    fn is_at_bottom_true_after_goto_bottom() {
1499        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1500        let mut v = Viewport::new(10, 5, "f".into());
1501        v.goto_bottom(&m, &mut idx);
1502        assert!(v.is_at_bottom(&idx));
1503    }
1504
1505    #[test]
1506    fn status_shows_follow_suffix_when_follow_mode_on() {
1507        let (m, mut idx) = setup(b"a\nb\n");
1508        let mut v = Viewport::new(20, 5, "f".into());
1509        let frame_off = v.frame(&m, &mut idx);
1510        assert!(!frame_off.status.contains("(F)"));
1511        v.set_follow_mode(true);
1512        let frame_on = v.frame(&m, &mut idx);
1513        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1514    }
1515
1516    #[test]
1517    fn toggle_follow_flips_state() {
1518        let mut v = Viewport::new(10, 5, "f".into());
1519        assert!(!v.follow_mode());
1520        v.toggle_follow();
1521        assert!(v.follow_mode());
1522        v.toggle_follow();
1523        assert!(!v.follow_mode());
1524    }
1525
1526    #[test]
1527    fn status_shows_prettify_label_when_set() {
1528        let (m, mut idx) = setup(b"a\n");
1529        let mut v = Viewport::new(40, 5, "f".into());
1530        let frame_off = v.frame(&m, &mut idx);
1531        assert!(!frame_off.status.contains("[pretty"));
1532        v.set_prettify_label(Some("json".into()));
1533        let frame_on = v.frame(&m, &mut idx);
1534        assert!(frame_on.status.contains("[pretty:json]"),
1535            "expected [pretty:json] in status, got: {}", frame_on.status);
1536        v.set_prettify_label(Some("json:err".into()));
1537        let frame_err = v.frame(&m, &mut idx);
1538        assert!(frame_err.status.contains("[pretty:json:err]"),
1539            "expected [pretty:json:err] in status, got: {}", frame_err.status);
1540    }
1541
1542    #[test]
1543    fn status_shows_l_suffix_when_live_mode_on() {
1544        let (m, mut idx) = setup(b"a\nb\n");
1545        let mut v = Viewport::new(20, 5, "f".into());
1546        let frame_off = v.frame(&m, &mut idx);
1547        assert!(!frame_off.status.contains("(L)"));
1548        v.set_live_mode(true);
1549        let frame_on = v.frame(&m, &mut idx);
1550        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1551    }
1552
1553    #[test]
1554    fn clamp_top_line_pulls_back_when_total_shrinks() {
1555        let mut v = Viewport::new(20, 5, "f".into());
1556        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
1557        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
1558        // Force top_line via a sequence; easiest: just call clamp directly.
1559        // We can't poke private state, but clamp works regardless of how we got there.
1560        v.clamp_top_line(100);  // total bigger than top_line=0, no change
1561        v.clamp_top_line(0);    // empty source: must reset
1562        // After clamp(0), line 0 is the floor.
1563        // (No public getter for top_line; we verify indirectly by going to top.)
1564        v.goto_top();
1565        // Just confirm no panic and no overflow on subsequent frame composition.
1566        let (m, mut idx) = setup(b"only\n");
1567        let _ = v.frame(&m, &mut idx);
1568    }
1569
1570    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
1571    /// when follow mode is on and the viewport is at the bottom.
1572    fn simulate_growth_tick(
1573        v: &mut Viewport,
1574        src: &MockSource,
1575        idx: &mut LineIndex,
1576    ) {
1577        if !v.follow_mode() { return; }
1578        let was_at_bottom = v.is_at_bottom(idx);
1579        let lines_before = idx.line_count();
1580        idx.notice_new_bytes(src);
1581        if idx.line_count() != lines_before && was_at_bottom {
1582            v.goto_bottom(src, idx);
1583        }
1584    }
1585
1586    #[test]
1587    fn auto_scroll_engages_when_at_bottom() {
1588        let m = MockSource::new();
1589        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
1590        let mut idx = LineIndex::new();
1591        let mut v = Viewport::new(10, 5, "f".into());
1592        v.set_follow_mode(true);
1593        idx.extend_to_end(&m);
1594        assert!(v.is_at_bottom(&idx));
1595        let top_before = {
1596            let f = v.frame(&m, &mut idx);
1597            f.status.clone()  // unused, just exercise frame
1598        };
1599        let _ = top_before;
1600        // Simulate growth: source gains 4 more lines.
1601        m.append(b"5\n6\n7\n8\n");
1602        simulate_growth_tick(&mut v, &m, &mut idx);
1603        // After auto-scroll, top_line should have advanced so the new last line is in view.
1604        assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1605        let frame = v.frame(&m, &mut idx);
1606        // The bottom-most body row should now contain the last logical line ('8').
1607        // Find which row has '8'.
1608        let last_row = &frame.body[frame.body.len() - 1];
1609        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
1610    }
1611
1612    #[test]
1613    fn auto_scroll_suppressed_when_scrolled_up() {
1614        let m = MockSource::new();
1615        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1616        let mut idx = LineIndex::new();
1617        let mut v = Viewport::new(10, 5, "f".into());  // body=4
1618        v.set_follow_mode(true);
1619        idx.extend_to_end(&m);
1620        v.goto_bottom(&m, &mut idx);
1621        // Now scroll up off the bottom.
1622        v.scroll_lines(-2, &m, &mut idx);
1623        assert!(!v.is_at_bottom(&idx));
1624        let frame_before = v.frame(&m, &mut idx);
1625        let top_first_cell_before = frame_before.body[0][0].clone();
1626        // Simulate growth.
1627        m.append(b"9\n10\n");
1628        simulate_growth_tick(&mut v, &m, &mut idx);
1629        // Viewport should NOT have moved (auto-scroll suppressed).
1630        let frame_after = v.frame(&m, &mut idx);
1631        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1632    }
1633
1634    // ----- Search -----
1635
1636    #[test]
1637    fn set_search_compiles_regex() {
1638        let mut v = Viewport::new(10, 5, "f".into());
1639        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1640        assert!(v.search_active());
1641    }
1642
1643    #[test]
1644    fn set_search_rejects_bad_regex() {
1645        let mut v = Viewport::new(10, 5, "f".into());
1646        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1647        assert!(!err.is_empty());
1648        assert!(!v.search_active(), "no search should be set on error");
1649    }
1650
1651    #[test]
1652    fn search_step_forward_finds_match_after_top() {
1653        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1654        let mut v = Viewport::new(20, 5, "f".into());
1655        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1656        let found = v.search_repeat(&m, &mut idx, false);
1657        assert!(found);
1658        // gamma is line 2 (0-indexed)
1659        assert_eq!(v.top_line, 2);
1660    }
1661
1662    #[test]
1663    fn search_step_backward_finds_match_before_top() {
1664        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1665        let mut v = Viewport::new(20, 5, "f".into());
1666        v.scroll_lines(4, &m, &mut idx); // top_line = 4
1667        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1668        let found = v.search_repeat(&m, &mut idx, false);
1669        assert!(found);
1670        assert_eq!(v.top_line, 0);
1671    }
1672
1673    #[test]
1674    fn search_wraps_at_end() {
1675        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1676        let mut v = Viewport::new(20, 5, "f".into());
1677        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
1678        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1679        let found = v.search_repeat(&m, &mut idx, false);
1680        assert!(found, "search should wrap forward past EOF");
1681        assert_eq!(v.top_line, 0);
1682    }
1683
1684    #[test]
1685    fn search_no_match_returns_false_and_does_not_move() {
1686        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1687        let mut v = Viewport::new(20, 5, "f".into());
1688        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1689        let found = v.search_repeat(&m, &mut idx, false);
1690        assert!(!found);
1691        assert_eq!(v.top_line, 0);
1692    }
1693
1694    #[test]
1695    fn frame_records_highlight_ranges_for_matches() {
1696        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1697        let mut v = Viewport::new(20, 5, "f".into());
1698        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1699        let frame = v.frame(&m, &mut idx);
1700        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
1701        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1702        assert!(frame.highlights[0].is_empty());
1703        assert!(frame.highlights[1].is_empty());
1704        assert_eq!(frame.highlights[2], vec![0..5]);
1705        assert!(frame.highlights[3].is_empty());
1706    }
1707
1708    #[test]
1709    fn frame_highlights_substring_inside_a_row() {
1710        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1711        let mut v = Viewport::new(40, 5, "f".into());
1712        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1713        let frame = v.frame(&m, &mut idx);
1714        // "beta" starts at column 18 in the first row.
1715        assert_eq!(frame.highlights[0], vec![18..22]);
1716        assert!(frame.highlights[1].is_empty());
1717    }
1718
1719    #[test]
1720    fn search_highlight_with_filter_dim_keeps_row_dim() {
1721        // alpha matches filter → Normal. beta doesn't → Dim. Search for
1722        // "beta" should leave row style Dim and mark the substring 0..4.
1723        let (m, mut idx) = setup(b"alpha\nbeta\n");
1724        let mut v = Viewport::new(20, 5, "f".into());
1725        let fmt = crate::format::LogFormat::compile(
1726            "simple",
1727            r"^(?P<line>.+)$",
1728        )
1729        .unwrap();
1730        let f = crate::filter::CompiledFilter::compile(
1731            &fmt,
1732            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1733        )
1734        .unwrap();
1735        v.set_filter(Some(f));
1736        v.set_dim_mode(true);
1737        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1738        let frame = v.frame(&m, &mut idx);
1739        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1740        assert_eq!(frame.row_styles[1], RowStyle::Dim);
1741        assert_eq!(frame.highlights[1], vec![0..4]);
1742    }
1743
1744    #[test]
1745    fn grep_only_hides_non_matching_lines() {
1746        use crate::grep::GrepPredicate;
1747        let src = crate::source::MockSource::new();
1748        src.append(b"keep this error\n");
1749        src.append(b"drop this one\n");
1750        src.append(b"another error line\n");
1751        src.finish();
1752        let mut idx = crate::line_index::LineIndex::new();
1753        idx.extend_to_end(&src);
1754
1755        let mut v = Viewport::new(40, 5, "test".into());
1756        v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1757        v.extend_visible_lines(&idx, &src);
1758
1759        // Only the two "error" lines should be visible.
1760        let frame = v.frame(&src, &mut idx);
1761        let body_text: Vec<String> = frame.body.iter()
1762            .map(|row| row.iter().filter_map(|c| match c {
1763                crate::render::Cell::Char { ch, .. } => Some(*ch),
1764                _ => None,
1765            }).collect())
1766            .collect();
1767        assert!(body_text[0].contains("keep this error"));
1768        assert!(body_text[1].contains("another error line"));
1769        assert!(frame.status.contains("[grep]"));
1770    }
1771
1772    #[test]
1773    fn filter_and_grep_combine_with_and() {
1774        use crate::grep::GrepPredicate;
1775        let fmt = crate::format::LogFormat::compile(
1776            "simple",
1777            r"^(?P<level>\w+) (?P<msg>.+)$",
1778        ).unwrap();
1779        let f = crate::filter::CompiledFilter::compile(
1780            &fmt,
1781            vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1782        ).unwrap();
1783        let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1784
1785        let src = crate::source::MockSource::new();
1786        src.append(b"ERROR timeout connecting\n");      // matches both → keep
1787        src.append(b"ERROR file not found\n");          // matches filter only → drop
1788        src.append(b"WARN timeout retrying\n");         // matches grep only → drop
1789        src.append(b"INFO all good\n");                 // matches neither → drop
1790        src.finish();
1791        let mut idx = crate::line_index::LineIndex::new();
1792        idx.extend_to_end(&src);
1793
1794        let mut v = Viewport::new(80, 5, "test".into());
1795        v.set_filter(Some(f));
1796        v.set_grep(Some(g));
1797        v.extend_visible_lines(&idx, &src);
1798        assert_eq!(v.visible_lines(), &[0usize]);
1799    }
1800
1801    #[test]
1802    fn search_status_shows_pattern() {
1803        let (m, mut idx) = setup(b"x\n");
1804        let mut v = Viewport::new(20, 5, "f".into());
1805        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1806        let frame = v.frame(&m, &mut idx);
1807        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1808    }
1809
1810    #[test]
1811    fn repeat_search_after_first_match_advances() {
1812        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1813        let mut v = Viewport::new(40, 5, "f".into());
1814        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1815        assert!(v.search_repeat(&m, &mut idx, false));
1816        assert_eq!(v.top_line, 1, "first foo");
1817        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1818        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1819        assert_eq!(v.top_line, 3, "should advance to next foo");
1820    }
1821
1822    #[test]
1823    fn auto_scroll_paused_when_follow_off() {
1824        let m = MockSource::new();
1825        m.append(b"1\n2\n3\n4\n");
1826        let mut idx = LineIndex::new();
1827        let mut v = Viewport::new(10, 5, "f".into());
1828        // Follow is off; viewport at top.
1829        idx.extend_to_end(&m);
1830        let frame_before = v.frame(&m, &mut idx);
1831        let top_first_cell = frame_before.body[0][0].clone();
1832        m.append(b"5\n6\n7\n8\n");
1833        simulate_growth_tick(&mut v, &m, &mut idx);
1834        let frame_after = v.frame(&m, &mut idx);
1835        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1836    }
1837
1838    // ----- Records-mode search -----
1839
1840    #[test]
1841    fn search_jumps_to_next_matching_record() {
1842        let m = MockSource::new();
1843        m.append(b"[1] alpha\n  cont\n[2] bravo\n[3] charlie\n  cont\n[4] delta\n");
1844        let mut idx = LineIndex::new();
1845        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1846        idx.extend_to_end(&m);
1847        let mut v = Viewport::new(40, 10, "f".into());
1848        v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1849        let hit = v.search_repeat(&m, &mut idx, false);
1850        assert!(hit, "should find 'charlie' in record 2");
1851        assert_eq!(v.top_line(), 3);  // record 2 starts at line 3 ("[3] charlie")
1852    }
1853
1854    #[test]
1855    fn search_finds_cross_line_match_in_record_with_s_flag() {
1856        let m = MockSource::new();
1857        m.append(b"[1] head\n  Renderer.php(214)\n[2] other line\n");
1858        let mut idx = LineIndex::new();
1859        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1860        idx.extend_to_end(&m);
1861        let mut v = Viewport::new(40, 10, "f".into());
1862        v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1863        let hit = v.search_repeat(&m, &mut idx, false);
1864        assert!(hit, "should match across \\n inside record 0 with (?s)");
1865        assert_eq!(v.top_line(), 0);
1866    }
1867
1868    #[test]
1869    fn search_repeat_with_no_match_returns_false() {
1870        let m = MockSource::new();
1871        m.append(b"[1] alpha\n[2] bravo\n");
1872        let mut idx = LineIndex::new();
1873        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1874        idx.extend_to_end(&m);
1875        let mut v = Viewport::new(40, 10, "f".into());
1876        v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1877        let hit = v.search_repeat(&m, &mut idx, false);
1878        assert!(!hit);
1879    }
1880
1881    // ----- Records-mode filter/grep -----
1882
1883    #[test]
1884    fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1885        // Record 0: "[1] head\n  cont a" — grep matches "cont a" → visible.
1886        // Record 1: "[2] head\n  cont b" — grep does NOT match → hidden.
1887        let m = MockSource::new();
1888        m.append(b"[1] head\n  cont a\n[2] head\n  cont b\n");
1889        let mut idx = LineIndex::new();
1890        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1891        idx.extend_to_end(&m);
1892        let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1893        let mut v = Viewport::new(40, 10, "f".into());
1894        v.set_grep(Some(grep));
1895        v.extend_visible_lines(&idx, &m);
1896        // Record 0 ([1] head + cont a) matches; lines 0 and 1 visible.
1897        // Record 1 ([2] head + cont b) does not match; lines 2 and 3 hidden.
1898        assert_eq!(v.visible_lines(), &[0usize, 1]);
1899    }
1900
1901    #[test]
1902    fn grep_matches_across_record_newlines_in_records_mode() {
1903        // Pattern spans the record-header and a continuation line (needs (?s) for .).
1904        let m = MockSource::new();
1905        m.append(b"[1] head\n  Renderer.php\n[2] other\n  body\n");
1906        let mut idx = LineIndex::new();
1907        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1908        idx.extend_to_end(&m);
1909        let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
1910        let mut v = Viewport::new(40, 10, "f".into());
1911        v.set_grep(Some(grep));
1912        v.extend_visible_lines(&idx, &m);
1913        // Record 0 matches (cross-line); record 1 does not.
1914        assert_eq!(v.visible_lines(), &[0usize, 1]);
1915    }
1916
1917    #[test]
1918    fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
1919        // All 4 lines stay in visible_lines (dim mode = no hiding).
1920        // Record 0 matches grep → Normal; record 1 does not → Dim.
1921        let m = MockSource::new();
1922        m.append(b"[1] head\n  cont\n[2] other\n  cont\n");
1923        let mut idx = LineIndex::new();
1924        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1925        idx.extend_to_end(&m);
1926        let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
1927        let mut v = Viewport::new(40, 10, "f".into());
1928        v.set_grep(Some(grep));
1929        v.set_dim_mode(true);
1930        v.extend_visible_lines(&idx, &m);
1931        // Dim mode: visible_lines stays empty (hide_mode() is false).
1932        assert_eq!(v.visible_lines(), &[] as &[usize]);
1933        // Dim decision is per record: lines 0 and 1 belong to matching record → Normal.
1934        assert!(!v.should_dim_line(0, &idx, &m));
1935        assert!(!v.should_dim_line(1, &idx, &m));
1936        // Lines 2 and 3 belong to non-matching record → Dim.
1937        assert!(v.should_dim_line(2, &idx, &m));
1938        assert!(v.should_dim_line(3, &idx, &m));
1939    }
1940
1941    #[test]
1942    fn status_unchanged_when_records_inactive() {
1943        let (m, mut idx) = setup(b"a\nb\nc\n");
1944        let mut v = Viewport::new(20, 5, "f".into());
1945        let frame = v.frame(&m, &mut idx);
1946        let status = &frame.status;
1947        // Default format: <label>  <top>-<bot>/<total>  <pct>%
1948        assert!(status.contains("1-3/3"), "got: {status}");
1949        assert!(!status.contains("L1"), "no L block in line-mode: {status}");
1950        assert!(!status.contains("R1"), "no R block in line-mode: {status}");
1951    }
1952
1953    #[test]
1954    fn status_dual_readout_when_records_active() {
1955        let m = MockSource::new();
1956        m.append(b"[1] a\n  cont\n[2] b\n");
1957        m.finish();
1958        let mut idx = LineIndex::new();
1959        idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1960        idx.extend_to_end(&m);
1961        let mut v = Viewport::new(20, 5, "f".into());
1962        let frame = v.frame(&m, &mut idx);
1963        let status = &frame.status;
1964        assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
1965        assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
1966    }
1967
1968    #[test]
1969    fn format_status_uses_custom_template_when_set() {
1970        let m = MockSource::new();
1971        m.append(b"a\nb\nc\n");
1972        m.finish();
1973        let mut idx = LineIndex::new();
1974        idx.extend_to_end(&m);
1975        let mut v = Viewport::new(20, 5, "f".into());
1976        let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
1977        v.set_prompt(Some(prompt));
1978        let frame = v.frame(&m, &mut idx);
1979        assert_eq!(frame.status, "f 100%");
1980    }
1981
1982    #[test]
1983    fn status_shows_preprocess_failed_tag_when_set() {
1984        let m = MockSource::new();
1985        m.append(b"a\n");
1986        let mut idx = LineIndex::new();
1987        idx.extend_to_end(&m);
1988        let mut v = Viewport::new(40, 5, "f".into());
1989        v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
1990        let frame = v.frame(&m, &mut idx);
1991        assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
1992                "got: {}", frame.status);
1993    }
1994
1995    #[test]
1996    fn status_shows_file_index_when_multifile() {
1997        let m = MockSource::new();
1998        m.append(b"a\n");
1999        let mut idx = LineIndex::new();
2000        idx.extend_to_end(&m);
2001        let mut v = Viewport::new(60, 5, "f.log".into());
2002        v.set_file_index(0, 3);
2003        let frame = v.frame(&m, &mut idx);
2004        assert!(frame.status.contains("f.log  [1/3]"), "got: {}", frame.status);
2005    }
2006
2007    #[test]
2008    fn status_omits_file_index_when_single_file() {
2009        let m = MockSource::new();
2010        m.append(b"a\n");
2011        let mut idx = LineIndex::new();
2012        idx.extend_to_end(&m);
2013        let mut v = Viewport::new(60, 5, "f.log".into());
2014        v.set_file_index(0, 1);
2015        let frame = v.frame(&m, &mut idx);
2016        assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
2017    }
2018
2019    #[test]
2020    fn status_shows_tag_active_when_multimatch() {
2021        let m = MockSource::new();
2022        m.append(b"a\n");
2023        let mut idx = LineIndex::new();
2024        idx.extend_to_end(&m);
2025        let mut v = Viewport::new(80, 5, "f.log".into());
2026        v.set_tag_active(Some(("foo".into(), 2, 3)));
2027        let frame = v.frame(&m, &mut idx);
2028        assert!(
2029            frame.status.contains("[tag: foo (2/3)]"),
2030            "got: {}",
2031            frame.status
2032        );
2033    }
2034
2035    #[test]
2036    fn status_omits_tag_active_when_single_match() {
2037        let m = MockSource::new();
2038        m.append(b"a\n");
2039        let mut idx = LineIndex::new();
2040        idx.extend_to_end(&m);
2041        let mut v = Viewport::new(80, 5, "f.log".into());
2042        v.set_tag_active(Some(("foo".into(), 1, 1)));
2043        let frame = v.frame(&m, &mut idx);
2044        assert!(
2045            !frame.status.contains("[tag:"),
2046            "should not show indicator for single match: {}",
2047            frame.status
2048        );
2049    }
2050
2051    // ----- SGR state reconstruction tests -----
2052
2053    #[test]
2054    fn reconstruct_picks_up_state_from_prior_lines() {
2055        let m = MockSource::new();
2056        m.append(b"\x1b[31mline 1\n");
2057        m.append(b"line 2 (still red, no reset)\n");
2058        m.append(b"line 3\n");
2059        let mut idx = LineIndex::new();
2060        idx.extend_to_end(&m);
2061        let state = reconstruct_render_state(&m, &idx, 2);
2062        assert_eq!(
2063            state.style.fg,
2064            Some(crate::ansi::Color::Ansi(1)),
2065            "red SGR from line 0 should persist to line 2"
2066        );
2067    }
2068
2069    #[test]
2070    fn reconstruct_respects_reset_between_lines() {
2071        let m = MockSource::new();
2072        m.append(b"\x1b[31mline 1\x1b[0m\n");
2073        m.append(b"line 2 (default)\n");
2074        let mut idx = LineIndex::new();
2075        idx.extend_to_end(&m);
2076        let state = reconstruct_render_state(&m, &idx, 1);
2077        assert_eq!(state.style.fg, None);
2078    }
2079
2080    #[test]
2081    fn reconstruct_caps_walkback_at_max_lines() {
2082        let m = MockSource::new();
2083        m.append(b"\x1b[31mvery early\n");
2084        for _ in 0..300 {
2085            m.append(b"line\n");
2086        }
2087        let mut idx = LineIndex::new();
2088        idx.extend_to_end(&m);
2089        // Line 290 is 290 lines past the red SGR. We cap at 256, so the
2090        // anchor we'd pick is line 34 (290 - 256), which is past the red.
2091        let state = reconstruct_render_state(&m, &idx, 290);
2092        assert_eq!(state.style.fg, None);
2093    }
2094}