Skip to main content

tess/
viewport.rs

1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::line_index::LineIndex;
7use crate::render::{count_rows, render_line, Cell, RenderOpts};
8use crate::source::Source;
9
10/// Build the rendered text of a display row plus a `starts` table mapping
11/// each char index in that text back to its starting cell column. The last
12/// entry is a sentinel pointing one past the row's width, so a match's
13/// `[char_start, char_end)` translates to the cell range
14/// `starts[char_start]..starts[char_end]`.
15fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
16    let mut text = String::new();
17    let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
18    for (col, cell) in row.iter().enumerate() {
19        match cell {
20            Cell::Char { ch, .. } => {
21                starts.push(col);
22                text.push(*ch);
23            }
24            Cell::Empty => {
25                starts.push(col);
26                text.push(' ');
27            }
28            Cell::Continuation => {}
29        }
30    }
31    starts.push(row.len());
32    (text, starts)
33}
34
35/// Find every regex match in the rendered text of a row, translating each
36/// to a cell column range. Empty matches are dropped. Trailing-padding
37/// spaces on a row would otherwise satisfy patterns like `\s+`; we trim
38/// those by clamping match ends to where actual content stops.
39fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
40    if row.is_empty() {
41        return Vec::new();
42    }
43    let last_content_col = row
44        .iter()
45        .enumerate()
46        .rev()
47        .find_map(|(c, cell)| match cell {
48            Cell::Char { width, .. } => Some(c + *width as usize),
49            Cell::Continuation => Some(c + 1),
50            Cell::Empty => None,
51        })
52        .unwrap_or(0);
53    if last_content_col == 0 {
54        return Vec::new();
55    }
56    let (text, starts) = row_text_and_starts(row);
57    let mut out = Vec::new();
58    for m in regex.find_iter(&text) {
59        if m.start() == m.end() {
60            continue;
61        }
62        let char_start = text[..m.start()].chars().count();
63        let char_end = text[..m.end()].chars().count();
64        if char_start >= starts.len() - 1 || char_end <= char_start {
65            continue;
66        }
67        let col_start = starts[char_start];
68        let col_end = starts[char_end].min(last_content_col);
69        if col_end > col_start {
70            out.push(col_start..col_end);
71        }
72    }
73    out
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RowStyle {
78    Normal,
79    /// Render with a reduced-emphasis terminal attribute. Used by `--dim` to
80    /// keep filtered-out lines visible as context.
81    Dim,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SearchDirection {
86    Forward,
87    Backward,
88}
89
90#[derive(Debug, Clone)]
91pub struct SearchState {
92    pub raw: String,
93    pub regex: Regex,
94    pub direction: SearchDirection,
95}
96
97#[derive(Debug, Clone)]
98pub struct Frame {
99    pub body: Vec<Vec<Cell>>,        // exactly (rows-1) entries
100    pub row_styles: Vec<RowStyle>,   // parallel to body
101    /// Per-row column ranges to render with reverse-video. Used by `/`
102    /// search to highlight just the matched phrase rather than the whole row.
103    /// Indexed parallel to `body`; each inner Vec holds column ranges in
104    /// `[start, end)` form (cell columns).
105    pub highlights: Vec<Vec<std::ops::Range<usize>>>,
106    pub status: String,
107}
108
109pub struct Viewport {
110    top_line: usize,
111    top_row: usize,
112    cols: u16,
113    rows: u16,
114    pub opts: RenderOpts,
115    pub show_line_numbers: bool,
116    pub source_label: String,
117    follow_mode: bool,
118    live_mode: bool,
119    prettify_label: Option<String>,
120    filter: Option<CompiledFilter>,
121    dim_mode: bool,
122    /// In hide mode (filter active, !dim), maps visible position → logical line
123    /// index. Empty otherwise.
124    visible_lines: Vec<usize>,
125    /// How many logical lines we've evaluated for filter membership. Used by
126    /// `extend_visible_lines` to avoid re-scanning lines on every tick.
127    visible_scanned: usize,
128    search: Option<SearchState>,
129    /// Active display template + format regex. When set, lines are rendered
130    /// through the template before being shown, searched, or counted for wraps.
131    /// Filtering still operates on the raw line (it uses captures, not text).
132    display: Option<crate::format::DisplayRenderer>,
133}
134
135impl Viewport {
136    pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
137        let mut opts = RenderOpts::default();
138        opts.cols = cols;
139        Self {
140            top_line: 0,
141            top_row: 0,
142            cols,
143            rows,
144            opts,
145            show_line_numbers: false,
146            source_label,
147            follow_mode: false,
148            live_mode: false,
149            prettify_label: None,
150            filter: None,
151            dim_mode: false,
152            visible_lines: Vec::new(),
153            visible_scanned: 0,
154            search: None,
155            display: None,
156        }
157    }
158
159    pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
160        self.display = renderer;
161    }
162
163    /// Fetch a logical line's display bytes — rendered through the active
164    /// display template if one is set and the line parses against the format
165    /// regex, otherwise the raw bytes. Used everywhere the *visible* form of
166    /// the line matters: rendering, search, wrap-row counting.
167    fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
168        let range = idx.line_range(line_n, src);
169        let raw = src.bytes(range);
170        if let Some(r) = self.display.as_ref() {
171            if let Some(rendered) = r.render_line(&raw) {
172                return std::borrow::Cow::Owned(rendered.into_bytes());
173            }
174        }
175        raw
176    }
177
178    /// Compile and store a search pattern. Returns the parse error from the
179    /// regex crate if the pattern is invalid; the previous search (if any)
180    /// is preserved on error.
181    pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
182        let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
183        self.search = Some(SearchState { raw, regex, direction });
184        Ok(())
185    }
186
187    pub fn clear_search(&mut self) { self.search = None; }
188
189    pub fn search_active(&self) -> bool { self.search.is_some() }
190
191    pub fn search_direction(&self) -> SearchDirection {
192        self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
193    }
194
195    /// Jump to the next match of the active search, in `direction` (or its
196    /// reverse if `reverse` is true). Wraps at the end of the source.
197    /// Returns true iff a match was found and the viewport moved.
198    pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
199        let Some(s) = self.search.as_ref() else { return false; };
200        let forward = match (s.direction, reverse) {
201            (SearchDirection::Forward, false) | (SearchDirection::Backward, true) => true,
202            _ => false,
203        };
204        idx.extend_to_end(src);
205        let pattern = s.regex.clone();
206        if self.hide_mode() {
207            self.extend_visible_lines(idx, src);
208            self.search_step_in_visible(&pattern, src, idx, forward)
209        } else {
210            self.search_step_in_logical(&pattern, src, idx, forward)
211        }
212    }
213
214    fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
215        // Search runs against the *displayed* bytes so what the user sees is
216        // what they can find. With a template active, that's the rendered form;
217        // otherwise the raw line.
218        let bytes = self.line_display_bytes(src, idx, line_n);
219        match std::str::from_utf8(&bytes) {
220            Ok(s) => pattern.is_match(s),
221            Err(_) => false,
222        }
223    }
224
225    fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
226        let total = idx.line_count();
227        if total == 0 { return false; }
228        let start = self.top_line;
229        // Walk every logical line once, starting from start+1 (or start-1)
230        // and wrapping at the end / beginning.
231        for offset in 1..=total {
232            let line_n = if forward {
233                (start + offset) % total
234            } else {
235                (start + total - offset) % total
236            };
237            if self.line_matches(pattern, src, idx, line_n) {
238                self.top_line = line_n;
239                self.top_row = 0;
240                return true;
241            }
242        }
243        false
244    }
245
246    fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
247        let total = self.visible_lines.len();
248        if total == 0 { return false; }
249        // Find current visible position for top_line.
250        let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
251        for offset in 1..=total {
252            let visible_idx = if forward {
253                (cur + offset) % total
254            } else {
255                (cur + total - offset) % total
256            };
257            let line_n = self.visible_lines[visible_idx];
258            if self.line_matches(pattern, src, idx, line_n) {
259                self.top_line = line_n;
260                self.top_row = 0;
261                return true;
262            }
263        }
264        false
265    }
266
267    pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
268        self.filter = filter;
269        self.visible_lines.clear();
270        self.visible_scanned = 0;
271        // Drop scroll state — line numbering may have changed under us.
272        self.top_line = 0;
273        self.top_row = 0;
274    }
275
276    pub fn set_dim_mode(&mut self, on: bool) {
277        self.dim_mode = on;
278        // Hide mode is the only mode that needs visible_lines; clear when
279        // turning dim ON, and re-derive from scratch when turning dim OFF
280        // (next extend_visible_lines call rebuilds it).
281        self.visible_lines.clear();
282        self.visible_scanned = 0;
283    }
284
285    pub fn filter_active(&self) -> bool { self.filter.is_some() }
286
287    pub fn dim_mode(&self) -> bool { self.dim_mode }
288
289    fn hide_mode(&self) -> bool { self.filter.is_some() && !self.dim_mode }
290
291    /// Walk any newly indexed logical lines and append matching ones to
292    /// `visible_lines` if we're in hide mode. No-op otherwise. Cheap to call
293    /// every loop tick — keeps a `visible_scanned` cursor.
294    pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
295        if !self.hide_mode() {
296            return;
297        }
298        let Some(filter) = self.filter.as_ref() else { return };
299        let total = idx.line_count();
300        while self.visible_scanned < total {
301            let line_n = self.visible_scanned;
302            let range = idx.line_range(line_n, src);
303            let bytes = src.bytes(range);
304            if matches!(filter.evaluate(&bytes), FilterMatch::Matched) {
305                self.visible_lines.push(line_n);
306            }
307            self.visible_scanned += 1;
308        }
309    }
310
311    pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
312
313    pub fn follow_mode(&self) -> bool { self.follow_mode }
314
315    pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
316
317    pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
318
319    pub fn live_mode(&self) -> bool { self.live_mode }
320
321    pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
322
323    /// Status-line label for active pretty-print state, e.g. `"json"` or
324    /// `"json:err"`. `None` means no indicator is shown.
325    pub fn set_prettify_label(&mut self, label: Option<String>) {
326        self.prettify_label = label;
327    }
328
329    /// Drop the per-line filter-membership cache without disturbing the filter
330    /// itself or scroll position. Used after a `--live` rebuild: line numbering
331    /// may have changed, so cached `visible_lines` is stale, but we want to
332    /// keep the same filter applied and let the user stay where they were.
333    pub fn invalidate_filter_cache(&mut self) {
334        self.visible_lines.clear();
335        self.visible_scanned = 0;
336    }
337
338    /// Clamp `top_line` so it doesn't fall past the new end of the source.
339    /// Pairs with `invalidate_filter_cache` after a content rewrite.
340    pub fn clamp_top_line(&mut self, line_count: usize) {
341        if line_count == 0 {
342            self.top_line = 0;
343            self.top_row = 0;
344        } else if self.top_line >= line_count {
345            self.top_line = line_count - 1;
346            self.top_row = 0;
347        }
348    }
349
350    /// True when the viewport's body window already covers the last line of
351    /// the source. New content added past this point should auto-scroll if
352    /// follow mode is on.
353    pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
354        let body = self.body_rows() as usize;
355        if self.hide_mode() {
356            // top_line is a logical line; find its position in visible_lines.
357            let pos = self
358                .visible_lines
359                .iter()
360                .position(|&l| l >= self.top_line)
361                .unwrap_or(self.visible_lines.len());
362            pos + body >= self.visible_lines.len()
363        } else {
364            self.top_line + body >= idx.line_count()
365        }
366    }
367
368    /// Width of the line-number gutter (digits + 1 space separator), 0 if disabled.
369    fn gutter_width(&self, idx: &LineIndex) -> u16 {
370        if !self.show_line_numbers { return 0; }
371        let n = idx.line_count().max(1);
372        let digits = (n as f64).log10().floor() as u16 + 1;
373        digits + 1
374    }
375
376    fn render_opts(&self, gutter: u16) -> RenderOpts {
377        let mut o = self.opts.clone();
378        o.cols = self.cols.saturating_sub(gutter);
379        o
380    }
381
382    pub fn frame(&self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
383        let body_rows = self.body_rows() as usize;
384        idx.extend_to_line(self.top_line + body_rows + 1, src);
385
386        let gutter = self.gutter_width(idx);
387        let r_opts = self.render_opts(gutter);
388
389        let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
390        let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
391        let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
392        // In hide mode we walk visible_lines; otherwise we walk logical lines.
393        let hide = self.hide_mode();
394        let total_lines = idx.line_count();
395
396        // For hide mode, find where the viewport starts in visible_lines.
397        let mut hide_pos = if hide {
398            self.visible_lines
399                .iter()
400                .position(|&l| l >= self.top_line)
401                .unwrap_or(self.visible_lines.len())
402        } else {
403            0
404        };
405        let mut line_n = if hide {
406            self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
407        } else {
408            self.top_line
409        };
410        let mut skip = if hide { 0 } else { self.top_row };
411
412        while body.len() < body_rows {
413            if line_n >= total_lines {
414                let mut row = Vec::with_capacity(self.cols as usize);
415                if gutter > 0 {
416                    for _ in 0..gutter { row.push(Cell::Empty); }
417                }
418                while row.len() < self.cols as usize { row.push(Cell::Empty); }
419                body.push(row);
420                row_styles.push(RowStyle::Normal);
421                highlights.push(Vec::new());
422                line_n += 1;
423                continue;
424            }
425            // Filter evaluation runs on the raw line (it uses captures, not
426            // text), but rendering goes through the template if one is set.
427            let raw = src.bytes(idx.line_range(line_n, src));
428            let display_bytes = if let Some(r) = self.display.as_ref() {
429                match r.render_line(&raw) {
430                    Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
431                    None => raw.clone(),
432                }
433            } else {
434                raw.clone()
435            };
436            let rows = render_line(&display_bytes, &r_opts);
437            let style = if let Some(f) = self.filter.as_ref() {
438                if self.dim_mode {
439                    match f.evaluate(&raw) {
440                        FilterMatch::Matched => RowStyle::Normal,
441                        _ => RowStyle::Dim,
442                    }
443                } else {
444                    // hide mode: only matching lines reach here
445                    RowStyle::Normal
446                }
447            } else {
448                RowStyle::Normal
449            };
450
451            for (i, mut content_row) in rows.into_iter().enumerate() {
452                if i < skip { continue; }
453                if body.len() >= body_rows { break; }
454                let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
455                if gutter > 0 {
456                    let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
457                    for c in label.chars() {
458                        full.push(Cell::Char { ch: c, width: 1 });
459                    }
460                }
461                full.append(&mut content_row);
462                // Compute search highlights for this display row by running
463                // the regex against the row's rendered text. Each match's
464                // char range maps to a cell column range via `starts`.
465                let row_highlights = if let Some(s) = self.search.as_ref() {
466                    find_row_highlights(&full, &s.regex)
467                } else {
468                    Vec::new()
469                };
470                body.push(full);
471                row_styles.push(style);
472                highlights.push(row_highlights);
473            }
474            skip = 0;
475            // Advance to next line — visible-space if hiding, logical-space otherwise.
476            if hide {
477                hide_pos += 1;
478                line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
479            } else {
480                line_n += 1;
481            }
482        }
483
484        let status = self.format_status(idx, src);
485        Frame { body, row_styles, highlights, status }
486    }
487
488    fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
489        let body_rows = self.body_rows() as usize;
490        let total = idx.line_count();
491        // In hide mode, the line range and percentage refer to visible (matched)
492        // lines, not the underlying logical line count.
493        let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
494            let visible_total = self.visible_lines.len();
495            // top_line is a logical line; find its visible index.
496            let cur = self
497                .visible_lines
498                .iter()
499                .position(|&l| l >= self.top_line)
500                .unwrap_or(visible_total);
501            let top = cur + 1;
502            let bottom = (cur + body_rows).min(visible_total.max(1));
503            let total_str = if src.is_complete() {
504                format!("{visible_total}/{total}")
505            } else {
506                format!("{visible_total}/{total}+")
507            };
508            (top, bottom, visible_total, total_str)
509        } else {
510            let top = self.top_line + 1;
511            let bottom = (self.top_line + body_rows).min(total.max(1));
512            let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
513            (top, bottom, total, total_str)
514        };
515        let pct = if total_for_pct == 0 { 0 } else { (bottom * 100) / total_for_pct };
516        let mut s = format!("{}  {}-{}/{}  {}%", self.source_label, top, bottom, total_str, pct);
517        // Wrap-row offset: when scrolled inside a long wrapping line, surface
518        // the offset so the user knows scrolling is happening at sub-line
519        // granularity. Without this the line range above stays static while
520        // pressing `j` and the scroll is invisible on repeating content.
521        if !self.hide_mode() && self.top_row > 0 {
522            let line_rows = if total > 0 {
523                let bytes = self.line_display_bytes(src, idx, self.top_line);
524                count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
525            } else { 1 };
526            s.push_str(&format!("  +{}/{}", self.top_row, line_rows));
527        }
528        if let Some(f) = self.filter.as_ref() {
529            s.push_str(&format!("  [{}]", f.format_name));
530            s.push_str(if self.dim_mode { "  [dim]" } else { "  [filter]" });
531        }
532        if let Some(sr) = self.search.as_ref() {
533            let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
534            s.push_str(&format!("  [{}{}]", prefix, sr.raw));
535        }
536        if let Some(label) = self.prettify_label.as_ref() {
537            s.push_str(&format!("  [pretty:{label}]"));
538        }
539        if self.live_mode { s.push_str("  (L)"); }
540        if self.follow_mode { s.push_str("  (F)"); }
541        s
542    }
543
544    /// Jump by whole logical lines, regardless of wrap rows. `top_row` is
545    /// reset to 0 so the start of the destination line is at the top of
546    /// the viewport. In hide mode this is equivalent to `scroll_lines`
547    /// (which already moves by visible/logical lines).
548    pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
549        if delta == 0 { return; }
550        if self.hide_mode() {
551            self.scroll_lines(delta, src, idx);
552            return;
553        }
554        if delta > 0 {
555            idx.extend_to_line(self.top_line + delta as usize + 1, src);
556            let total = idx.line_count();
557            if total == 0 { return; }
558            let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
559            self.top_line = target;
560            self.top_row = 0;
561        } else {
562            let back = (-delta) as usize;
563            // If we're inside a wrapped line (top_row > 0), `K` first snaps to
564            // the start of the current line; only the remaining count goes to
565            // previous lines. This matches the user's mental model of "jump
566            // to the start of the previous line".
567            let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
568            let extra_back = back.saturating_sub(consumed_for_snap);
569            self.top_line = self.top_line.saturating_sub(extra_back);
570            self.top_row = 0;
571        }
572    }
573
574    pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
575        if delta == 0 { return; }
576        if self.hide_mode() {
577            // Scroll by visible (matching) lines. We don't honor wrap rows in
578            // hide mode — top_row stays 0. Each unit of `delta` advances or
579            // retreats one visible line.
580            self.extend_visible_lines(idx, src);
581            let total = self.visible_lines.len();
582            if total == 0 {
583                self.top_line = 0;
584                self.top_row = 0;
585                return;
586            }
587            let cur = self
588                .visible_lines
589                .iter()
590                .position(|&l| l >= self.top_line)
591                .unwrap_or(total);
592            let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
593            self.top_line = self.visible_lines[new];
594            self.top_row = 0;
595            return;
596        }
597        if delta > 0 {
598            let mut remaining = delta as usize;
599            while remaining > 0 {
600                idx.extend_to_line(self.top_line + 1, src);
601                let total = idx.line_count();
602                if total == 0 { break; }
603                let bytes = self.line_display_bytes(src, idx, self.top_line);
604                let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
605                if self.top_row + 1 < line_rows {
606                    self.top_row += 1;
607                } else if self.top_line + 1 < total {
608                    self.top_row = 0;
609                    self.top_line += 1;
610                } else {
611                    break;
612                }
613                remaining -= 1;
614            }
615        } else {
616            let mut remaining = (-delta) as usize;
617            while remaining > 0 {
618                if self.top_row > 0 {
619                    self.top_row -= 1;
620                } else if self.top_line > 0 {
621                    self.top_line -= 1;
622                    let bytes = self.line_display_bytes(src, idx, self.top_line);
623                    let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
624                    self.top_row = line_rows.saturating_sub(1);
625                } else {
626                    break;
627                }
628                remaining -= 1;
629            }
630        }
631    }
632
633    pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
634        let n = self.body_rows() as i64;
635        self.scroll_lines(n, src, idx);
636    }
637
638    pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
639        let n = self.body_rows() as i64;
640        self.scroll_lines(-n, src, idx);
641    }
642
643    pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
644        let n = (self.body_rows() / 2).max(1) as i64;
645        self.scroll_lines(n, src, idx);
646    }
647
648    pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
649        let n = (self.body_rows() / 2).max(1) as i64;
650        self.scroll_lines(-n, src, idx);
651    }
652
653    pub fn goto_top(&mut self) {
654        self.top_line = 0;
655        self.top_row = 0;
656    }
657
658    pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
659        idx.extend_to_end(src);
660        let body = self.body_rows() as usize;
661        if self.hide_mode() {
662            self.extend_visible_lines(idx, src);
663            let total = self.visible_lines.len();
664            let target_visible = total.saturating_sub(body);
665            self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
666            self.top_row = 0;
667        } else {
668            let total = idx.line_count();
669            self.top_line = total.saturating_sub(body);
670            self.top_row = 0;
671        }
672    }
673
674    pub fn resize(&mut self, cols: u16, rows: u16) {
675        self.cols = cols.max(1);
676        self.rows = rows.max(2);
677        self.opts.cols = self.cols;
678    }
679
680    pub fn toggle_line_numbers(&mut self) {
681        self.show_line_numbers = !self.show_line_numbers;
682    }
683
684    pub fn toggle_chop(&mut self) {
685        self.opts.wrap = !self.opts.wrap;
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::source::MockSource;
693
694    fn setup(content: &[u8]) -> (MockSource, LineIndex) {
695        let m = MockSource::new();
696        m.append(content);
697        m.finish();
698        let idx = LineIndex::new();
699        (m, idx)
700    }
701
702    #[test]
703    fn frame_renders_body_height_rows() {
704        let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
705        let v = Viewport::new(10, 5, "test".into());  // body = 4
706        let frame = v.frame(&m, &mut idx);
707        assert_eq!(frame.body.len(), 4);
708        assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1 });
709        assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1 });
710    }
711
712    #[test]
713    fn scroll_down_advances_top_line() {
714        let (m, mut idx) = setup(b"a\nb\nc\nd\n");
715        let mut v = Viewport::new(10, 5, "test".into());
716        v.scroll_lines(2, &m, &mut idx);
717        assert_eq!(v.top_line, 2);
718        assert_eq!(v.top_row, 0);
719    }
720
721    #[test]
722    fn scroll_up_clamps_at_zero() {
723        let (m, mut idx) = setup(b"a\nb\nc\n");
724        let mut v = Viewport::new(10, 5, "test".into());
725        v.scroll_lines(-5, &m, &mut idx);
726        assert_eq!(v.top_line, 0);
727        assert_eq!(v.top_row, 0);
728    }
729
730    #[test]
731    fn scroll_down_clamps_at_last_line() {
732        let (m, mut idx) = setup(b"a\nb\nc\n");
733        let mut v = Viewport::new(10, 5, "test".into());
734        v.scroll_lines(50, &m, &mut idx);
735        assert_eq!(v.top_line, 2);
736    }
737
738    #[test]
739    fn scroll_logical_lines_skips_wrap_rows() {
740        // Line 0 has 50 wraps in a 10-col viewport. J should jump straight to line 1.
741        let mut content = vec![b'X'; 500];
742        content.push(b'\n');
743        content.extend_from_slice(b"second\n");
744        content.extend_from_slice(b"third\n");
745        let (m, mut idx) = setup(&content);
746        let mut v = Viewport::new(10, 8, "f".into());
747        v.scroll_logical_lines(1, &m, &mut idx);
748        assert_eq!((v.top_line, v.top_row), (1, 0));
749        v.scroll_logical_lines(1, &m, &mut idx);
750        assert_eq!((v.top_line, v.top_row), (2, 0));
751    }
752
753    #[test]
754    fn scroll_logical_lines_back_snaps_to_line_start() {
755        // Mid-wrap K should snap to start of current line first, then go back.
756        let mut content = vec![b'A'; 50];
757        content.push(b'\n');
758        content.extend_from_slice(&vec![b'B'; 50]);
759        content.push(b'\n');
760        let (m, mut idx) = setup(&content);
761        let mut v = Viewport::new(10, 8, "f".into());
762        v.scroll_lines(7, &m, &mut idx);
763        assert_eq!(v.top_line, 1, "should be on line 1");
764        assert!(v.top_row > 0, "should be inside line 1's wraps");
765        v.scroll_logical_lines(-1, &m, &mut idx);
766        assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
767        v.scroll_logical_lines(-1, &m, &mut idx);
768        assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
769    }
770
771    #[test]
772    fn scroll_down_walks_wraps_of_last_line() {
773        // Last line is 30 chars in a 10-col viewport → 3 wrap rows.
774        let mut content = b"first\n".to_vec();
775        content.extend_from_slice(&vec![b'X'; 30]);
776        content.push(b'\n');
777        let (m, mut idx) = setup(&content);
778        let mut v = Viewport::new(10, 5, "f".into());
779        v.scroll_lines(1, &m, &mut idx);
780        assert_eq!((v.top_line, v.top_row), (1, 0));
781        v.scroll_lines(1, &m, &mut idx);
782        assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
783        v.scroll_lines(1, &m, &mut idx);
784        assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
785    }
786
787    #[test]
788    fn scroll_down_walks_wrap_rows_within_long_line() {
789        // Line 0 is 30 chars in a 10-col viewport → 3 wrap rows. Body = 4.
790        let mut content = vec![b'X'; 30];
791        content.push(b'\n');
792        content.extend_from_slice(b"second\n");
793        let (m, mut idx) = setup(&content);
794        let mut v = Viewport::new(10, 5, "f".into());
795        v.scroll_lines(1, &m, &mut idx);
796        assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
797        v.scroll_lines(1, &m, &mut idx);
798        assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
799        v.scroll_lines(1, &m, &mut idx);
800        assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
801    }
802
803    #[test]
804    fn status_line_shows_range_and_pct() {
805        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
806        let v = Viewport::new(20, 5, "f".into());  // body = 4
807        let frame = v.frame(&m, &mut idx);
808        assert!(frame.status.starts_with("f  1-4/10"));
809    }
810
811    #[test]
812    fn page_down_advances_by_body_rows() {
813        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
814        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
815        v.page_down(&m, &mut idx);
816        assert_eq!(v.top_line, 4);
817    }
818
819    #[test]
820    fn page_up_then_page_down_returns_to_start_when_no_resize() {
821        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
822        let mut v = Viewport::new(10, 5, "f".into());
823        v.page_down(&m, &mut idx);
824        v.page_up(&m, &mut idx);
825        assert_eq!(v.top_line, 0);
826        assert_eq!(v.top_row, 0);
827    }
828
829    #[test]
830    fn half_page_down_advances_by_half_body() {
831        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
832        let mut v = Viewport::new(10, 7, "f".into());  // body = 6, half = 3
833        v.half_page_down(&m, &mut idx);
834        assert_eq!(v.top_line, 3);
835    }
836
837    #[test]
838    fn goto_top_resets_position() {
839        let (m, mut idx) = setup(b"1\n2\n3\n4\n");
840        let mut v = Viewport::new(10, 5, "f".into());
841        v.scroll_lines(2, &m, &mut idx);
842        v.goto_top();
843        assert_eq!(v.top_line, 0);
844        assert_eq!(v.top_row, 0);
845    }
846
847    #[test]
848    fn goto_bottom_scrolls_to_last_page() {
849        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
850        let mut v = Viewport::new(10, 5, "f".into());  // body = 4
851        v.goto_bottom(&m, &mut idx);
852        // Last page should show lines 7..=10 → top_line = 6.
853        assert_eq!(v.top_line, 6);
854    }
855
856    #[test]
857    fn resize_updates_dimensions_and_render_opts() {
858        let (m, mut idx) = setup(b"1\n2\n");
859        let mut v = Viewport::new(10, 5, "f".into());
860        v.resize(40, 12);
861        assert_eq!(v.cols, 40);
862        assert_eq!(v.rows, 12);
863        assert_eq!(v.opts.cols, 40);
864        let _ = v.frame(&m, &mut idx);
865    }
866
867    #[test]
868    fn toggle_line_numbers_changes_gutter() {
869        let (m, mut idx) = setup(b"a\nb\nc\n");
870        let mut v = Viewport::new(10, 5, "f".into());
871        let frame_off = v.frame(&m, &mut idx);
872        v.toggle_line_numbers();
873        let frame_on = v.frame(&m, &mut idx);
874        // With gutter, first cell is a digit or space, not 'a'.
875        assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1 });
876        assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1 });
877    }
878
879    #[test]
880    fn toggle_chop_changes_wrap_mode() {
881        let (m, mut idx) = setup(b"abcdefghij\n");
882        let mut v = Viewport::new(4, 5, "f".into());
883        v.toggle_chop();
884        let frame = v.frame(&m, &mut idx);
885        // After toggle_chop, the line is one row, not wrapped.
886        // Body row 0 is "abcd"; rows 1..3 are blank fill.
887        assert_eq!(frame.body[0][..4],
888            [Cell::Char { ch: 'a', width: 1 }, Cell::Char { ch: 'b', width: 1 },
889             Cell::Char { ch: 'c', width: 1 }, Cell::Char { ch: 'd', width: 1 }]);
890        // Row 1 should be all-empty (no wrap continuation).
891        assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
892    }
893
894    // ----- Follow mode -----
895
896    #[test]
897    fn is_at_bottom_initially_only_when_source_fits() {
898        let (m, mut idx) = setup(b"a\nb\n");  // 2 lines
899        let v = Viewport::new(10, 5, "f".into());  // body = 4 ≥ 2
900        idx.extend_to_end(&m);
901        assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
902    }
903
904    #[test]
905    fn is_at_bottom_false_when_top_and_more_lines_below() {
906        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
907        let v = Viewport::new(10, 5, "f".into());  // body = 4
908        idx.extend_to_end(&m);
909        assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
910    }
911
912    #[test]
913    fn is_at_bottom_true_after_goto_bottom() {
914        let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
915        let mut v = Viewport::new(10, 5, "f".into());
916        v.goto_bottom(&m, &mut idx);
917        assert!(v.is_at_bottom(&idx));
918    }
919
920    #[test]
921    fn status_shows_F_suffix_when_follow_mode_on() {
922        let (m, mut idx) = setup(b"a\nb\n");
923        let mut v = Viewport::new(20, 5, "f".into());
924        let frame_off = v.frame(&m, &mut idx);
925        assert!(!frame_off.status.contains("(F)"));
926        v.set_follow_mode(true);
927        let frame_on = v.frame(&m, &mut idx);
928        assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
929    }
930
931    #[test]
932    fn toggle_follow_flips_state() {
933        let mut v = Viewport::new(10, 5, "f".into());
934        assert!(!v.follow_mode());
935        v.toggle_follow();
936        assert!(v.follow_mode());
937        v.toggle_follow();
938        assert!(!v.follow_mode());
939    }
940
941    #[test]
942    fn status_shows_prettify_label_when_set() {
943        let (m, mut idx) = setup(b"a\n");
944        let mut v = Viewport::new(40, 5, "f".into());
945        let frame_off = v.frame(&m, &mut idx);
946        assert!(!frame_off.status.contains("[pretty"));
947        v.set_prettify_label(Some("json".into()));
948        let frame_on = v.frame(&m, &mut idx);
949        assert!(frame_on.status.contains("[pretty:json]"),
950            "expected [pretty:json] in status, got: {}", frame_on.status);
951        v.set_prettify_label(Some("json:err".into()));
952        let frame_err = v.frame(&m, &mut idx);
953        assert!(frame_err.status.contains("[pretty:json:err]"),
954            "expected [pretty:json:err] in status, got: {}", frame_err.status);
955    }
956
957    #[test]
958    fn status_shows_l_suffix_when_live_mode_on() {
959        let (m, mut idx) = setup(b"a\nb\n");
960        let mut v = Viewport::new(20, 5, "f".into());
961        let frame_off = v.frame(&m, &mut idx);
962        assert!(!frame_off.status.contains("(L)"));
963        v.set_live_mode(true);
964        let frame_on = v.frame(&m, &mut idx);
965        assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
966    }
967
968    #[test]
969    fn clamp_top_line_pulls_back_when_total_shrinks() {
970        let mut v = Viewport::new(20, 5, "f".into());
971        // Pretend we were on line 100, then a rewrite leaves only 10 lines.
972        v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); // no-op, just to satisfy
973        // Force top_line via a sequence; easiest: just call clamp directly.
974        // We can't poke private state, but clamp works regardless of how we got there.
975        v.clamp_top_line(100);  // total bigger than top_line=0, no change
976        v.clamp_top_line(0);    // empty source: must reset
977        // After clamp(0), line 0 is the floor.
978        // (No public getter for top_line; we verify indirectly by going to top.)
979        v.goto_top();
980        // Just confirm no panic and no overflow on subsequent frame composition.
981        let (m, mut idx) = setup(b"only\n");
982        let _ = v.frame(&m, &mut idx);
983    }
984
985    /// Simulates the app::run timeout-branch logic to verify auto-scroll engages
986    /// when follow mode is on and the viewport is at the bottom.
987    fn simulate_growth_tick(
988        v: &mut Viewport,
989        src: &MockSource,
990        idx: &mut LineIndex,
991    ) {
992        if !v.follow_mode() { return; }
993        let was_at_bottom = v.is_at_bottom(idx);
994        let lines_before = idx.line_count();
995        idx.notice_new_bytes(src);
996        if idx.line_count() != lines_before && was_at_bottom {
997            v.goto_bottom(src, idx);
998        }
999    }
1000
1001    #[test]
1002    fn auto_scroll_engages_when_at_bottom() {
1003        let m = MockSource::new();
1004        m.append(b"1\n2\n3\n4\n");  // 4 lines, body=4 fits
1005        let mut idx = LineIndex::new();
1006        let mut v = Viewport::new(10, 5, "f".into());
1007        v.set_follow_mode(true);
1008        idx.extend_to_end(&m);
1009        assert!(v.is_at_bottom(&idx));
1010        let top_before = {
1011            let f = v.frame(&m, &mut idx);
1012            f.status.clone()  // unused, just exercise frame
1013        };
1014        let _ = top_before;
1015        // Simulate growth: source gains 4 more lines.
1016        m.append(b"5\n6\n7\n8\n");
1017        simulate_growth_tick(&mut v, &m, &mut idx);
1018        // After auto-scroll, top_line should have advanced so the new last line is in view.
1019        assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1020        let frame = v.frame(&m, &mut idx);
1021        // The bottom-most body row should now contain the last logical line ('8').
1022        // Find which row has '8'.
1023        let last_row = &frame.body[frame.body.len() - 1];
1024        assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1 });
1025    }
1026
1027    #[test]
1028    fn auto_scroll_suppressed_when_scrolled_up() {
1029        let m = MockSource::new();
1030        m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n");  // 8 lines
1031        let mut idx = LineIndex::new();
1032        let mut v = Viewport::new(10, 5, "f".into());  // body=4
1033        v.set_follow_mode(true);
1034        idx.extend_to_end(&m);
1035        v.goto_bottom(&m, &mut idx);
1036        // Now scroll up off the bottom.
1037        v.scroll_lines(-2, &m, &mut idx);
1038        assert!(!v.is_at_bottom(&idx));
1039        let frame_before = v.frame(&m, &mut idx);
1040        let top_first_cell_before = frame_before.body[0][0].clone();
1041        // Simulate growth.
1042        m.append(b"9\n10\n");
1043        simulate_growth_tick(&mut v, &m, &mut idx);
1044        // Viewport should NOT have moved (auto-scroll suppressed).
1045        let frame_after = v.frame(&m, &mut idx);
1046        assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1047    }
1048
1049    // ----- Search -----
1050
1051    #[test]
1052    fn set_search_compiles_regex() {
1053        let mut v = Viewport::new(10, 5, "f".into());
1054        assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1055        assert!(v.search_active());
1056    }
1057
1058    #[test]
1059    fn set_search_rejects_bad_regex() {
1060        let mut v = Viewport::new(10, 5, "f".into());
1061        let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1062        assert!(!err.is_empty());
1063        assert!(!v.search_active(), "no search should be set on error");
1064    }
1065
1066    #[test]
1067    fn search_step_forward_finds_match_after_top() {
1068        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1069        let mut v = Viewport::new(20, 5, "f".into());
1070        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1071        let found = v.search_repeat(&m, &mut idx, false);
1072        assert!(found);
1073        // gamma is line 2 (0-indexed)
1074        assert_eq!(v.top_line, 2);
1075    }
1076
1077    #[test]
1078    fn search_step_backward_finds_match_before_top() {
1079        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1080        let mut v = Viewport::new(20, 5, "f".into());
1081        v.scroll_lines(4, &m, &mut idx); // top_line = 4
1082        v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1083        let found = v.search_repeat(&m, &mut idx, false);
1084        assert!(found);
1085        assert_eq!(v.top_line, 0);
1086    }
1087
1088    #[test]
1089    fn search_wraps_at_end() {
1090        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1091        let mut v = Viewport::new(20, 5, "f".into());
1092        v.scroll_lines(2, &m, &mut idx); // top_line = 2 (last line)
1093        v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1094        let found = v.search_repeat(&m, &mut idx, false);
1095        assert!(found, "search should wrap forward past EOF");
1096        assert_eq!(v.top_line, 0);
1097    }
1098
1099    #[test]
1100    fn search_no_match_returns_false_and_does_not_move() {
1101        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1102        let mut v = Viewport::new(20, 5, "f".into());
1103        v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1104        let found = v.search_repeat(&m, &mut idx, false);
1105        assert!(!found);
1106        assert_eq!(v.top_line, 0);
1107    }
1108
1109    #[test]
1110    fn frame_records_highlight_ranges_for_matches() {
1111        let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1112        let mut v = Viewport::new(20, 5, "f".into());
1113        v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1114        let frame = v.frame(&m, &mut idx);
1115        // Body has 4 rows; row 2 is "gamma" (5 chars at columns 0..5).
1116        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1117        assert!(frame.highlights[0].is_empty());
1118        assert!(frame.highlights[1].is_empty());
1119        assert_eq!(frame.highlights[2], vec![0..5]);
1120        assert!(frame.highlights[3].is_empty());
1121    }
1122
1123    #[test]
1124    fn frame_highlights_substring_inside_a_row() {
1125        let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1126        let mut v = Viewport::new(40, 5, "f".into());
1127        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1128        let frame = v.frame(&m, &mut idx);
1129        // "beta" starts at column 18 in the first row.
1130        assert_eq!(frame.highlights[0], vec![18..22]);
1131        assert!(frame.highlights[1].is_empty());
1132    }
1133
1134    #[test]
1135    fn search_highlight_with_filter_dim_keeps_row_dim() {
1136        // alpha matches filter → Normal. beta doesn't → Dim. Search for
1137        // "beta" should leave row style Dim and mark the substring 0..4.
1138        let (m, mut idx) = setup(b"alpha\nbeta\n");
1139        let mut v = Viewport::new(20, 5, "f".into());
1140        let fmt = crate::format::LogFormat::compile(
1141            "simple",
1142            r"^(?P<line>.+)$",
1143        )
1144        .unwrap();
1145        let f = crate::filter::CompiledFilter::compile(
1146            &fmt,
1147            vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1148        )
1149        .unwrap();
1150        v.set_filter(Some(f));
1151        v.set_dim_mode(true);
1152        v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1153        let frame = v.frame(&m, &mut idx);
1154        assert_eq!(frame.row_styles[0], RowStyle::Normal);
1155        assert_eq!(frame.row_styles[1], RowStyle::Dim);
1156        assert_eq!(frame.highlights[1], vec![0..4]);
1157    }
1158
1159    #[test]
1160    fn search_status_shows_pattern() {
1161        let (m, mut idx) = setup(b"x\n");
1162        let mut v = Viewport::new(20, 5, "f".into());
1163        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1164        let frame = v.frame(&m, &mut idx);
1165        assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1166    }
1167
1168    #[test]
1169    fn repeat_search_after_first_match_advances() {
1170        let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1171        let mut v = Viewport::new(40, 5, "f".into());
1172        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1173        assert!(v.search_repeat(&m, &mut idx, false));
1174        assert_eq!(v.top_line, 1, "first foo");
1175        v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1176        assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1177        assert_eq!(v.top_line, 3, "should advance to next foo");
1178    }
1179
1180    #[test]
1181    fn auto_scroll_paused_when_follow_off() {
1182        let m = MockSource::new();
1183        m.append(b"1\n2\n3\n4\n");
1184        let mut idx = LineIndex::new();
1185        let mut v = Viewport::new(10, 5, "f".into());
1186        // Follow is off; viewport at top.
1187        idx.extend_to_end(&m);
1188        let frame_before = v.frame(&m, &mut idx);
1189        let top_first_cell = frame_before.body[0][0].clone();
1190        m.append(b"5\n6\n7\n8\n");
1191        simulate_growth_tick(&mut v, &m, &mut idx);
1192        let frame_after = v.frame(&m, &mut idx);
1193        assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1194    }
1195}