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