Skip to main content

turbo_debug_console/
streamview.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! A scrollback view over styled cells, one `Vec<Cell>` per line.
5
6use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7
8use turbo_vision::core::draw::{Cell, DrawBuffer};
9use turbo_vision::core::event::{
10    Event, EventType, KB_DOWN, KB_END, KB_ESC, KB_HOME, KB_PGDN, KB_PGUP, KB_UP, MB_LEFT_BUTTON,
11};
12use turbo_vision::core::geometry::{Point, Rect};
13use turbo_vision::core::palette::{Attr, TvColor};
14use turbo_vision::core::state::{GF_GROW_HI_X, GF_GROW_HI_Y, GrowFlags};
15use turbo_vision::terminal::Terminal;
16use turbo_vision::views::view::{View, write_line_to_terminal};
17
18/// A caret position in the wrapped scrollback: an absolute display-row index
19/// (into `iter_rows()`, so it survives scrolling) and a column, where the
20/// column is a cell index in that row (`draw` maps cell index 1:1 to screen
21/// column). A caret at `col` sits just before the cell at `col`.
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23struct SelPos {
24    row: usize,
25    col: usize,
26}
27
28/// An active (continuous) selection between two carets.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30struct Selection {
31    anchor: SelPos,
32    head: SelPos,
33}
34
35/// Swaps foreground and background, preserving text style — the highlight for
36/// a selected cell.
37fn reverse(attr: Attr) -> Attr {
38    Attr::new(attr.bg, attr.fg).with_style(attr.style)
39}
40
41/// The two carets in reading order (top-to-bottom, left-to-right).
42fn order(a: SelPos, b: SelPos) -> (SelPos, SelPos) {
43    if (a.row, a.col) <= (b.row, b.col) {
44        (a, b)
45    } else {
46        (b, a)
47    }
48}
49
50/// A base character immediately followed by U+FE0F (the emoji presentation
51/// selector, VS-16) or U+FE0E (the text presentation selector, VS-15) forms
52/// one *presentation sequence* whose combined width can differ from the
53/// base character's own width in isolation. This is exactly the shape of
54/// plank's tool-call banner glyph (`🛠️` = U+1F6E0 + U+FE0F): the bare
55/// wrench is East-Asian-Width `Neutral` (width 1), but the fully-qualified
56/// emoji sequence the model actually emits is double-width. `unicode_width`
57/// only resolves this at the *string* level (`UnicodeWidthStr`), not per
58/// `char`, so a two-character lookahead is required to catch it — this is
59/// still the crate doing the Unicode-correctness work; nothing here is a
60/// hand-rolled codepoint table.
61const PRESENTATION_SELECTORS: [char; 2] = ['\u{FE0F}', '\u{FE0E}'];
62
63/// Normalizes a naive, one-`Cell`-per-`char` line into one `Cell` per
64/// terminal *column* — the invariant every other method in this module
65/// relies on (row width, wrapping's column-accurate break points, and
66/// `draw`'s column count).
67///
68/// A double-width character (an emoji, a CJK glyph) keeps its real `char`
69/// in the first cell and gets a filler cell for each additional column,
70/// mirroring `turbo_vision`'s own `DrawBuffer::move_str` convention: the
71/// terminal's cell-diffing flush already knows to skip a `'\0'` when
72/// encoding output, so an invented filler paints as blank if ever exposed
73/// (e.g. wrapping is careful never to cut a wide character in half, but if
74/// it ever did, this is what would be exposed) rather than emitting half a
75/// glyph. When the second column instead comes from a real
76/// trailing presentation selector, that selector's own character is kept
77/// as the filler — it is a genuine, zero-advance character, not a padding
78/// artifact, so `plain_text` must still hand it back on Save As.
79///
80/// A zero-width character (a combining mark, a selector whose sequence
81/// collapses to width 0) occupies no column and is dropped — again
82/// matching `move_str`, and this module's only way to keep the stored
83/// column count equal to the true rendered width without a codepoint-range
84/// table of our own.
85///
86/// Idempotent: a spacer cell (`ch == '\0'`) already produced by a previous
87/// call passes through unchanged, so re-normalizing already-normalized
88/// cells (e.g. lines rebuilt from `styled_lines()`) is harmless.
89fn normalize_line(cells: &[Cell]) -> Vec<Cell> {
90    let mut out = Vec::with_capacity(cells.len());
91    let mut i = 0;
92    while i < cells.len() {
93        let cell = cells[i];
94        if cell.ch == '\0' {
95            out.push(cell);
96            i += 1;
97            continue;
98        }
99
100        let next = cells.get(i + 1).copied();
101        let selector = next.filter(|n| PRESENTATION_SELECTORS.contains(&n.ch));
102
103        let width = if let Some(sel) = selector {
104            let mut seq = String::with_capacity(cell.ch.len_utf8() + sel.ch.len_utf8());
105            seq.push(cell.ch);
106            seq.push(sel.ch);
107            seq.width()
108        } else {
109            cell.ch.width().unwrap_or(0)
110        };
111
112        if width == 0 {
113            i += if selector.is_some() { 2 } else { 1 };
114            continue;
115        }
116
117        out.push(cell);
118        if let Some(sel) = selector {
119            out.push(sel);
120            for _ in 2..width {
121                out.push(Cell::new('\0', cell.attr));
122            }
123            i += 2;
124        } else {
125            for _ in 1..width {
126                out.push(Cell::new('\0', cell.attr));
127            }
128            i += 1;
129        }
130    }
131    out
132}
133
134/// Default scrollback depth.
135pub const DEFAULT_MAX_LINES: usize = 10_000;
136
137/// Splits one width-normalized logical line (one `Cell` per terminal column,
138/// per `normalize_line`'s invariant) into the display rows it wraps to at
139/// `width` columns.
140///
141/// Breaks at the last whitespace cell at or before the width boundary when
142/// one exists in the row being filled; otherwise breaks exactly at `width`.
143/// Because `cells` is already column-normalized, a wrap point chosen this
144/// way always falls on a column boundary and never between a double-width
145/// character's leading cell and its filler, since a filler cell (`ch ==
146/// '\0'`) is never itself whitespace and so is never chosen as, or split
147/// from, a break point ahead of its owner.
148///
149/// An empty line still yields one (empty) row, matching a real terminal:
150/// a blank logical line occupies one blank display row, not zero.
151fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
152    if width == 0 || cells.is_empty() {
153        return vec![cells.to_vec()];
154    }
155
156    let mut rows = Vec::new();
157    let mut rest = cells;
158    while rest.len() > width {
159        // Search for a break point: the last whitespace cell whose index is
160        // < width, scanning backwards from width - 1. A filler cell ('\0')
161        // is skipped as a candidate break (it is never whitespace) but does
162        // not stop the scan.
163        let mut break_at = None;
164        for i in (0..width).rev() {
165            if rest[i].ch.is_whitespace() {
166                break_at = Some(i);
167                break;
168            }
169        }
170        if let Some(i) = break_at {
171            rows.push(rest[..i].to_vec());
172            rest = &rest[i + 1..]; // drop the whitespace cell itself
173        } else {
174            // A plain character-break cut at `width` could land between a
175            // double-width character's leading cell and its filler ('\0');
176            // if so, pull the cut back one column so the whole glyph moves
177            // to the next row instead of splitting it.
178            let mut cut = width;
179            if cut > 1 && rest.get(cut).is_some_and(|c| c.ch == '\0') {
180                cut -= 1;
181            }
182            rows.push(rest[..cut].to_vec());
183            rest = &rest[cut..];
184        }
185    }
186    rows.push(rest.to_vec());
187    rows
188}
189
190/// A scrollback of styled lines, with autoscroll that releases when the user
191/// scrolls back and re-arms at the bottom.
192#[derive(Debug)]
193pub struct StreamView {
194    bounds: Rect,
195    /// How this view follows its parent when the terminal is resized.
196    ///
197    /// `View`'s default is 0, meaning fixed, and a fixed view is skipped by
198    /// the desktop's resize cascade: the window frame would resize around a
199    /// scrollback still wrapped for the old width. `HI_X | HI_Y` pins the
200    /// top-left and moves the bottom-right edge, which is what a view that
201    /// fills its window wants.
202    grow_mode: GrowFlags,
203    /// Completed lines, oldest first. This is the source of truth: the log
204    /// text as the producer sent it, one entry per logical line, never
205    /// baked with this window's current wrap points. `plain_text()` reads
206    /// from here, not from `wrapped`.
207    lines: Vec<Vec<Cell>>,
208    /// The line currently streaming in, not yet terminated by a newline.
209    partial: Option<Vec<Cell>>,
210    /// Display rows for `lines`, in order, each logical line's rows
211    /// contiguous. `draw` and all scroll arithmetic read only from here (and
212    /// from `partial_wrapped` below), never from `lines` directly.
213    wrapped: Vec<Vec<Cell>>,
214    /// How many display rows in `wrapped` each entry of `lines` currently
215    /// occupies, parallel to `lines`. Lets `trim` drop exactly the rows a
216    /// dropped logical line contributed without re-wrapping everything.
217    row_counts: Vec<usize>,
218    /// Display rows for the in-progress `partial` line, wrapped the same
219    /// way; kept separate from `wrapped` because `set_partial` replaces
220    /// rather than appends.
221    partial_wrapped: Vec<Vec<Cell>>,
222    /// Bounds by logical lines, not display rows: a narrower window wraps
223    /// the same history into more rows, and bounding by rows would make a
224    /// narrow window silently forget more history than a wide one for the
225    /// same underlying stream. Logical-line count is the stable, resize-
226    /// independent budget.
227    max_lines: usize,
228    /// Index of the topmost displayed row, in `wrapped`.
229    top: usize,
230    /// True while the view follows the tail.
231    follow: bool,
232    fill: Attr,
233    /// The active text selection, if any. Positions are in absolute
234    /// wrapped-row coordinates (see [`SelPos`]). Dropped whenever the buffer
235    /// mutates, since row indices would otherwise dangle.
236    selection: Option<Selection>,
237}
238
239impl StreamView {
240    #[must_use]
241    pub fn new(bounds: Rect) -> Self {
242        Self {
243            bounds,
244            grow_mode: GF_GROW_HI_X | GF_GROW_HI_Y,
245            lines: Vec::new(),
246            partial: None,
247            wrapped: Vec::new(),
248            row_counts: Vec::new(),
249            partial_wrapped: Vec::new(),
250            max_lines: DEFAULT_MAX_LINES,
251            top: 0,
252            follow: true,
253            fill: Attr::new(TvColor::LightGray, TvColor::Black),
254            selection: None,
255        }
256    }
257
258    fn width(&self) -> usize {
259        usize::try_from(self.bounds.width()).unwrap_or(0)
260    }
261
262    pub fn set_max_lines(&mut self, n: usize) {
263        self.max_lines = n.max(1);
264        self.trim();
265    }
266
267    /// Appends a completed line.
268    pub fn push_line(&mut self, cells: &[Cell]) {
269        // Row indices shift when the buffer grows/trims, so a held selection
270        // would dangle; drop it.
271        self.selection = None;
272        let normalized = normalize_line(cells);
273        let rows = wrap_cells(&normalized, self.width());
274        self.row_counts.push(rows.len());
275        self.wrapped.extend(rows);
276        self.lines.push(normalized);
277        self.trim();
278        if self.follow {
279            self.scroll_to_bottom();
280        }
281    }
282
283    /// Replaces the in-progress line. Called on every repaint while a line is
284    /// still streaming, so it must overwrite rather than append.
285    pub fn set_partial(&mut self, cells: &[Cell]) {
286        let cells = normalize_line(cells);
287        if cells.is_empty() {
288            self.partial = None;
289            self.partial_wrapped.clear();
290        } else {
291            self.partial_wrapped = wrap_cells(&cells, self.width());
292            self.partial = Some(cells);
293        }
294        if self.follow {
295            self.scroll_to_bottom();
296        }
297    }
298
299    pub fn clear(&mut self) {
300        self.lines.clear();
301        self.partial = None;
302        self.wrapped.clear();
303        self.row_counts.clear();
304        self.partial_wrapped.clear();
305        self.top = 0;
306        self.follow = true;
307        self.selection = None;
308    }
309
310    /// Total displayed lines, including the in-progress one.
311    #[must_use]
312    pub fn line_count(&self) -> usize {
313        self.lines.len() + usize::from(self.partial.is_some())
314    }
315
316    /// Total display rows currently shown, including the in-progress line's
317    /// wrapped rows. This is what scroll arithmetic (`page`, `max_top`, and
318    /// the keyboard handlers) counts, so scrolling lands correctly wherever
319    /// a wrapped long line pushes rows out of alignment with logical lines.
320    #[must_use]
321    pub fn row_count(&self) -> usize {
322        self.wrapped.len() + self.partial_wrapped.len()
323    }
324
325    /// Visible rows, i.e. the view height.
326    fn page(&self) -> usize {
327        usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
328    }
329
330    fn max_top(&self) -> usize {
331        self.row_count().saturating_sub(self.page())
332    }
333
334    /// Rewraps every logical line and the in-progress partial at the current
335    /// width, rebuilding `wrapped`, `row_counts` and `partial_wrapped` from
336    /// scratch. Needed whenever the width itself changes (a resize), since
337    /// every existing wrap point can be stale in either direction.
338    fn rewrap(&mut self) {
339        let width = self.width();
340        self.wrapped.clear();
341        self.row_counts.clear();
342        for line in &self.lines {
343            let rows = wrap_cells(line, width);
344            self.row_counts.push(rows.len());
345            self.wrapped.extend(rows);
346        }
347        self.partial_wrapped = match &self.partial {
348            Some(cells) => wrap_cells(cells, width),
349            None => Vec::new(),
350        };
351    }
352
353    pub fn scroll_to_bottom(&mut self) {
354        self.top = self.max_top();
355        self.follow = true;
356    }
357
358    pub fn scroll_to_top(&mut self) {
359        self.top = 0;
360        self.follow = false;
361    }
362
363    pub fn scroll_up(&mut self, n: usize) {
364        self.top = self.top.saturating_sub(n);
365        self.follow = false;
366    }
367
368    pub fn scroll_down(&mut self, n: usize) {
369        self.top = (self.top + n).min(self.max_top());
370        self.follow = self.top == self.max_top();
371    }
372
373    #[must_use]
374    pub fn is_at_bottom(&self) -> bool {
375        self.follow
376    }
377
378    // ---- selection ----
379
380    /// Selects the entire scrollback in stream mode. Leaves no selection if
381    /// the buffer is empty.
382    pub fn select_all(&mut self) {
383        let rows = self.row_count();
384        if rows == 0 {
385            self.selection = None;
386            return;
387        }
388        let last = rows - 1;
389        let last_len = self.row_at(last).map_or(0, Vec::len);
390        self.selection = Some(Selection {
391            anchor: SelPos { row: 0, col: 0 },
392            head: SelPos {
393                row: last,
394                col: last_len,
395            },
396        });
397    }
398
399    /// Sets a selection between two carets `(row, col)`. Order-independent:
400    /// anchor and head may be given in either order.
401    pub fn set_selection(&mut self, anchor: (usize, usize), head: (usize, usize)) {
402        self.selection = Some(Selection {
403            anchor: SelPos {
404                row: anchor.0,
405                col: anchor.1,
406            },
407            head: SelPos {
408                row: head.0,
409                col: head.1,
410            },
411        });
412    }
413
414    pub fn clear_selection(&mut self) {
415        self.selection = None;
416    }
417
418    #[must_use]
419    pub fn has_selection(&self) -> bool {
420        self.selection.is_some()
421    }
422
423    /// The selected text, or `None` when there is no selection. Logical lines
424    /// are reconstructed: a soft wrap within a line does not become a newline.
425    #[must_use]
426    pub fn selected_text(&self) -> Option<String> {
427        let sel = self.selection?;
428        let (start, end) = order(sel.anchor, sel.head);
429        let mut out = String::new();
430        for row in start.row..=end.row {
431            let Some(cells) = self.row_at(row) else {
432                continue;
433            };
434            let from = if row == start.row { start.col } else { 0 }.min(cells.len());
435            let to = if row == end.row { end.col } else { cells.len() }.min(cells.len());
436            if row > start.row && self.row_is_logical_start(row) {
437                out.push('\n');
438            }
439            out.extend(cells[from..to.max(from)].iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
440        }
441        Some(out)
442    }
443
444    fn row_at(&self, idx: usize) -> Option<&Vec<Cell>> {
445        self.iter_rows().nth(idx)
446    }
447
448    /// Maps a screen position to a caret in the scrollback, or `None` if it
449    /// falls outside the view or below the last row. The column is clamped to
450    /// the hit row's length, so dragging past a line's end caps at its end.
451    fn hit(&self, pos: Point) -> Option<SelPos> {
452        let x = usize::try_from(pos.x - self.bounds.a.x).ok()?;
453        let y = usize::try_from(pos.y - self.bounds.a.y).ok()?;
454        if y >= self.page() {
455            return None;
456        }
457        let abs_row = self.top + y;
458        let len = self.row_at(abs_row)?.len();
459        Some(SelPos {
460            row: abs_row,
461            col: x.min(len),
462        })
463    }
464
465    /// Whether the cell at absolute wrapped-row `abs_row`, column `col` (a cell
466    /// index) lies inside the current selection.
467    fn is_selected(&self, abs_row: usize, col: usize) -> bool {
468        let Some(sel) = self.selection else {
469            return false;
470        };
471        let (s, e) = order(sel.anchor, sel.head);
472        (abs_row, col) >= (s.row, s.col) && (abs_row, col) < (e.row, e.col)
473    }
474
475    /// Whether absolute wrapped-row `abs_row` is the first row of a logical
476    /// line (as opposed to a soft-wrap continuation of the one above).
477    fn row_is_logical_start(&self, abs_row: usize) -> bool {
478        let mut offset = 0;
479        for &count in &self.row_counts {
480            if abs_row == offset {
481                return true;
482            }
483            offset += count;
484        }
485        // `offset` now equals `wrapped.len()`, where the partial line begins.
486        abs_row == offset
487    }
488
489    /// The whole scrollback with attributes stripped, for File > Save As.
490    #[must_use]
491    pub fn plain_text(&self) -> String {
492        let mut out = String::new();
493        for (i, line) in self.iter_lines().enumerate() {
494            if i > 0 {
495                out.push('\n');
496            }
497            // Spacer cells (the second column of a wide char) carry no
498            // text of their own; skip them so the saved text round-trips
499            // the original characters with no padding artifacts.
500            out.extend(line.iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
501        }
502        out
503    }
504
505    /// The whole scrollback with attributes intact, for tests and golden files.
506    #[must_use]
507    pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
508        self.iter_lines().cloned().collect()
509    }
510
511    fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
512        self.lines.iter().chain(self.partial.iter())
513    }
514
515    /// Display rows currently on screen or scrolled to, in order: the wrapped
516    /// completed lines followed by the wrapped in-progress line.
517    fn iter_rows(&self) -> impl Iterator<Item = &Vec<Cell>> {
518        self.wrapped.iter().chain(self.partial_wrapped.iter())
519    }
520
521    /// Bounds the scrollback by logical lines (see `max_lines`'s doc
522    /// comment), dropping the oldest ones and exactly the display rows they
523    /// contributed to `wrapped`.
524    fn trim(&mut self) {
525        if self.lines.len() > self.max_lines {
526            let drop = self.lines.len() - self.max_lines;
527            self.lines.drain(..drop);
528            let dropped_rows: usize = self.row_counts.drain(..drop).sum();
529            self.wrapped.drain(..dropped_rows);
530            self.top = self.top.saturating_sub(dropped_rows);
531        }
532    }
533}
534
535impl View for StreamView {
536    fn bounds(&self) -> Rect {
537        self.bounds
538    }
539
540    fn set_bounds(&mut self, bounds: Rect) {
541        let width_changed = self.width() != usize::try_from(bounds.width()).unwrap_or(0);
542        self.bounds = bounds;
543        if width_changed {
544            self.rewrap();
545        }
546        if self.follow {
547            self.scroll_to_bottom();
548        } else {
549            self.top = self.top.min(self.max_top());
550        }
551    }
552
553    fn draw(&mut self, terminal: &mut Terminal) {
554        if self.bounds.height() <= 0 {
555            return;
556        }
557        let width = usize::try_from(self.bounds.width()).unwrap_or(0);
558        let page = self.page();
559        let rows: Vec<&Vec<Cell>> = self.iter_rows().skip(self.top).take(page).collect();
560
561        for row in 0..page {
562            let mut buf = DrawBuffer::new(width);
563            for i in 0..width {
564                buf.put_char(i, ' ', self.fill);
565            }
566            if let Some(line) = rows.get(row) {
567                let abs_row = self.top + row;
568                for (i, cell) in line.iter().take(width).enumerate() {
569                    let attr = if self.is_selected(abs_row, i) {
570                        reverse(cell.attr)
571                    } else {
572                        cell.attr
573                    };
574                    buf.put_char(i, cell.ch, attr);
575                }
576            }
577            let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
578            write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
579        }
580    }
581
582    fn handle_event(&mut self, event: &mut Event) {
583        match event.what {
584            EventType::Keyboard => {
585                let page = self.page();
586                match event.key_code {
587                    KB_UP => self.scroll_up(1),
588                    KB_DOWN => self.scroll_down(1),
589                    KB_PGUP => self.scroll_up(page),
590                    KB_PGDN => self.scroll_down(page),
591                    KB_HOME => self.scroll_to_top(),
592                    KB_END => self.scroll_to_bottom(),
593                    KB_ESC if self.selection.is_some() => self.clear_selection(),
594                    _ => return,
595                }
596                event.clear();
597            }
598            EventType::MouseDown if event.mouse.buttons & MB_LEFT_BUTTON != 0 => {
599                let Some(pos) = self.hit(event.mouse.pos) else {
600                    return;
601                };
602                self.selection = Some(Selection {
603                    anchor: pos,
604                    head: pos,
605                });
606                event.clear();
607            }
608            EventType::MouseMove | EventType::MouseAuto
609                if event.mouse.buttons & MB_LEFT_BUTTON != 0 =>
610            {
611                if let (Some(mut sel), Some(pos)) = (self.selection, self.hit(event.mouse.pos)) {
612                    sel.head = pos;
613                    self.selection = Some(sel);
614                    event.clear();
615                }
616            }
617            EventType::MouseUp => {
618                // A press with no drag (anchor == head) is a plain click: it
619                // selects nothing, so drop the empty selection.
620                if let Some(sel) = self.selection
621                    && sel.anchor == sel.head
622                {
623                    self.selection = None;
624                }
625                event.clear();
626            }
627            _ => {}
628        }
629    }
630
631    fn grow_mode(&self) -> GrowFlags {
632        self.grow_mode
633    }
634
635    fn set_grow_mode(&mut self, grow_mode: GrowFlags) {
636        self.grow_mode = grow_mode;
637    }
638
639    fn can_focus(&self) -> bool {
640        true
641    }
642
643    fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
644        // Cells already carry resolved `Attr`s (from `AnsiLineAssembler`), so
645        // there is no logical-color index for a palette to remap.
646        None
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use std::io;
654    use std::time::Duration;
655    use turbo_vision::core::palette::TvColor;
656    use turbo_vision::terminal::Backend;
657
658    fn line(s: &str) -> Vec<Cell> {
659        s.chars()
660            .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
661            .collect()
662    }
663
664    fn view() -> StreamView {
665        StreamView::new(Rect::new(0, 0, 40, 10))
666    }
667
668    #[test]
669    fn select_all_extracts_logical_lines_without_soft_wrap_newlines() {
670        let mut v = StreamView::new(Rect::new(0, 0, 10, 10));
671        v.push_line(&line("hello"));
672        v.push_line(&line("abcdefghijABCDEFGHIJ")); // 20 cols wraps at width 10
673        v.select_all();
674        assert_eq!(
675            v.selected_text().unwrap(),
676            "hello\nabcdefghijABCDEFGHIJ",
677            "soft wraps within a logical line must not become newlines"
678        );
679    }
680
681    #[test]
682    fn stream_selection_spans_from_anchor_to_head_across_a_line_break() {
683        let mut v = view();
684        v.push_line(&line("hello"));
685        v.push_line(&line("world"));
686        // caret before col 2 of row 0 to caret before col 3 of row 1
687        v.set_selection((0, 2), (1, 3));
688        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
689    }
690
691    #[test]
692    fn stream_selection_is_order_independent() {
693        let mut v = view();
694        v.push_line(&line("hello"));
695        v.push_line(&line("world"));
696        v.set_selection((1, 3), (0, 2)); // reversed
697        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
698    }
699
700    #[test]
701    fn no_selection_yields_no_text() {
702        let mut v = view();
703        v.push_line(&line("hello"));
704        assert!(v.selected_text().is_none());
705        assert!(!v.has_selection());
706    }
707
708    #[test]
709    fn selected_cells_render_reverse_video() {
710        let mut v = StreamView::new(Rect::new(0, 0, 8, 4));
711        v.push_line(&line("abcd"));
712        v.set_selection((0, 1), (0, 3)); // 'b','c'
713        let mut terminal = fake_terminal(20, 10);
714        v.draw(&mut terminal);
715        let a = terminal.read_cell(0, 0).unwrap(); // unselected 'a'
716        let b = terminal.read_cell(1, 0).unwrap(); // selected 'b'
717        assert_eq!(b.ch, 'b');
718        assert_eq!(b.attr.fg, a.attr.bg, "selected fg is the normal bg");
719        assert_eq!(b.attr.bg, a.attr.fg, "selected bg is the normal fg");
720        // The cell just past the selection ('d' region, col 3) is normal.
721        let d = terminal.read_cell(3, 0).unwrap();
722        assert_eq!(d.attr.fg, a.attr.fg, "col 3 is outside [1,3), so normal");
723    }
724
725    #[test]
726    fn mouse_drag_creates_a_stream_selection() {
727        let mut v = view();
728        v.push_line(&line("hello"));
729        v.push_line(&line("world"));
730        let mut down = Event::mouse(EventType::MouseDown, Point::new(2, 0), MB_LEFT_BUTTON, false);
731        v.handle_event(&mut down);
732        let mut mv = Event::mouse(EventType::MouseMove, Point::new(3, 1), MB_LEFT_BUTTON, false);
733        v.handle_event(&mut mv);
734        let mut up = Event::mouse(EventType::MouseUp, Point::new(3, 1), 0, false);
735        v.handle_event(&mut up);
736        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
737    }
738
739    #[test]
740    fn a_plain_click_clears_any_selection() {
741        let mut v = view();
742        v.push_line(&line("hello"));
743        v.select_all();
744        assert!(v.has_selection());
745        let mut down = Event::mouse(EventType::MouseDown, Point::new(2, 0), MB_LEFT_BUTTON, false);
746        v.handle_event(&mut down);
747        let mut up = Event::mouse(EventType::MouseUp, Point::new(2, 0), 0, false);
748        v.handle_event(&mut up);
749        assert!(!v.has_selection(), "click without drag deselects");
750    }
751
752    #[test]
753    fn esc_clears_the_selection() {
754        let mut v = view();
755        v.push_line(&line("hello"));
756        v.select_all();
757        let mut esc = Event::keyboard(KB_ESC);
758        v.handle_event(&mut esc);
759        assert!(!v.has_selection());
760    }
761
762    #[test]
763    fn mutating_the_buffer_clears_the_selection() {
764        let mut v = view();
765        v.push_line(&line("hello"));
766        v.select_all();
767        assert!(v.has_selection());
768        v.push_line(&line("more"));
769        assert!(!v.has_selection(), "new content must drop a stale selection");
770    }
771
772    /// An in-memory `Backend` for tests: no real TTY, fixed size, no I/O.
773    /// `Terminal::write_line`/`write_cell` write straight into `Terminal`'s
774    /// own in-memory buffer, so this stub only needs to satisfy
775    /// initialization and size queries for `Terminal::with_backend`.
776    struct FakeBackend {
777        width: u16,
778        height: u16,
779    }
780
781    impl Backend for FakeBackend {
782        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
783            self
784        }
785
786        fn init(&mut self) -> io::Result<()> {
787            Ok(())
788        }
789
790        fn cleanup(&mut self) -> io::Result<()> {
791            Ok(())
792        }
793
794        fn size(&self) -> io::Result<(u16, u16)> {
795            Ok((self.width, self.height))
796        }
797
798        fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
799            Ok(None)
800        }
801
802        fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
803            Ok(())
804        }
805
806        fn flush(&mut self) -> io::Result<()> {
807            Ok(())
808        }
809
810        fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
811            Ok(())
812        }
813
814        fn hide_cursor(&mut self) -> io::Result<()> {
815            Ok(())
816        }
817    }
818
819    fn fake_terminal(width: u16, height: u16) -> Terminal {
820        Terminal::with_backend(Box::new(FakeBackend { width, height }))
821            .expect("fake backend never fails to init")
822    }
823
824    /// A `Backend` that records every byte `Terminal::flush` actually sends
825    /// downstream, via a shared buffer -- the write-through path
826    /// `FakeBackend` above stubs out. `Terminal::flush` is the one place
827    /// that decides what physically reaches a real terminal (it does a
828    /// diffed, escape-coded re-encode of the cell buffer, and knowingly
829    /// skips `'\0'` filler cells), so a bug specific to *that* encoding is
830    /// invisible to any test that only inspects `Terminal::read_cell`,
831    /// which reflects the in-memory cell buffer `write_line` always
832    /// updates unconditionally.
833    #[derive(Clone, Default)]
834    struct RecordingBackend {
835        width: u16,
836        height: u16,
837        output: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
838    }
839
840    impl Backend for RecordingBackend {
841        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
842            self
843        }
844
845        fn init(&mut self) -> io::Result<()> {
846            Ok(())
847        }
848
849        fn cleanup(&mut self) -> io::Result<()> {
850            Ok(())
851        }
852
853        fn size(&self) -> io::Result<(u16, u16)> {
854            Ok((self.width, self.height))
855        }
856
857        fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
858            Ok(None)
859        }
860
861        fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
862            self.output.lock().unwrap().extend_from_slice(data);
863            Ok(())
864        }
865
866        fn flush(&mut self) -> io::Result<()> {
867            Ok(())
868        }
869
870        fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
871            Ok(())
872        }
873
874        fn hide_cursor(&mut self) -> io::Result<()> {
875            Ok(())
876        }
877    }
878
879    /// Builds a `Terminal` whose every `flush`-emitted byte lands in the
880    /// returned buffer, so a test can inspect what actually reaches a real
881    /// terminal rather than only the in-memory cell buffer.
882    fn recording_terminal(
883        width: u16,
884        height: u16,
885    ) -> (Terminal, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
886        let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
887        let backend = RecordingBackend {
888            width,
889            height,
890            output: output.clone(),
891        };
892        let terminal =
893            Terminal::with_backend(Box::new(backend)).expect("fake backend never fails to init");
894        (terminal, output)
895    }
896
897    /// Replays `flush`'s escape-coded byte stream onto a plain grid the way
898    /// a real terminal would: `ESC[row;colH` repositions the cursor
899    /// (1-indexed), an SGR color sequence is consumed and ignored, and every
900    /// other character is placed at the cursor and advances it by its own
901    /// display width -- 2 for a double-width glyph, 0 for a combining or
902    /// selector character, exactly as a real terminal renders it (not by
903    /// our internal one-`Cell`-per-logical-column bookkeeping, which is
904    /// precisely what could drift from physical reality). Bytes from
905    /// successive flushes are replayed in order onto the same grid, since a
906    /// real terminal's screen persists across flushes the same way.
907    fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
908        let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
909        let mut chars = text.chars().peekable();
910        let mut row = 0usize;
911        let mut col = 0usize;
912        while let Some(c) = chars.next() {
913            if c == '\u{1b}' && chars.peek() == Some(&'[') {
914                chars.next(); // consume '['
915                let mut params = String::new();
916                let mut final_byte = ' ';
917                for pc in chars.by_ref() {
918                    if pc.is_ascii_digit() || pc == ';' {
919                        params.push(pc);
920                    } else {
921                        final_byte = pc;
922                        break;
923                    }
924                }
925                if final_byte == 'H' {
926                    let mut parts = params.split(';');
927                    let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
928                    let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
929                    row = r.saturating_sub(1);
930                    col = cix.saturating_sub(1);
931                }
932                // An SGR ('m') sequence carries no cursor movement.
933                continue;
934            }
935            let width = c.width().unwrap_or(0);
936            if row < grid.len() && col < grid[row].len() {
937                grid[row][col] = c;
938            }
939            col += width;
940        }
941    }
942
943    #[test]
944    fn scrollback_cap_drops_oldest_lines() {
945        let mut v = view();
946        v.set_max_lines(3);
947        for i in 0..5 {
948            v.push_line(&line(&i.to_string()));
949        }
950        assert_eq!(v.line_count(), 3);
951        assert_eq!(v.plain_text(), "2\n3\n4");
952    }
953
954    #[test]
955    fn autoscroll_holds_at_bottom_while_lines_arrive() {
956        let mut v = view();
957        for i in 0..50 {
958            v.push_line(&line(&i.to_string()));
959        }
960        assert!(v.is_at_bottom());
961    }
962
963    #[test]
964    fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
965        let mut v = view();
966        for i in 0..50 {
967            v.push_line(&line(&i.to_string()));
968        }
969        v.scroll_up(5);
970        assert!(!v.is_at_bottom());
971        v.push_line(&line("new"));
972        assert!(
973            !v.is_at_bottom(),
974            "a new line must not yank a scrolled-back reader to the bottom"
975        );
976        v.scroll_to_bottom();
977        assert!(v.is_at_bottom());
978    }
979
980    #[test]
981    fn partial_line_is_replaced_not_appended() {
982        let mut v = view();
983        v.set_partial(&line("par"));
984        v.set_partial(&line("part"));
985        assert_eq!(v.plain_text(), "part");
986        assert_eq!(v.line_count(), 1);
987    }
988
989    #[test]
990    fn plain_text_strips_attributes() {
991        let mut v = view();
992        v.push_line(&[Cell::new('x', Attr::new(TvColor::LightRed, TvColor::Blue))]);
993        assert_eq!(v.plain_text(), "x");
994    }
995
996    #[test]
997    fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
998        let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
999        for i in 0..50 {
1000            v.push_line(&line(&i.to_string()));
1001        }
1002        // Scroll back so `top` sits well below the current max_top()
1003        // (line_count 50, page 5 -> max_top 45).
1004        v.scroll_to_top();
1005        v.scroll_down(40);
1006        assert!(!v.is_at_bottom());
1007        let old_top = v.top;
1008        assert!(old_top < v.max_top());
1009
1010        // Grow the view a lot: max_top() shrinks to line_count - new_page
1011        // (50 - 48 = 2), which is now well below the old `top` (40). Left
1012        // unclamped, that would leave blank rows at the bottom of the
1013        // viewport even though unshown history sits above.
1014        v.set_bounds(Rect::new(0, 0, 40, 48));
1015
1016        assert!(
1017            v.top <= v.max_top(),
1018            "top ({}) must not exceed max_top ({}) after growing",
1019            v.top,
1020            v.max_top()
1021        );
1022        let rows: Vec<&Vec<Cell>> = v.iter_rows().skip(v.top).take(v.page()).collect();
1023        assert_eq!(
1024            rows.len(),
1025            v.page().min(v.row_count()),
1026            "a full page of content should be visible after growing"
1027        );
1028    }
1029
1030    #[test]
1031    fn draw_clips_to_bounds_width() {
1032        let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
1033        v.push_line(&line("short")); // shorter than the 6-wide view
1034
1035        let mut terminal = fake_terminal(20, 10);
1036        v.draw(&mut terminal);
1037
1038        // Row 0 (bounds.a.y == 1): "short", padded with the fill space for
1039        // the remaining column.
1040        for (i, expected) in "short ".chars().enumerate() {
1041            let cell = terminal
1042                .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
1043                .expect("cell within terminal bounds");
1044            assert_eq!(cell.ch, expected);
1045        }
1046        // Nothing is drawn past the view's width (x == 8 is out of bounds).
1047        assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
1048
1049        // Nothing above the view's rows was touched.
1050        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1051    }
1052
1053    /// The exact banner glyph plank emits: U+1F6E0 HAMMER AND WRENCH followed
1054    /// by U+FE0F VARIATION SELECTOR-16 (the emoji presentation selector).
1055    /// The base character alone is East-Asian-Width `Neutral` (width 1 per
1056    /// `unicode-width`'s plain per-`char` rule) -- it is only the
1057    /// *emoji presentation sequence* (base + U+FE0F) that is double-width,
1058    /// which is exactly the sequence a real tool-call banner sends and the
1059    /// case this fix targets. `line()` builds one `Cell` per `char` here
1060    /// too, since `.chars()` splits the base and the selector into two
1061    /// separate `char`s -- the same shape `tracefmt`'s `cells()` produces.
1062    const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
1063
1064    #[test]
1065    fn wide_character_row_paints_the_correct_total_number_of_columns() {
1066        // wrench (2 columns) + space + x = 4 columns total.
1067        let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
1068        v.push_line(&line(&format!("{WRENCH} x")));
1069        let mut terminal = fake_terminal(20, 10);
1070        v.draw(&mut terminal);
1071
1072        // Column 0 holds the wrench glyph itself.
1073        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1074        // Column 1 is the wrench's second column: the trailing presentation
1075        // selector itself, kept (not an invented '\0') because it is a real
1076        // character.
1077        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1078        // The rest of the row lands at its true, width-aware columns.
1079        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1080        assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'x');
1081        // And the row is blank-padded for the remaining columns of the view.
1082        for x in 4..10 {
1083            assert_eq!(terminal.read_cell(x, 0).unwrap().ch, ' ');
1084        }
1085    }
1086
1087    #[test]
1088    fn text_after_a_wide_character_lands_at_the_right_column() {
1089        let mut v = StreamView::new(Rect::new(0, 0, 30, 4));
1090        v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1091        let mut terminal = fake_terminal(30, 10);
1092        v.draw(&mut terminal);
1093
1094        let expected = "\u{1F6E0}\u{FE0F} Reading src/dsml.rs";
1095        for (i, expected_ch) in expected.chars().enumerate() {
1096            let cell = terminal
1097                .read_cell(i16::try_from(i).unwrap(), 0)
1098                .expect("cell within terminal bounds");
1099            assert_eq!(cell.ch, expected_ch, "column {i} mismatch");
1100        }
1101    }
1102
1103    #[test]
1104    fn short_row_is_blank_padded_so_nothing_shows_through_from_beneath() {
1105        let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
1106        // First paint a row that fills the whole width...
1107        v.push_line(&line("XXXXXXXXXX"));
1108        let mut terminal = fake_terminal(20, 10);
1109        v.draw(&mut terminal);
1110        // ...then a shorter, width-shrinking row should overwrite every
1111        // column the first row touched, leaving nothing behind.
1112        v.clear();
1113        v.push_line(&line(&format!("{WRENCH}hi")));
1114        v.draw(&mut terminal);
1115
1116        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1117        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1118        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, 'h');
1119        assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'i');
1120        for x in 4..10 {
1121            assert_eq!(
1122                terminal.read_cell(x, 0).unwrap().ch,
1123                ' ',
1124                "column {x} must be blanked, not left over from the previous row"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn a_double_width_character_straddling_a_wrap_boundary_is_never_split() {
1131        // Columns: a b [中 col0] [中 col1: a '\0' filler cell] c d -- 6
1132        // columns, wrapped at width 3. A naive character-break cut at column
1133        // 3 would land squarely on the filler cell, splitting the glyph in
1134        // half; the wrap must instead push the whole character to the next
1135        // row.
1136        let mut v = StreamView::new(Rect::new(0, 0, 3, 4));
1137        v.push_line(&line("ab中cd"));
1138
1139        assert_eq!(v.row_count(), 3, "the 6-column line wraps to three rows");
1140
1141        let mut terminal = fake_terminal(20, 10);
1142        v.draw(&mut terminal);
1143
1144        // Row 0 holds only "ab": the wide character was pushed whole to the
1145        // next row rather than being split across the boundary.
1146        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, 'a');
1147        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, 'b');
1148
1149        // Row 1 holds the wide character (both its columns) followed by "c".
1150        assert_eq!(terminal.read_cell(0, 1).unwrap().ch, '中');
1151        assert_eq!(terminal.read_cell(1, 1).unwrap().ch, '\0');
1152        assert_eq!(terminal.read_cell(2, 1).unwrap().ch, 'c');
1153
1154        // Row 2 holds the remaining "d".
1155        assert_eq!(terminal.read_cell(0, 2).unwrap().ch, 'd');
1156    }
1157
1158    #[test]
1159    fn plain_text_round_trips_a_wide_character_with_no_padding_artifacts() {
1160        let mut v = view();
1161        v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1162        assert_eq!(v.plain_text(), format!("{WRENCH} Reading src/dsml.rs"));
1163    }
1164
1165    /// Reproduces the real, two-window bug: a lower window paints a row
1166    /// containing plank's real tool-call banner glyph and *flushes* it (not
1167    /// just `draw`s it -- the defect lives in what `Terminal::flush` sends
1168    /// downstream, invisible to any test that only checks
1169    /// `Terminal::read_cell`, since `write_line` updates the in-memory cell
1170    /// buffer unconditionally regardless of what flush later encodes). A
1171    /// second, unrelated window then opens on top with the same bounds and
1172    /// paints an all-blank row over the identical region, and flushes too.
1173    /// A real terminal's screen must show nothing left over from the first
1174    /// window afterwards.
1175    #[test]
1176    fn a_covering_window_s_flush_fully_blanks_a_row_that_held_a_wide_character() {
1177        let (mut terminal, output) = recording_terminal(30, 4);
1178        let mut grid = vec![vec![' '; 30]; 4];
1179
1180        // Lower window: the real banner line at row 0, drawn and flushed.
1181        let mut lower = StreamView::new(Rect::new(0, 0, 30, 4));
1182        lower.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
1183        lower.draw(&mut terminal);
1184        terminal
1185            .flush()
1186            .expect("flush never fails against a fake backend");
1187        replay_onto_grid(&output.lock().unwrap(), &mut grid);
1188        output.lock().unwrap().clear();
1189
1190        // Upper window: same bounds, no content of its own at all -- opens
1191        // on top and must blank every column of row 0 that the lower
1192        // window's banner occupied.
1193        let mut upper = StreamView::new(Rect::new(0, 0, 30, 4));
1194        upper.draw(&mut terminal);
1195        terminal
1196            .flush()
1197            .expect("flush never fails against a fake backend");
1198        replay_onto_grid(&output.lock().unwrap(), &mut grid);
1199
1200        // Row 0 must now be fully blank -- nothing from the lower window's
1201        // banner may still show through.
1202        for (col, &ch) in grid[0].iter().enumerate() {
1203            assert_eq!(
1204                ch, ' ',
1205                "row 0 column {col} still shows a leftover character from \
1206                 the window underneath: {grid:?}"
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn a_line_longer_than_the_width_wraps_across_the_right_number_of_rows_with_complete_content() {
1213        let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
1214        // 25 non-space characters at width 10 -> ceil(25/10) = 3 rows.
1215        let text = "abcdefghijklmnopqrstuvwxy";
1216        v.push_line(&line(text));
1217
1218        assert_eq!(v.row_count(), 3);
1219        assert_eq!(
1220            v.plain_text(),
1221            text,
1222            "wrapping must not drop or duplicate any character"
1223        );
1224
1225        // Also verify via the rendered rows that content is complete and in
1226        // order across them.
1227        let mut terminal = fake_terminal(20, 20);
1228        v.draw(&mut terminal);
1229        let mut rendered = String::new();
1230        for row in 0..3 {
1231            for col in 0..10 {
1232                rendered.push(terminal.read_cell(col, row).unwrap().ch);
1233            }
1234        }
1235        assert_eq!(rendered, "abcdefghijklmnopqrstuvwxy     ");
1236    }
1237
1238    #[test]
1239    fn a_wrap_breaks_at_a_space_rather_than_mid_word_when_one_is_available() {
1240        let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
1241        v.push_line(&line("hello world"));
1242
1243        // "hello world" is 11 columns wide; wrapping at 10 without a
1244        // space-aware break would cut mid-word ("hello worl" / "d"). The
1245        // break must instead land on the space, dropping it, and produce
1246        // "hello" / "world".
1247        assert_eq!(v.row_count(), 2);
1248        let mut terminal = fake_terminal(20, 20);
1249        v.draw(&mut terminal);
1250        for (i, expected) in "hello     ".chars().enumerate() {
1251            assert_eq!(
1252                terminal.read_cell(i16::try_from(i).unwrap(), 0).unwrap().ch,
1253                expected
1254            );
1255        }
1256        for (i, expected) in "world     ".chars().enumerate() {
1257            assert_eq!(
1258                terminal.read_cell(i16::try_from(i).unwrap(), 1).unwrap().ch,
1259                expected
1260            );
1261        }
1262    }
1263
1264    #[test]
1265    fn a_single_token_longer_than_the_width_is_broken_rather_than_truncated() {
1266        let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1267        // A 12-character token with no whitespace at all -- a long path,
1268        // say -- must still be fully visible, broken mid-token instead of
1269        // truncated.
1270        v.push_line(&line("abcdefghijkl"));
1271
1272        assert_eq!(v.row_count(), 3); // ceil(12/5) = 3
1273        assert_eq!(
1274            v.plain_text(),
1275            "abcdefghijkl",
1276            "the logical text is preserved even though it had to be broken mid-token"
1277        );
1278    }
1279
1280    #[test]
1281    fn plain_text_returns_the_original_unwrapped_logical_lines() {
1282        let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1283        v.push_line(&line("a much longer line than the five-column view"));
1284        v.push_line(&line("short"));
1285
1286        assert_eq!(
1287            v.plain_text(),
1288            "a much longer line than the five-column view\nshort",
1289            "Save As must get the original logical lines, not this window's wrap points"
1290        );
1291    }
1292
1293    #[test]
1294    fn resizing_narrower_then_wider_rewraps_and_content_survives_both() {
1295        let mut v = StreamView::new(Rect::new(0, 0, 20, 20));
1296        let text = "abcdefghijklmnopqrstuvwxyz";
1297        v.push_line(&line(text));
1298        assert_eq!(v.row_count(), 2); // ceil(26/20)
1299
1300        v.set_bounds(Rect::new(0, 0, 5, 20));
1301        assert_eq!(v.row_count(), 6); // ceil(26/5)
1302        assert_eq!(v.plain_text(), text);
1303
1304        v.set_bounds(Rect::new(0, 0, 30, 20));
1305        assert_eq!(v.row_count(), 1); // fits on one row now
1306        assert_eq!(v.plain_text(), text);
1307    }
1308
1309    #[test]
1310    fn scrolling_by_page_lands_correctly_when_wrapped_rows_are_present() {
1311        // One long line that wraps to 20 rows, in a 5-row-tall view.
1312        let mut v = StreamView::new(Rect::new(0, 0, 4, 5));
1313        let text: String = (0..80).map(|i| char::from(b'a' + (i % 26))).collect();
1314        v.push_line(&line(&text));
1315        assert_eq!(v.row_count(), 20);
1316
1317        v.scroll_to_top();
1318        assert_eq!(v.top, 0);
1319        v.scroll_down(v.page()); // one page down: page() == 5
1320        assert_eq!(
1321            v.top, 5,
1322            "paging must move by display rows, not logical lines"
1323        );
1324
1325        v.scroll_to_bottom();
1326        assert_eq!(v.top, v.row_count() - v.page());
1327    }
1328
1329    #[test]
1330    fn draw_on_zero_height_view_writes_nothing() {
1331        let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
1332        v.push_line(&line("hello"));
1333        let mut terminal = fake_terminal(20, 10);
1334        v.draw(&mut terminal);
1335        for y in 0..10 {
1336            for x in 0..20 {
1337                assert_eq!(
1338                    terminal.read_cell(x, y).unwrap().ch,
1339                    ' ',
1340                    "zero-height view must not write any cell"
1341                );
1342            }
1343        }
1344    }
1345}