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