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