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