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/// The shape a selection takes.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30enum SelMode {
31    /// Everything between the two carets in reading order, wrapping at the
32    /// end of each row.
33    Stream,
34    /// The rectangular column band between the two carets, taken from every
35    /// row they span.
36    Block,
37}
38
39/// An active selection between two carets.
40///
41/// The shape is fixed when the selection starts, the way `Editor` fixes its
42/// own `selection_mode`: toggling Edit > Block mode mid-drag would otherwise
43/// change what is already highlighted under the pointer.
44#[derive(Clone, Copy, PartialEq, Eq, Debug)]
45struct Selection {
46    anchor: SelPos,
47    head: SelPos,
48    mode: SelMode,
49}
50
51impl SelMode {
52    /// The shape implied by the global block-edit mode (Edit > Block mode).
53    fn from_global() -> Self {
54        if turbo_vision::core::state::block_edit_mode() {
55            Self::Block
56        } else {
57            Self::Stream
58        }
59    }
60}
61
62/// Swaps foreground and background, preserving text style — the highlight for
63/// a selected cell.
64fn reverse(attr: Attr) -> Attr {
65    Attr::new(attr.bg, attr.fg).with_style(attr.style)
66}
67
68/// The two carets in reading order (top-to-bottom, left-to-right).
69fn order(a: SelPos, b: SelPos) -> (SelPos, SelPos) {
70    if (a.row, a.col) <= (b.row, b.col) {
71        (a, b)
72    } else {
73        (b, a)
74    }
75}
76
77/// The row span and half-open column band of a rectangular selection, as
78/// `(top, bottom, left, right)`. Either caret may be the top-left one.
79fn block_bounds(sel: Selection) -> (usize, usize, usize, usize) {
80    let (a, b) = (sel.anchor, sel.head);
81    (
82        a.row.min(b.row),
83        a.row.max(b.row),
84        a.col.min(b.col),
85        a.col.max(b.col),
86    )
87}
88
89/// A base character immediately followed by U+FE0F (the emoji presentation
90/// selector, VS-16) or U+FE0E (the text presentation selector, VS-15) forms
91/// one *presentation sequence* whose combined width can differ from the
92/// base character's own width in isolation. This is exactly the shape of
93/// plank's tool-call banner glyph (`🛠️` = U+1F6E0 + U+FE0F): the bare
94/// wrench is East-Asian-Width `Neutral` (width 1), but the fully-qualified
95/// emoji sequence the model actually emits is double-width. `unicode_width`
96/// only resolves this at the *string* level (`UnicodeWidthStr`), not per
97/// `char`, so a two-character lookahead is required to catch it — this is
98/// still the crate doing the Unicode-correctness work; nothing here is a
99/// hand-rolled codepoint table.
100const PRESENTATION_SELECTORS: [char; 2] = ['\u{FE0F}', '\u{FE0E}'];
101
102/// Normalizes a naive, one-`Cell`-per-`char` line into one `Cell` per
103/// terminal *column* — the invariant every other method in this module
104/// relies on (row width, wrapping's column-accurate break points, and
105/// `draw`'s column count).
106///
107/// A double-width character (an emoji, a CJK glyph) keeps its real `char`
108/// in the first cell and gets a filler cell for each additional column,
109/// mirroring `turbo_vision`'s own `DrawBuffer::move_str` convention: the
110/// terminal's cell-diffing flush already knows to skip a `'\0'` when
111/// encoding output, so an invented filler paints as blank if ever exposed
112/// (e.g. wrapping is careful never to cut a wide character in half, but if
113/// it ever did, this is what would be exposed) rather than emitting half a
114/// glyph. When the second column instead comes from a real
115/// trailing presentation selector, that selector's own character is kept
116/// as the filler — it is a genuine, zero-advance character, not a padding
117/// artifact, so `plain_text` must still hand it back on Save As.
118///
119/// A zero-width character (a combining mark, a selector whose sequence
120/// collapses to width 0) occupies no column and is dropped — again
121/// matching `move_str`, and this module's only way to keep the stored
122/// column count equal to the true rendered width without a codepoint-range
123/// table of our own.
124///
125/// Idempotent: a spacer cell (`ch == '\0'`) already produced by a previous
126/// call passes through unchanged, so re-normalizing already-normalized
127/// cells (e.g. lines rebuilt from `styled_lines()`) is harmless.
128fn normalize_line(cells: &[Cell]) -> Vec<Cell> {
129    let mut out = Vec::with_capacity(cells.len());
130    let mut i = 0;
131    while i < cells.len() {
132        let cell = cells[i];
133        if cell.ch == '\0' {
134            out.push(cell);
135            i += 1;
136            continue;
137        }
138
139        let next = cells.get(i + 1).copied();
140        let selector = next.filter(|n| PRESENTATION_SELECTORS.contains(&n.ch));
141
142        let width = if let Some(sel) = selector {
143            let mut seq = String::with_capacity(cell.ch.len_utf8() + sel.ch.len_utf8());
144            seq.push(cell.ch);
145            seq.push(sel.ch);
146            seq.width()
147        } else {
148            cell.ch.width().unwrap_or(0)
149        };
150
151        if width == 0 {
152            i += if selector.is_some() { 2 } else { 1 };
153            continue;
154        }
155
156        out.push(cell);
157        if let Some(sel) = selector {
158            out.push(sel);
159            for _ in 2..width {
160                out.push(Cell::new('\0', cell.attr));
161            }
162            i += 2;
163        } else {
164            for _ in 1..width {
165                out.push(Cell::new('\0', cell.attr));
166            }
167            i += 1;
168        }
169    }
170    out
171}
172
173/// Rows scrolled per mouse-wheel notch.
174const WHEEL_STEP: usize = 3;
175
176/// Columns reserved at the right edge of the view for the vertical scrollbar.
177const SCROLLBAR_WIDTH: usize = 1;
178
179/// Default scrollback depth.
180pub const DEFAULT_MAX_LINES: usize = 10_000;
181
182/// Splits one width-normalized logical line (one `Cell` per terminal column,
183/// per `normalize_line`'s invariant) into the display rows it wraps to at
184/// `width` columns.
185///
186/// Breaks at the last whitespace cell at or before the width boundary when
187/// one exists in the row being filled; otherwise breaks exactly at `width`.
188/// Because `cells` is already column-normalized, a wrap point chosen this
189/// way always falls on a column boundary and never between a double-width
190/// character's leading cell and its filler, since a filler cell (`ch ==
191/// '\0'`) is never itself whitespace and so is never chosen as, or split
192/// from, a break point ahead of its owner.
193///
194/// An empty line still yields one (empty) row, matching a real terminal:
195/// a blank logical line occupies one blank display row, not zero.
196fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
197    if width == 0 || cells.is_empty() {
198        return vec![cells.to_vec()];
199    }
200
201    let mut rows = Vec::new();
202    let mut rest = cells;
203    while rest.len() > width {
204        // Search for a break point: the last whitespace cell whose index is
205        // < width, scanning backwards from width - 1. A filler cell ('\0')
206        // is skipped as a candidate break (it is never whitespace) but does
207        // not stop the scan.
208        let mut break_at = None;
209        for i in (0..width).rev() {
210            if rest[i].ch.is_whitespace() {
211                break_at = Some(i);
212                break;
213            }
214        }
215        if let Some(i) = break_at {
216            rows.push(rest[..i].to_vec());
217            rest = &rest[i + 1..]; // drop the whitespace cell itself
218        } else {
219            // A plain character-break cut at `width` could land between a
220            // double-width character's leading cell and its filler ('\0');
221            // if so, pull the cut back one column so the whole glyph moves
222            // to the next row instead of splitting it.
223            let mut cut = width;
224            if cut > 1 && rest.get(cut).is_some_and(|c| c.ch == '\0') {
225                cut -= 1;
226            }
227            rows.push(rest[..cut].to_vec());
228            rest = &rest[cut..];
229        }
230    }
231    rows.push(rest.to_vec());
232    rows
233}
234
235/// A scrollback of styled lines, with autoscroll that releases when the user
236/// scrolls back and re-arms at the bottom.
237#[derive(Debug)]
238pub struct StreamView {
239    bounds: Rect,
240    /// How this view follows its parent when the terminal is resized.
241    ///
242    /// `View`'s default is 0, meaning fixed, and a fixed view is skipped by
243    /// the desktop's resize cascade: the window frame would resize around a
244    /// scrollback still wrapped for the old width. `HI_X | HI_Y` pins the
245    /// top-left and moves the bottom-right edge, which is what a view that
246    /// fills its window wants.
247    grow_mode: GrowFlags,
248    /// Completed lines, oldest first. This is the source of truth: the log
249    /// text as the producer sent it, one entry per logical line, never
250    /// baked with this window's current wrap points. `plain_text()` reads
251    /// from here, not from `wrapped`.
252    lines: Vec<Vec<Cell>>,
253    /// The line currently streaming in, not yet terminated by a newline.
254    partial: Option<Vec<Cell>>,
255    /// Display rows for `lines`, in order, each logical line's rows
256    /// contiguous. `draw` and all scroll arithmetic read only from here (and
257    /// from `partial_wrapped` below), never from `lines` directly.
258    wrapped: Vec<Vec<Cell>>,
259    /// How many display rows in `wrapped` each entry of `lines` currently
260    /// occupies, parallel to `lines`. Lets `trim` drop exactly the rows a
261    /// dropped logical line contributed without re-wrapping everything.
262    row_counts: Vec<usize>,
263    /// Display rows for the in-progress `partial` line, wrapped the same
264    /// way; kept separate from `wrapped` because `set_partial` replaces
265    /// rather than appends.
266    partial_wrapped: Vec<Vec<Cell>>,
267    /// Bounds by logical lines, not display rows: a narrower window wraps
268    /// the same history into more rows, and bounding by rows would make a
269    /// narrow window silently forget more history than a wide one for the
270    /// same underlying stream. Logical-line count is the stable, resize-
271    /// independent budget.
272    max_lines: usize,
273    /// Index of the topmost displayed row, in `wrapped`.
274    top: usize,
275    /// True while the view follows the tail.
276    follow: bool,
277    fill: Attr,
278    /// The active text selection, if any. Positions are in absolute
279    /// wrapped-row coordinates (see [`SelPos`]). Dropped whenever the buffer
280    /// mutates, since row indices would otherwise dangle.
281    selection: Option<Selection>,
282    /// True while the scrollbar thumb is being dragged with the mouse.
283    dragging_thumb: bool,
284}
285
286impl StreamView {
287    #[must_use]
288    pub fn new(bounds: Rect) -> Self {
289        Self {
290            bounds,
291            grow_mode: GF_GROW_HI_X | GF_GROW_HI_Y,
292            lines: Vec::new(),
293            partial: None,
294            wrapped: Vec::new(),
295            row_counts: Vec::new(),
296            partial_wrapped: Vec::new(),
297            max_lines: DEFAULT_MAX_LINES,
298            top: 0,
299            follow: true,
300            fill: Attr::new(TvColor::LightGray, TvColor::Black),
301            selection: None,
302            dragging_thumb: false,
303        }
304    }
305
306    /// Width of the text area: the view minus the scrollbar column.
307    fn width(&self) -> usize {
308        usize::try_from(self.bounds.width())
309            .unwrap_or(0)
310            .saturating_sub(SCROLLBAR_WIDTH)
311    }
312
313    /// Screen column of the scrollbar.
314    fn scrollbar_x(&self) -> i16 {
315        self.bounds.b.x - 1
316    }
317
318    pub fn set_max_lines(&mut self, n: usize) {
319        self.max_lines = n.max(1);
320        self.trim();
321    }
322
323    /// Appends a completed line.
324    pub fn push_line(&mut self, cells: &[Cell]) {
325        // Row indices shift when the buffer grows/trims, so a held selection
326        // would dangle; drop it.
327        self.selection = None;
328        let normalized = normalize_line(cells);
329        let rows = wrap_cells(&normalized, self.width());
330        self.row_counts.push(rows.len());
331        self.wrapped.extend(rows);
332        self.lines.push(normalized);
333        self.trim();
334        if self.follow {
335            self.scroll_to_bottom();
336        }
337    }
338
339    /// Replaces the in-progress line. Called on every repaint while a line is
340    /// still streaming, so it must overwrite rather than append.
341    pub fn set_partial(&mut self, cells: &[Cell]) {
342        let cells = normalize_line(cells);
343        if cells.is_empty() {
344            self.partial = None;
345            self.partial_wrapped.clear();
346        } else {
347            self.partial_wrapped = wrap_cells(&cells, self.width());
348            self.partial = Some(cells);
349        }
350        if self.follow {
351            self.scroll_to_bottom();
352        }
353    }
354
355    pub fn clear(&mut self) {
356        self.lines.clear();
357        self.partial = None;
358        self.wrapped.clear();
359        self.row_counts.clear();
360        self.partial_wrapped.clear();
361        self.top = 0;
362        self.follow = true;
363        self.selection = None;
364    }
365
366    /// Total displayed lines, including the in-progress one.
367    #[must_use]
368    pub fn line_count(&self) -> usize {
369        self.lines.len() + usize::from(self.partial.is_some())
370    }
371
372    /// Total display rows currently shown, including the in-progress line's
373    /// wrapped rows. This is what scroll arithmetic (`page`, `max_top`, and
374    /// the keyboard handlers) counts, so scrolling lands correctly wherever
375    /// a wrapped long line pushes rows out of alignment with logical lines.
376    #[must_use]
377    pub fn row_count(&self) -> usize {
378        self.wrapped.len() + self.partial_wrapped.len()
379    }
380
381    /// Visible rows, i.e. the view height.
382    fn page(&self) -> usize {
383        usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
384    }
385
386    fn max_top(&self) -> usize {
387        self.row_count().saturating_sub(self.page())
388    }
389
390    /// Rewraps every logical line and the in-progress partial at the current
391    /// width, rebuilding `wrapped`, `row_counts` and `partial_wrapped` from
392    /// scratch. Needed whenever the width itself changes (a resize), since
393    /// every existing wrap point can be stale in either direction.
394    fn rewrap(&mut self) {
395        let width = self.width();
396        self.wrapped.clear();
397        self.row_counts.clear();
398        for line in &self.lines {
399            let rows = wrap_cells(line, width);
400            self.row_counts.push(rows.len());
401            self.wrapped.extend(rows);
402        }
403        self.partial_wrapped = match &self.partial {
404            Some(cells) => wrap_cells(cells, width),
405            None => Vec::new(),
406        };
407    }
408
409    pub fn scroll_to_bottom(&mut self) {
410        self.top = self.max_top();
411        self.follow = true;
412    }
413
414    pub fn scroll_to_top(&mut self) {
415        self.top = 0;
416        self.follow = false;
417    }
418
419    pub fn scroll_up(&mut self, n: usize) {
420        self.top = self.top.saturating_sub(n);
421        self.follow = false;
422    }
423
424    pub fn scroll_down(&mut self, n: usize) {
425        self.set_top(self.top + n);
426    }
427
428    /// Scrolls so that `top` is the first visible row, clamped to the
429    /// scrollback; re-arms autoscroll when that lands on the last page.
430    pub fn set_top(&mut self, top: usize) {
431        self.top = top.min(self.max_top());
432        self.follow = self.top == self.max_top();
433    }
434
435    /// Index of the topmost visible row.
436    #[must_use]
437    pub fn top(&self) -> usize {
438        self.top
439    }
440
441    // ---- scrollbar ----
442
443    /// Track cells between the two arrows (the arrows are dropped when the
444    /// view is too short to hold them).
445    fn track_len(&self) -> usize {
446        let page = self.page();
447        if page >= 3 { page - 2 } else { page }
448    }
449
450    /// Row offset of the first track cell from the top of the view.
451    fn track_start(&self) -> usize {
452        usize::from(self.page() >= 3)
453    }
454
455    /// `(thumb_start, thumb_len)` in track cells, or `None` when everything
456    /// fits and there is nothing to scroll.
457    fn thumb(&self) -> Option<(usize, usize)> {
458        let rows = self.row_count();
459        let page = self.page();
460        let track = self.track_len();
461        if rows <= page || track == 0 {
462            return None;
463        }
464        let len = (track * page / rows).clamp(1, track);
465        let usable = track - len;
466        let start = if usable == 0 {
467            0
468        } else {
469            (self.top * usable).div_ceil(self.max_top()).min(usable)
470        };
471        Some((start, len))
472    }
473
474    /// Maps a screen row on the scrollbar track to a `top`, for thumb drags.
475    fn top_for_track_row(&self, y: i16) -> usize {
476        let Some((_, len)) = self.thumb() else {
477            return self.top;
478        };
479        let usable = self.track_len() - len;
480        if usable == 0 {
481            return self.top;
482        }
483        let rel = usize::try_from(y - self.bounds.a.y)
484            .unwrap_or(0)
485            .saturating_sub(self.track_start())
486            .min(usable);
487        rel * self.max_top() / usable
488    }
489
490    fn in_scrollbar(&self, pos: Point) -> bool {
491        pos.x == self.scrollbar_x()
492            && pos.y >= self.bounds.a.y
493            && pos.y < self.bounds.b.y
494            && self.bounds.width() > 0
495    }
496
497    fn in_view(&self, pos: Point) -> bool {
498        pos.x >= self.bounds.a.x
499            && pos.x < self.bounds.b.x
500            && pos.y >= self.bounds.a.y
501            && pos.y < self.bounds.b.y
502    }
503
504    /// A left click on the scrollbar: arrows step a row, the track pages,
505    /// the thumb starts a drag.
506    fn scrollbar_click(&mut self, y: i16) {
507        let rel = usize::try_from(y - self.bounds.a.y).unwrap_or(0);
508        let page = self.page();
509        if page >= 3 && rel == 0 {
510            self.scroll_up(1);
511        } else if page >= 3 && rel == page - 1 {
512            self.scroll_down(1);
513        } else if let Some((start, len)) = self.thumb() {
514            let track_row = rel - self.track_start();
515            if track_row < start {
516                self.scroll_up(page);
517            } else if track_row >= start + len {
518                self.scroll_down(page);
519            } else {
520                self.dragging_thumb = true;
521            }
522        }
523    }
524
525    fn draw_scrollbar(&self, terminal: &mut Terminal) {
526        if self.bounds.width() <= 0 {
527            return;
528        }
529        let track_attr = Attr::new(TvColor::DarkGray, self.fill.bg);
530        let thumb_attr = Attr::new(TvColor::LightGray, self.fill.bg);
531        let page = self.page();
532        let thumb = self.thumb();
533        let track_start = self.track_start();
534        let x = self.scrollbar_x();
535        for row in 0..page {
536            let (ch, attr) = if page >= 3 && row == 0 {
537                ('▲', thumb_attr)
538            } else if page >= 3 && row == page - 1 {
539                ('▼', thumb_attr)
540            } else {
541                match thumb {
542                    Some((start, len))
543                        if row - track_start >= start && row - track_start < start + len =>
544                    {
545                        ('█', thumb_attr)
546                    }
547                    _ => ('░', track_attr),
548                }
549            };
550            let mut buf = DrawBuffer::new(1);
551            buf.put_char(0, ch, attr);
552            let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
553            write_line_to_terminal(terminal, x, y, &buf);
554        }
555    }
556
557    #[must_use]
558    pub fn is_at_bottom(&self) -> bool {
559        self.follow
560    }
561
562    // ---- selection ----
563
564    /// Selects the entire scrollback in stream mode. Leaves no selection if
565    /// the buffer is empty.
566    pub fn select_all(&mut self) {
567        let rows = self.row_count();
568        if rows == 0 {
569            self.selection = None;
570            return;
571        }
572        let last = rows - 1;
573        let last_len = self.row_at(last).map_or(0, Vec::len);
574        self.selection = Some(Selection {
575            anchor: SelPos { row: 0, col: 0 },
576            head: SelPos {
577                row: last,
578                col: last_len,
579            },
580            // Select All means the whole scrollback, never a column band.
581            mode: SelMode::Stream,
582        });
583    }
584
585    /// Sets a selection between two carets `(row, col)`. Order-independent:
586    /// anchor and head may be given in either order.
587    pub fn set_selection(&mut self, anchor: (usize, usize), head: (usize, usize)) {
588        self.selection = Some(Selection {
589            anchor: SelPos {
590                row: anchor.0,
591                col: anchor.1,
592            },
593            head: SelPos {
594                row: head.0,
595                col: head.1,
596            },
597            mode: SelMode::from_global(),
598        });
599    }
600
601    pub fn clear_selection(&mut self) {
602        self.selection = None;
603    }
604
605    #[must_use]
606    pub fn has_selection(&self) -> bool {
607        self.selection.is_some()
608    }
609
610    /// The selected text, or `None` when there is no selection. Logical lines
611    /// are reconstructed: a soft wrap within a line does not become a newline.
612    #[must_use]
613    pub fn selected_text(&self) -> Option<String> {
614        let sel = self.selection?;
615        if sel.mode == SelMode::Block {
616            return Some(self.block_text(sel));
617        }
618        let (start, end) = order(sel.anchor, sel.head);
619        let mut out = String::new();
620        for row in start.row..=end.row {
621            let Some(cells) = self.row_at(row) else {
622                continue;
623            };
624            let from = if row == start.row { start.col } else { 0 }.min(cells.len());
625            let to = if row == end.row { end.col } else { cells.len() }.min(cells.len());
626            if row > start.row && self.row_is_logical_start(row) {
627                out.push('\n');
628            }
629            out.extend(
630                cells[from..to.max(from)]
631                    .iter()
632                    .map(|c| c.ch)
633                    .filter(|&ch| ch != '\0'),
634            );
635        }
636        Some(out)
637    }
638
639    /// The text of a rectangular selection: the column band `[left, right)`
640    /// taken from every row the two carets span, one line per row. Rows
641    /// shorter than `left` contribute an empty line, so the block keeps its
642    /// shape when it is pasted elsewhere.
643    fn block_text(&self, sel: Selection) -> String {
644        let (top, bottom, left, right) = block_bounds(sel);
645        let mut out = String::new();
646        for row in top..=bottom {
647            if row > top {
648                out.push('\n');
649            }
650            let Some(cells) = self.row_at(row) else {
651                continue;
652            };
653            let from = left.min(cells.len());
654            let to = right.min(cells.len());
655            out.extend(
656                cells[from..to.max(from)]
657                    .iter()
658                    .map(|c| c.ch)
659                    .filter(|&ch| ch != '\0'),
660            );
661        }
662        out
663    }
664
665    fn row_at(&self, idx: usize) -> Option<&Vec<Cell>> {
666        self.iter_rows().nth(idx)
667    }
668
669    /// Maps a screen position to a caret in the scrollback, or `None` if it
670    /// falls outside the view or below the last row.
671    ///
672    /// In stream mode the column is clamped to the hit row's length, so
673    /// dragging past a line's end caps at its end. A block selection keeps
674    /// the raw column instead: its column band is the same on every row it
675    /// spans, including rows too short to reach it.
676    fn hit(&self, pos: Point, mode: SelMode) -> Option<SelPos> {
677        let x = usize::try_from(pos.x - self.bounds.a.x).ok()?;
678        let y = usize::try_from(pos.y - self.bounds.a.y).ok()?;
679        if y >= self.page() || x >= self.width() {
680            return None;
681        }
682        let abs_row = self.top + y;
683        let len = self.row_at(abs_row)?.len();
684        let col = match mode {
685            SelMode::Stream => x.min(len),
686            SelMode::Block => x,
687        };
688        Some(SelPos { row: abs_row, col })
689    }
690
691    /// Whether the cell at absolute wrapped-row `abs_row`, column `col` (a cell
692    /// index) lies inside the current selection.
693    fn is_selected(&self, abs_row: usize, col: usize) -> bool {
694        let Some(sel) = self.selection else {
695            return false;
696        };
697        if sel.mode == SelMode::Block {
698            let (top, bottom, left, right) = block_bounds(sel);
699            return (top..=bottom).contains(&abs_row) && (left..right).contains(&col);
700        }
701        let (s, e) = order(sel.anchor, sel.head);
702        (abs_row, col) >= (s.row, s.col) && (abs_row, col) < (e.row, e.col)
703    }
704
705    /// Whether absolute wrapped-row `abs_row` is the first row of a logical
706    /// line (as opposed to a soft-wrap continuation of the one above).
707    fn row_is_logical_start(&self, abs_row: usize) -> bool {
708        let mut offset = 0;
709        for &count in &self.row_counts {
710            if abs_row == offset {
711                return true;
712            }
713            offset += count;
714        }
715        // `offset` now equals `wrapped.len()`, where the partial line begins.
716        abs_row == offset
717    }
718
719    /// The whole scrollback with attributes stripped, for File > Save As.
720    #[must_use]
721    pub fn plain_text(&self) -> String {
722        let mut out = String::new();
723        for (i, line) in self.iter_lines().enumerate() {
724            if i > 0 {
725                out.push('\n');
726            }
727            // Spacer cells (the second column of a wide char) carry no
728            // text of their own; skip them so the saved text round-trips
729            // the original characters with no padding artifacts.
730            out.extend(line.iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
731        }
732        out
733    }
734
735    /// The whole scrollback with attributes intact, for tests and golden files.
736    #[must_use]
737    pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
738        self.iter_lines().cloned().collect()
739    }
740
741    fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
742        self.lines.iter().chain(self.partial.iter())
743    }
744
745    /// Display rows currently on screen or scrolled to, in order: the wrapped
746    /// completed lines followed by the wrapped in-progress line.
747    fn iter_rows(&self) -> impl Iterator<Item = &Vec<Cell>> {
748        self.wrapped.iter().chain(self.partial_wrapped.iter())
749    }
750
751    /// Bounds the scrollback by logical lines (see `max_lines`'s doc
752    /// comment), dropping the oldest ones and exactly the display rows they
753    /// contributed to `wrapped`.
754    fn trim(&mut self) {
755        if self.lines.len() > self.max_lines {
756            let drop = self.lines.len() - self.max_lines;
757            self.lines.drain(..drop);
758            let dropped_rows: usize = self.row_counts.drain(..drop).sum();
759            self.wrapped.drain(..dropped_rows);
760            self.top = self.top.saturating_sub(dropped_rows);
761        }
762    }
763}
764
765impl View for StreamView {
766    fn bounds(&self) -> Rect {
767        self.bounds
768    }
769
770    fn set_bounds(&mut self, bounds: Rect) {
771        let width_changed = self.bounds.width() != bounds.width();
772        self.bounds = bounds;
773        if width_changed {
774            self.rewrap();
775        }
776        if self.follow {
777            self.scroll_to_bottom();
778        } else {
779            self.top = self.top.min(self.max_top());
780        }
781    }
782
783    fn draw(&mut self, terminal: &mut Terminal) {
784        if self.bounds.height() <= 0 {
785            return;
786        }
787        let width = self.width();
788        let page = self.page();
789        let rows: Vec<&Vec<Cell>> = self.iter_rows().skip(self.top).take(page).collect();
790
791        for row in 0..page {
792            let mut buf = DrawBuffer::new(width);
793            for i in 0..width {
794                buf.put_char(i, ' ', self.fill);
795            }
796            if let Some(line) = rows.get(row) {
797                let abs_row = self.top + row;
798                for (i, cell) in line.iter().take(width).enumerate() {
799                    let attr = if self.is_selected(abs_row, i) {
800                        reverse(cell.attr)
801                    } else {
802                        cell.attr
803                    };
804                    buf.put_char(i, cell.ch, attr);
805                }
806            }
807            let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
808            write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
809        }
810        self.draw_scrollbar(terminal);
811    }
812
813    fn handle_event(&mut self, event: &mut Event) {
814        match event.what {
815            EventType::Keyboard => {
816                let page = self.page();
817                match event.key_code {
818                    KB_UP => self.scroll_up(1),
819                    KB_DOWN => self.scroll_down(1),
820                    KB_PGUP => self.scroll_up(page),
821                    KB_PGDN => self.scroll_down(page),
822                    KB_HOME => self.scroll_to_top(),
823                    KB_END => self.scroll_to_bottom(),
824                    KB_ESC if self.selection.is_some() => self.clear_selection(),
825                    _ => return,
826                }
827                event.clear();
828            }
829            EventType::MouseWheelUp if self.in_view(event.mouse.pos) => {
830                self.scroll_up(WHEEL_STEP);
831                event.clear();
832            }
833            EventType::MouseWheelDown if self.in_view(event.mouse.pos) => {
834                self.scroll_down(WHEEL_STEP);
835                event.clear();
836            }
837            EventType::MouseDown
838                if event.mouse.buttons & MB_LEFT_BUTTON != 0
839                    && self.in_scrollbar(event.mouse.pos) =>
840            {
841                self.scrollbar_click(event.mouse.pos.y);
842                event.clear();
843            }
844            EventType::MouseMove | EventType::MouseAuto if self.dragging_thumb => {
845                let top = self.top_for_track_row(event.mouse.pos.y);
846                self.set_top(top);
847                event.clear();
848            }
849            EventType::MouseDown if event.mouse.buttons & MB_LEFT_BUTTON != 0 => {
850                let mode = SelMode::from_global();
851                let Some(pos) = self.hit(event.mouse.pos, mode) else {
852                    return;
853                };
854                self.selection = Some(Selection {
855                    anchor: pos,
856                    head: pos,
857                    mode,
858                });
859                event.clear();
860            }
861            EventType::MouseMove | EventType::MouseAuto
862                if event.mouse.buttons & MB_LEFT_BUTTON != 0 =>
863            {
864                if let Some(mut sel) = self.selection
865                    && let Some(pos) = self.hit(event.mouse.pos, sel.mode)
866                {
867                    sel.head = pos;
868                    self.selection = Some(sel);
869                    event.clear();
870                }
871            }
872            EventType::MouseUp => {
873                self.dragging_thumb = false;
874                // A press with no drag (anchor == head) is a plain click: it
875                // selects nothing, so drop the empty selection.
876                if let Some(sel) = self.selection
877                    && sel.anchor == sel.head
878                {
879                    self.selection = None;
880                }
881                event.clear();
882            }
883            _ => {}
884        }
885    }
886
887    fn grow_mode(&self) -> GrowFlags {
888        self.grow_mode
889    }
890
891    fn set_grow_mode(&mut self, grow_mode: GrowFlags) {
892        self.grow_mode = grow_mode;
893    }
894
895    fn can_focus(&self) -> bool {
896        true
897    }
898
899    fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
900        // Cells already carry resolved `Attr`s (from `AnsiLineAssembler`), so
901        // there is no logical-color index for a palette to remap.
902        None
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use std::io;
910    use std::time::Duration;
911    use turbo_vision::core::palette::TvColor;
912    use turbo_vision::terminal::Backend;
913
914    fn line(s: &str) -> Vec<Cell> {
915        s.chars()
916            .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
917            .collect()
918    }
919
920    /// Test views are sized one column wider than the text they are meant to
921    /// hold: the rightmost column is the scrollbar, not text.
922    fn view() -> StreamView {
923        StreamView::new(Rect::new(0, 0, 41, 10))
924    }
925
926    #[test]
927    fn select_all_extracts_logical_lines_without_soft_wrap_newlines() {
928        let mut v = StreamView::new(Rect::new(0, 0, 11, 10));
929        v.push_line(&line("hello"));
930        v.push_line(&line("abcdefghijABCDEFGHIJ")); // 20 cols wraps at width 10
931        v.select_all();
932        assert_eq!(
933            v.selected_text().unwrap(),
934            "hello\nabcdefghijABCDEFGHIJ",
935            "soft wraps within a logical line must not become newlines"
936        );
937    }
938
939    #[test]
940    fn stream_selection_spans_from_anchor_to_head_across_a_line_break() {
941        let mut v = view();
942        v.push_line(&line("hello"));
943        v.push_line(&line("world"));
944        // caret before col 2 of row 0 to caret before col 3 of row 1
945        v.set_selection((0, 2), (1, 3));
946        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
947    }
948
949    #[test]
950    fn stream_selection_is_order_independent() {
951        let mut v = view();
952        v.push_line(&line("hello"));
953        v.push_line(&line("world"));
954        v.set_selection((1, 3), (0, 2)); // reversed
955        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
956    }
957
958    #[test]
959    fn no_selection_yields_no_text() {
960        let mut v = view();
961        v.push_line(&line("hello"));
962        assert!(v.selected_text().is_none());
963        assert!(!v.has_selection());
964    }
965
966    #[test]
967    fn selected_cells_render_reverse_video() {
968        let mut v = StreamView::new(Rect::new(0, 0, 8, 4));
969        v.push_line(&line("abcd"));
970        v.set_selection((0, 1), (0, 3)); // 'b','c'
971        let mut terminal = fake_terminal(20, 10);
972        v.draw(&mut terminal);
973        let a = terminal.read_cell(0, 0).unwrap(); // unselected 'a'
974        let b = terminal.read_cell(1, 0).unwrap(); // selected 'b'
975        assert_eq!(b.ch, 'b');
976        assert_eq!(b.attr.fg, a.attr.bg, "selected fg is the normal bg");
977        assert_eq!(b.attr.bg, a.attr.fg, "selected bg is the normal fg");
978        // The cell just past the selection ('d' region, col 3) is normal.
979        let d = terminal.read_cell(3, 0).unwrap();
980        assert_eq!(d.attr.fg, a.attr.fg, "col 3 is outside [1,3), so normal");
981    }
982
983    #[test]
984    fn mouse_drag_creates_a_stream_selection() {
985        let mut v = view();
986        v.push_line(&line("hello"));
987        v.push_line(&line("world"));
988        let mut down = Event::mouse(
989            EventType::MouseDown,
990            Point::new(2, 0),
991            MB_LEFT_BUTTON,
992            false,
993        );
994        v.handle_event(&mut down);
995        let mut mv = Event::mouse(
996            EventType::MouseMove,
997            Point::new(3, 1),
998            MB_LEFT_BUTTON,
999            false,
1000        );
1001        v.handle_event(&mut mv);
1002        let mut up = Event::mouse(EventType::MouseUp, Point::new(3, 1), 0, false);
1003        v.handle_event(&mut up);
1004        assert_eq!(v.selected_text().unwrap(), "llo\nwor");
1005    }
1006
1007    /// Serializes the tests that flip the process-wide block-edit mode, and
1008    /// clears it again when the guard drops.
1009    struct BlockModeGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
1010
1011    impl BlockModeGuard {
1012        fn on() -> Self {
1013            static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1014            let guard = LOCK
1015                .lock()
1016                .unwrap_or_else(std::sync::PoisonError::into_inner);
1017            turbo_vision::core::state::set_block_edit_mode(true);
1018            Self(guard)
1019        }
1020    }
1021
1022    impl Drop for BlockModeGuard {
1023        fn drop(&mut self) {
1024            turbo_vision::core::state::set_block_edit_mode(false);
1025        }
1026    }
1027
1028    #[test]
1029    fn block_mode_selects_a_column_band_from_every_row_it_spans() {
1030        let _guard = BlockModeGuard::on();
1031        let mut v = view();
1032        v.push_line(&line("abcdef"));
1033        v.push_line(&line("gh"));
1034        v.push_line(&line("klmnop"));
1035        // Cols 2..5 of rows 0..=2. "gh" is too short to reach the band.
1036        v.set_selection((0, 2), (2, 5));
1037        assert_eq!(
1038            v.selected_text().unwrap(),
1039            "cde
1040
1041mno"
1042        );
1043    }
1044
1045    #[test]
1046    fn a_block_drag_keeps_its_column_band_over_a_short_row() {
1047        let _guard = BlockModeGuard::on();
1048        let mut v = view();
1049        v.push_line(&line("abcdef"));
1050        v.push_line(&line("gh"));
1051        v.push_line(&line("klmnop"));
1052        let mut down = Event::mouse(
1053            EventType::MouseDown,
1054            Point::new(2, 0),
1055            MB_LEFT_BUTTON,
1056            false,
1057        );
1058        v.handle_event(&mut down);
1059        // The drag passes over "gh", whose length would clamp a stream caret
1060        // to column 2 and collapse the band.
1061        let mut mv = Event::mouse(
1062            EventType::MouseMove,
1063            Point::new(5, 1),
1064            MB_LEFT_BUTTON,
1065            false,
1066        );
1067        v.handle_event(&mut mv);
1068        let mut mv = Event::mouse(
1069            EventType::MouseMove,
1070            Point::new(5, 2),
1071            MB_LEFT_BUTTON,
1072            false,
1073        );
1074        v.handle_event(&mut mv);
1075        let mut up = Event::mouse(EventType::MouseUp, Point::new(5, 2), 0, false);
1076        v.handle_event(&mut up);
1077        assert_eq!(
1078            v.selected_text().unwrap(),
1079            "cde
1080
1081mno"
1082        );
1083    }
1084
1085    #[test]
1086    fn block_mode_highlights_only_the_column_band() {
1087        let _guard = BlockModeGuard::on();
1088        let mut v = view();
1089        v.push_line(&line("abcdef"));
1090        v.push_line(&line("klmnop"));
1091        v.set_selection((0, 2), (1, 5));
1092        assert!(v.is_selected(0, 3), "cols 2..5 of row 0 are in the band");
1093        assert!(!v.is_selected(0, 5), "col 5 is past the band");
1094        assert!(!v.is_selected(1, 1), "col 1 is before the band");
1095        assert!(
1096            !v.is_selected(0, 1),
1097            "a stream selection would have taken the whole tail of row 0"
1098        );
1099    }
1100
1101    #[test]
1102    fn select_all_stays_a_stream_selection_in_block_mode() {
1103        let _guard = BlockModeGuard::on();
1104        let mut v = view();
1105        v.push_line(&line("abcdef"));
1106        v.push_line(&line("gh"));
1107        v.select_all();
1108        assert_eq!(
1109            v.selected_text().unwrap(),
1110            "abcdef
1111gh"
1112        );
1113    }
1114
1115    #[test]
1116    fn a_plain_click_clears_any_selection() {
1117        let mut v = view();
1118        v.push_line(&line("hello"));
1119        v.select_all();
1120        assert!(v.has_selection());
1121        let mut down = Event::mouse(
1122            EventType::MouseDown,
1123            Point::new(2, 0),
1124            MB_LEFT_BUTTON,
1125            false,
1126        );
1127        v.handle_event(&mut down);
1128        let mut up = Event::mouse(EventType::MouseUp, Point::new(2, 0), 0, false);
1129        v.handle_event(&mut up);
1130        assert!(!v.has_selection(), "click without drag deselects");
1131    }
1132
1133    #[test]
1134    fn esc_clears_the_selection() {
1135        let mut v = view();
1136        v.push_line(&line("hello"));
1137        v.select_all();
1138        let mut esc = Event::keyboard(KB_ESC);
1139        v.handle_event(&mut esc);
1140        assert!(!v.has_selection());
1141    }
1142
1143    #[test]
1144    fn mutating_the_buffer_clears_the_selection() {
1145        let mut v = view();
1146        v.push_line(&line("hello"));
1147        v.select_all();
1148        assert!(v.has_selection());
1149        v.push_line(&line("more"));
1150        assert!(
1151            !v.has_selection(),
1152            "new content must drop a stale selection"
1153        );
1154    }
1155
1156    /// An in-memory `Backend` for tests: no real TTY, fixed size, no I/O.
1157    /// `Terminal::write_line`/`write_cell` write straight into `Terminal`'s
1158    /// own in-memory buffer, so this stub only needs to satisfy
1159    /// initialization and size queries for `Terminal::with_backend`.
1160    struct FakeBackend {
1161        width: u16,
1162        height: u16,
1163    }
1164
1165    impl Backend for FakeBackend {
1166        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1167            self
1168        }
1169
1170        fn init(&mut self) -> io::Result<()> {
1171            Ok(())
1172        }
1173
1174        fn cleanup(&mut self) -> io::Result<()> {
1175            Ok(())
1176        }
1177
1178        fn size(&self) -> io::Result<(u16, u16)> {
1179            Ok((self.width, self.height))
1180        }
1181
1182        fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
1183            Ok(None)
1184        }
1185
1186        fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
1187            Ok(())
1188        }
1189
1190        fn flush(&mut self) -> io::Result<()> {
1191            Ok(())
1192        }
1193
1194        fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
1195            Ok(())
1196        }
1197
1198        fn hide_cursor(&mut self) -> io::Result<()> {
1199            Ok(())
1200        }
1201    }
1202
1203    fn fake_terminal(width: u16, height: u16) -> Terminal {
1204        Terminal::with_backend(Box::new(FakeBackend { width, height }))
1205            .expect("fake backend never fails to init")
1206    }
1207
1208    /// A `Backend` that records every byte `Terminal::flush` actually sends
1209    /// downstream, via a shared buffer -- the write-through path
1210    /// `FakeBackend` above stubs out. `Terminal::flush` is the one place
1211    /// that decides what physically reaches a real terminal (it does a
1212    /// diffed, escape-coded re-encode of the cell buffer, and knowingly
1213    /// skips `'\0'` filler cells), so a bug specific to *that* encoding is
1214    /// invisible to any test that only inspects `Terminal::read_cell`,
1215    /// which reflects the in-memory cell buffer `write_line` always
1216    /// updates unconditionally.
1217    #[derive(Clone, Default)]
1218    struct RecordingBackend {
1219        width: u16,
1220        height: u16,
1221        output: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
1222    }
1223
1224    impl Backend for RecordingBackend {
1225        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1226            self
1227        }
1228
1229        fn init(&mut self) -> io::Result<()> {
1230            Ok(())
1231        }
1232
1233        fn cleanup(&mut self) -> io::Result<()> {
1234            Ok(())
1235        }
1236
1237        fn size(&self) -> io::Result<(u16, u16)> {
1238            Ok((self.width, self.height))
1239        }
1240
1241        fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
1242            Ok(None)
1243        }
1244
1245        fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
1246            self.output.lock().unwrap().extend_from_slice(data);
1247            Ok(())
1248        }
1249
1250        fn flush(&mut self) -> io::Result<()> {
1251            Ok(())
1252        }
1253
1254        fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
1255            Ok(())
1256        }
1257
1258        fn hide_cursor(&mut self) -> io::Result<()> {
1259            Ok(())
1260        }
1261    }
1262
1263    /// Builds a `Terminal` whose every `flush`-emitted byte lands in the
1264    /// returned buffer, so a test can inspect what actually reaches a real
1265    /// terminal rather than only the in-memory cell buffer.
1266    fn recording_terminal(
1267        width: u16,
1268        height: u16,
1269    ) -> (Terminal, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
1270        let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1271        let backend = RecordingBackend {
1272            width,
1273            height,
1274            output: output.clone(),
1275        };
1276        let terminal =
1277            Terminal::with_backend(Box::new(backend)).expect("fake backend never fails to init");
1278        (terminal, output)
1279    }
1280
1281    /// Replays `flush`'s escape-coded byte stream onto a plain grid the way
1282    /// a real terminal would: `ESC[row;colH` repositions the cursor
1283    /// (1-indexed), an SGR color sequence is consumed and ignored, and every
1284    /// other character is placed at the cursor and advances it by its own
1285    /// display width -- 2 for a double-width glyph, 0 for a combining or
1286    /// selector character, exactly as a real terminal renders it (not by
1287    /// our internal one-`Cell`-per-logical-column bookkeeping, which is
1288    /// precisely what could drift from physical reality). Bytes from
1289    /// successive flushes are replayed in order onto the same grid, since a
1290    /// real terminal's screen persists across flushes the same way.
1291    fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
1292        let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
1293        let mut chars = text.chars().peekable();
1294        let mut row = 0usize;
1295        let mut col = 0usize;
1296        while let Some(c) = chars.next() {
1297            if c == '\u{1b}' && chars.peek() == Some(&'[') {
1298                chars.next(); // consume '['
1299                let mut params = String::new();
1300                let mut final_byte = ' ';
1301                for pc in chars.by_ref() {
1302                    if pc.is_ascii_digit() || pc == ';' {
1303                        params.push(pc);
1304                    } else {
1305                        final_byte = pc;
1306                        break;
1307                    }
1308                }
1309                if final_byte == 'H' {
1310                    let mut parts = params.split(';');
1311                    let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
1312                    let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
1313                    row = r.saturating_sub(1);
1314                    col = cix.saturating_sub(1);
1315                }
1316                // An SGR ('m') sequence carries no cursor movement.
1317                continue;
1318            }
1319            let width = c.width().unwrap_or(0);
1320            if row < grid.len() && col < grid[row].len() {
1321                grid[row][col] = c;
1322            }
1323            col += width;
1324        }
1325    }
1326
1327    #[test]
1328    fn scrollback_cap_drops_oldest_lines() {
1329        let mut v = view();
1330        v.set_max_lines(3);
1331        for i in 0..5 {
1332            v.push_line(&line(&i.to_string()));
1333        }
1334        assert_eq!(v.line_count(), 3);
1335        assert_eq!(v.plain_text(), "2\n3\n4");
1336    }
1337
1338    #[test]
1339    fn autoscroll_holds_at_bottom_while_lines_arrive() {
1340        let mut v = view();
1341        for i in 0..50 {
1342            v.push_line(&line(&i.to_string()));
1343        }
1344        assert!(v.is_at_bottom());
1345    }
1346
1347    #[test]
1348    fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
1349        let mut v = view();
1350        for i in 0..50 {
1351            v.push_line(&line(&i.to_string()));
1352        }
1353        v.scroll_up(5);
1354        assert!(!v.is_at_bottom());
1355        v.push_line(&line("new"));
1356        assert!(
1357            !v.is_at_bottom(),
1358            "a new line must not yank a scrolled-back reader to the bottom"
1359        );
1360        v.scroll_to_bottom();
1361        assert!(v.is_at_bottom());
1362    }
1363
1364    #[test]
1365    fn partial_line_is_replaced_not_appended() {
1366        let mut v = view();
1367        v.set_partial(&line("par"));
1368        v.set_partial(&line("part"));
1369        assert_eq!(v.plain_text(), "part");
1370        assert_eq!(v.line_count(), 1);
1371    }
1372
1373    #[test]
1374    fn plain_text_strips_attributes() {
1375        let mut v = view();
1376        v.push_line(&[Cell::new('x', Attr::new(TvColor::LightRed, TvColor::Blue))]);
1377        assert_eq!(v.plain_text(), "x");
1378    }
1379
1380    #[test]
1381    fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
1382        let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
1383        for i in 0..50 {
1384            v.push_line(&line(&i.to_string()));
1385        }
1386        // Scroll back so `top` sits well below the current max_top()
1387        // (line_count 50, page 5 -> max_top 45).
1388        v.scroll_to_top();
1389        v.scroll_down(40);
1390        assert!(!v.is_at_bottom());
1391        let old_top = v.top;
1392        assert!(old_top < v.max_top());
1393
1394        // Grow the view a lot: max_top() shrinks to line_count - new_page
1395        // (50 - 48 = 2), which is now well below the old `top` (40). Left
1396        // unclamped, that would leave blank rows at the bottom of the
1397        // viewport even though unshown history sits above.
1398        v.set_bounds(Rect::new(0, 0, 40, 48));
1399
1400        assert!(
1401            v.top <= v.max_top(),
1402            "top ({}) must not exceed max_top ({}) after growing",
1403            v.top,
1404            v.max_top()
1405        );
1406        let rows: Vec<&Vec<Cell>> = v.iter_rows().skip(v.top).take(v.page()).collect();
1407        assert_eq!(
1408            rows.len(),
1409            v.page().min(v.row_count()),
1410            "a full page of content should be visible after growing"
1411        );
1412    }
1413
1414    #[test]
1415    fn draw_clips_to_bounds_width() {
1416        let mut v = StreamView::new(Rect::new(2, 1, 9, 4));
1417        v.push_line(&line("short")); // shorter than the 6-wide text area
1418
1419        let mut terminal = fake_terminal(20, 10);
1420        v.draw(&mut terminal);
1421
1422        // Row 0 (bounds.a.y == 1): "short", padded with the fill space for
1423        // the remaining column.
1424        for (i, expected) in "short ".chars().enumerate() {
1425            let cell = terminal
1426                .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
1427                .expect("cell within terminal bounds");
1428            assert_eq!(cell.ch, expected);
1429        }
1430        // The last column of the view is the scrollbar (its up arrow on the
1431        // top row); nothing is drawn past the view's width (x == 9).
1432        assert_eq!(terminal.read_cell(8, 1).unwrap().ch, '▲');
1433        assert_eq!(terminal.read_cell(9, 1).unwrap().ch, ' ');
1434
1435        // Nothing above the view's rows was touched.
1436        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1437    }
1438
1439    /// The exact banner glyph plank emits: U+1F6E0 HAMMER AND WRENCH followed
1440    /// by U+FE0F VARIATION SELECTOR-16 (the emoji presentation selector).
1441    /// The base character alone is East-Asian-Width `Neutral` (width 1 per
1442    /// `unicode-width`'s plain per-`char` rule) -- it is only the
1443    /// *emoji presentation sequence* (base + U+FE0F) that is double-width,
1444    /// which is exactly the sequence a real tool-call banner sends and the
1445    /// case this fix targets. `line()` builds one `Cell` per `char` here
1446    /// too, since `.chars()` splits the base and the selector into two
1447    /// separate `char`s -- the same shape `tracefmt`'s `cells()` produces.
1448    const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
1449
1450    #[test]
1451    fn wide_character_row_paints_the_correct_total_number_of_columns() {
1452        // wrench (2 columns) + space + x = 4 columns total.
1453        let mut v = StreamView::new(Rect::new(0, 0, 11, 4));
1454        v.push_line(&line(&format!("{WRENCH} x")));
1455        let mut terminal = fake_terminal(20, 10);
1456        v.draw(&mut terminal);
1457
1458        // Column 0 holds the wrench glyph itself.
1459        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1460        // Column 1 is the wrench's second column: the trailing presentation
1461        // selector itself, kept (not an invented '\0') because it is a real
1462        // character.
1463        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1464        // The rest of the row lands at its true, width-aware columns.
1465        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1466        assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'x');
1467        // And the row is blank-padded for the remaining columns of the view.
1468        for x in 4..10 {
1469            assert_eq!(terminal.read_cell(x, 0).unwrap().ch, ' ');
1470        }
1471    }
1472
1473    #[test]
1474    fn text_after_a_wide_character_lands_at_the_right_column() {
1475        let mut v = StreamView::new(Rect::new(0, 0, 30, 4));
1476        v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1477        let mut terminal = fake_terminal(30, 10);
1478        v.draw(&mut terminal);
1479
1480        let expected = "\u{1F6E0}\u{FE0F} Reading src/dsml.rs";
1481        for (i, expected_ch) in expected.chars().enumerate() {
1482            let cell = terminal
1483                .read_cell(i16::try_from(i).unwrap(), 0)
1484                .expect("cell within terminal bounds");
1485            assert_eq!(cell.ch, expected_ch, "column {i} mismatch");
1486        }
1487    }
1488
1489    #[test]
1490    fn short_row_is_blank_padded_so_nothing_shows_through_from_beneath() {
1491        let mut v = StreamView::new(Rect::new(0, 0, 11, 4));
1492        // First paint a row that fills the whole text width...
1493        v.push_line(&line("XXXXXXXXXX"));
1494        let mut terminal = fake_terminal(20, 10);
1495        v.draw(&mut terminal);
1496        // ...then a shorter, width-shrinking row should overwrite every
1497        // column the first row touched, leaving nothing behind.
1498        v.clear();
1499        v.push_line(&line(&format!("{WRENCH}hi")));
1500        v.draw(&mut terminal);
1501
1502        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1503        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1504        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, 'h');
1505        assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'i');
1506        for x in 4..10 {
1507            assert_eq!(
1508                terminal.read_cell(x, 0).unwrap().ch,
1509                ' ',
1510                "column {x} must be blanked, not left over from the previous row"
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn a_double_width_character_straddling_a_wrap_boundary_is_never_split() {
1517        // Columns: a b [中 col0] [中 col1: a '\0' filler cell] c d -- 6
1518        // columns, wrapped at width 3. A naive character-break cut at column
1519        // 3 would land squarely on the filler cell, splitting the glyph in
1520        // half; the wrap must instead push the whole character to the next
1521        // row.
1522        let mut v = StreamView::new(Rect::new(0, 0, 4, 4));
1523        v.push_line(&line("ab中cd"));
1524
1525        assert_eq!(v.row_count(), 3, "the 6-column line wraps to three rows");
1526
1527        let mut terminal = fake_terminal(20, 10);
1528        v.draw(&mut terminal);
1529
1530        // Row 0 holds only "ab": the wide character was pushed whole to the
1531        // next row rather than being split across the boundary.
1532        assert_eq!(terminal.read_cell(0, 0).unwrap().ch, 'a');
1533        assert_eq!(terminal.read_cell(1, 0).unwrap().ch, 'b');
1534
1535        // Row 1 holds the wide character (both its columns) followed by "c".
1536        assert_eq!(terminal.read_cell(0, 1).unwrap().ch, '中');
1537        assert_eq!(terminal.read_cell(1, 1).unwrap().ch, '\0');
1538        assert_eq!(terminal.read_cell(2, 1).unwrap().ch, 'c');
1539
1540        // Row 2 holds the remaining "d".
1541        assert_eq!(terminal.read_cell(0, 2).unwrap().ch, 'd');
1542    }
1543
1544    #[test]
1545    fn plain_text_round_trips_a_wide_character_with_no_padding_artifacts() {
1546        let mut v = view();
1547        v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1548        assert_eq!(v.plain_text(), format!("{WRENCH} Reading src/dsml.rs"));
1549    }
1550
1551    /// Reproduces the real, two-window bug: a lower window paints a row
1552    /// containing plank's real tool-call banner glyph and *flushes* it (not
1553    /// just `draw`s it -- the defect lives in what `Terminal::flush` sends
1554    /// downstream, invisible to any test that only checks
1555    /// `Terminal::read_cell`, since `write_line` updates the in-memory cell
1556    /// buffer unconditionally regardless of what flush later encodes). A
1557    /// second, unrelated window then opens on top with the same bounds and
1558    /// paints an all-blank row over the identical region, and flushes too.
1559    /// A real terminal's screen must show nothing left over from the first
1560    /// window afterwards.
1561    #[test]
1562    fn a_covering_window_s_flush_fully_blanks_a_row_that_held_a_wide_character() {
1563        let (mut terminal, output) = recording_terminal(31, 4);
1564        let mut grid = vec![vec![' '; 31]; 4];
1565
1566        // Lower window: the real banner line at row 0, drawn and flushed.
1567        let mut lower = StreamView::new(Rect::new(0, 0, 31, 4));
1568        lower.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
1569        lower.draw(&mut terminal);
1570        terminal
1571            .flush()
1572            .expect("flush never fails against a fake backend");
1573        replay_onto_grid(&output.lock().unwrap(), &mut grid);
1574        output.lock().unwrap().clear();
1575
1576        // Upper window: same bounds, no content of its own at all -- opens
1577        // on top and must blank every column of row 0 that the lower
1578        // window's banner occupied.
1579        let mut upper = StreamView::new(Rect::new(0, 0, 31, 4));
1580        upper.draw(&mut terminal);
1581        terminal
1582            .flush()
1583            .expect("flush never fails against a fake backend");
1584        replay_onto_grid(&output.lock().unwrap(), &mut grid);
1585
1586        // Row 0 must now be fully blank -- nothing from the lower window's
1587        // banner may still show through. (Column 30 is the scrollbar.)
1588        for (col, &ch) in grid[0].iter().take(30).enumerate() {
1589            assert_eq!(
1590                ch, ' ',
1591                "row 0 column {col} still shows a leftover character from \
1592                 the window underneath: {grid:?}"
1593            );
1594        }
1595    }
1596
1597    #[test]
1598    fn a_line_longer_than_the_width_wraps_across_the_right_number_of_rows_with_complete_content() {
1599        let mut v = StreamView::new(Rect::new(0, 0, 11, 20));
1600        // 25 non-space characters at width 10 -> ceil(25/10) = 3 rows.
1601        let text = "abcdefghijklmnopqrstuvwxy";
1602        v.push_line(&line(text));
1603
1604        assert_eq!(v.row_count(), 3);
1605        assert_eq!(
1606            v.plain_text(),
1607            text,
1608            "wrapping must not drop or duplicate any character"
1609        );
1610
1611        // Also verify via the rendered rows that content is complete and in
1612        // order across them.
1613        let mut terminal = fake_terminal(20, 20);
1614        v.draw(&mut terminal);
1615        let mut rendered = String::new();
1616        for row in 0..3 {
1617            for col in 0..10 {
1618                rendered.push(terminal.read_cell(col, row).unwrap().ch);
1619            }
1620        }
1621        assert_eq!(rendered, "abcdefghijklmnopqrstuvwxy     ");
1622    }
1623
1624    #[test]
1625    fn a_wrap_breaks_at_a_space_rather_than_mid_word_when_one_is_available() {
1626        let mut v = StreamView::new(Rect::new(0, 0, 11, 20));
1627        v.push_line(&line("hello world"));
1628
1629        // "hello world" is 11 columns wide; wrapping at 10 without a
1630        // space-aware break would cut mid-word ("hello worl" / "d"). The
1631        // break must instead land on the space, dropping it, and produce
1632        // "hello" / "world".
1633        assert_eq!(v.row_count(), 2);
1634        let mut terminal = fake_terminal(20, 20);
1635        v.draw(&mut terminal);
1636        for (i, expected) in "hello     ".chars().enumerate() {
1637            assert_eq!(
1638                terminal.read_cell(i16::try_from(i).unwrap(), 0).unwrap().ch,
1639                expected
1640            );
1641        }
1642        for (i, expected) in "world     ".chars().enumerate() {
1643            assert_eq!(
1644                terminal.read_cell(i16::try_from(i).unwrap(), 1).unwrap().ch,
1645                expected
1646            );
1647        }
1648    }
1649
1650    #[test]
1651    fn a_single_token_longer_than_the_width_is_broken_rather_than_truncated() {
1652        let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1653        // A 12-character token with no whitespace at all -- a long path,
1654        // say -- must still be fully visible, broken mid-token instead of
1655        // truncated.
1656        v.push_line(&line("abcdefghijkl"));
1657
1658        assert_eq!(v.row_count(), 3); // ceil(12/5) = 3
1659        assert_eq!(
1660            v.plain_text(),
1661            "abcdefghijkl",
1662            "the logical text is preserved even though it had to be broken mid-token"
1663        );
1664    }
1665
1666    #[test]
1667    fn plain_text_returns_the_original_unwrapped_logical_lines() {
1668        let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1669        v.push_line(&line("a much longer line than the five-column view"));
1670        v.push_line(&line("short"));
1671
1672        assert_eq!(
1673            v.plain_text(),
1674            "a much longer line than the five-column view\nshort",
1675            "Save As must get the original logical lines, not this window's wrap points"
1676        );
1677    }
1678
1679    #[test]
1680    fn resizing_narrower_then_wider_rewraps_and_content_survives_both() {
1681        let mut v = StreamView::new(Rect::new(0, 0, 21, 20));
1682        let text = "abcdefghijklmnopqrstuvwxyz";
1683        v.push_line(&line(text));
1684        assert_eq!(v.row_count(), 2); // ceil(26/20)
1685
1686        v.set_bounds(Rect::new(0, 0, 6, 20));
1687        assert_eq!(v.row_count(), 6); // ceil(26/5)
1688        assert_eq!(v.plain_text(), text);
1689
1690        v.set_bounds(Rect::new(0, 0, 31, 20));
1691        assert_eq!(v.row_count(), 1); // fits on one row now
1692        assert_eq!(v.plain_text(), text);
1693    }
1694
1695    #[test]
1696    fn scrolling_by_page_lands_correctly_when_wrapped_rows_are_present() {
1697        // One long line that wraps to 20 rows, in a 5-row-tall view.
1698        let mut v = StreamView::new(Rect::new(0, 0, 5, 5));
1699        let text: String = (0..80).map(|i| char::from(b'a' + (i % 26))).collect();
1700        v.push_line(&line(&text));
1701        assert_eq!(v.row_count(), 20);
1702
1703        v.scroll_to_top();
1704        assert_eq!(v.top, 0);
1705        v.scroll_down(v.page()); // one page down: page() == 5
1706        assert_eq!(
1707            v.top, 5,
1708            "paging must move by display rows, not logical lines"
1709        );
1710
1711        v.scroll_to_bottom();
1712        assert_eq!(v.top, v.row_count() - v.page());
1713    }
1714
1715    fn mouse(what: EventType, x: i16, y: i16, buttons: u8) -> Event {
1716        Event::mouse(what, Point::new(x, y), buttons, false)
1717    }
1718
1719    /// A 41x10 view with 50 one-row lines: page 10, `max_top` 40.
1720    fn scrollable_view() -> StreamView {
1721        let mut v = view();
1722        for i in 0..50 {
1723            v.push_line(&line(&i.to_string()));
1724        }
1725        v
1726    }
1727
1728    #[test]
1729    fn mouse_wheel_scrolls_by_a_few_rows_and_releases_autoscroll() {
1730        let mut v = scrollable_view();
1731        assert!(v.is_at_bottom());
1732        let mut ev = mouse(EventType::MouseWheelUp, 5, 5, 0);
1733        v.handle_event(&mut ev);
1734        assert_eq!(ev.what, EventType::Nothing, "the wheel event is consumed");
1735        assert_eq!(v.top(), 40 - WHEEL_STEP);
1736        assert!(!v.is_at_bottom());
1737
1738        v.handle_event(&mut mouse(EventType::MouseWheelDown, 5, 5, 0));
1739        assert_eq!(v.top(), 40);
1740        assert!(
1741            v.is_at_bottom(),
1742            "wheeling back to the end re-arms autoscroll"
1743        );
1744    }
1745
1746    #[test]
1747    fn mouse_wheel_outside_the_view_is_ignored() {
1748        let mut v = scrollable_view();
1749        let mut ev = mouse(EventType::MouseWheelUp, 60, 5, 0);
1750        v.handle_event(&mut ev);
1751        assert_eq!(ev.what, EventType::MouseWheelUp);
1752        assert_eq!(v.top(), 40);
1753    }
1754
1755    #[test]
1756    fn scrollbar_arrows_step_one_row_and_track_pages() {
1757        let mut v = scrollable_view();
1758        let x = v.scrollbar_x();
1759        assert_eq!(x, 40);
1760
1761        v.handle_event(&mut mouse(EventType::MouseDown, x, 0, MB_LEFT_BUTTON));
1762        assert_eq!(v.top(), 39, "up arrow steps one row");
1763        v.handle_event(&mut mouse(EventType::MouseDown, x, 9, MB_LEFT_BUTTON));
1764        assert_eq!(v.top(), 40, "down arrow steps one row");
1765
1766        // Thumb sits at the bottom of the track; clicking the track above
1767        // it pages up.
1768        v.handle_event(&mut mouse(EventType::MouseDown, x, 1, MB_LEFT_BUTTON));
1769        assert_eq!(v.top(), 30, "track above the thumb pages up");
1770        v.scroll_to_top();
1771        v.handle_event(&mut mouse(EventType::MouseDown, x, 8, MB_LEFT_BUTTON));
1772        assert_eq!(v.top(), 10, "track below the thumb pages down");
1773    }
1774
1775    #[test]
1776    fn dragging_the_thumb_scrolls_and_a_click_on_the_scrollbar_never_selects() {
1777        let mut v = scrollable_view();
1778        let x = v.scrollbar_x();
1779        v.scroll_to_top();
1780        let (start, len) = v.thumb().expect("50 rows in a 10-row view scroll");
1781        assert_eq!((start, len), (0, 1));
1782
1783        // Press on the thumb (track row 0 -> screen row 1), drag to the
1784        // bottom of the track, release.
1785        v.handle_event(&mut mouse(EventType::MouseDown, x, 1, MB_LEFT_BUTTON));
1786        assert!(v.dragging_thumb);
1787        assert!(
1788            v.selection.is_none(),
1789            "a scrollbar press must not start a selection"
1790        );
1791        v.handle_event(&mut mouse(EventType::MouseMove, x, 8, MB_LEFT_BUTTON));
1792        assert_eq!(v.top(), 40);
1793        assert!(v.is_at_bottom());
1794        v.handle_event(&mut mouse(EventType::MouseMove, x, 4, MB_LEFT_BUTTON));
1795        assert!(v.top() > 0 && v.top() < 40);
1796        v.handle_event(&mut mouse(EventType::MouseUp, x, 4, 0));
1797        assert!(!v.dragging_thumb);
1798        assert!(v.selection.is_none());
1799    }
1800
1801    #[test]
1802    fn scrollbar_draws_arrows_and_a_thumb_that_tracks_the_position() {
1803        let mut v = scrollable_view();
1804        let x = v.scrollbar_x();
1805        let mut terminal = fake_terminal(50, 10);
1806        v.draw(&mut terminal);
1807        assert_eq!(terminal.read_cell(x, 0).unwrap().ch, '▲');
1808        assert_eq!(terminal.read_cell(x, 9).unwrap().ch, '▼');
1809        // At the bottom the thumb is the last track cell.
1810        assert_eq!(terminal.read_cell(x, 8).unwrap().ch, '█');
1811        assert_eq!(terminal.read_cell(x, 1).unwrap().ch, '░');
1812
1813        v.scroll_to_top();
1814        v.draw(&mut terminal);
1815        assert_eq!(terminal.read_cell(x, 1).unwrap().ch, '█');
1816        assert_eq!(terminal.read_cell(x, 8).unwrap().ch, '░');
1817    }
1818
1819    #[test]
1820    fn scrollbar_has_no_thumb_when_everything_fits() {
1821        let mut v = view();
1822        v.push_line(&line("one"));
1823        assert!(v.thumb().is_none());
1824        let mut terminal = fake_terminal(50, 10);
1825        v.draw(&mut terminal);
1826        for y in 1..9 {
1827            assert_eq!(terminal.read_cell(v.scrollbar_x(), y).unwrap().ch, '░');
1828        }
1829    }
1830
1831    #[test]
1832    fn draw_on_zero_height_view_writes_nothing() {
1833        let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
1834        v.push_line(&line("hello"));
1835        let mut terminal = fake_terminal(20, 10);
1836        v.draw(&mut terminal);
1837        for y in 0..10 {
1838            for x in 0..20 {
1839                assert_eq!(
1840                    terminal.read_cell(x, y).unwrap().ch,
1841                    ' ',
1842                    "zero-height view must not write any cell"
1843                );
1844            }
1845        }
1846    }
1847}