diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index bf2aa6c9..207e38d0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -28,8 +28,9 @@ use crate::input::{Composer, ComposerAction}; use crate::theme; use crate::ui::sessions as sessions_ui; use crate::ui::tool_card::{self, DiffText}; +use crate::ui::text::paint_line; use crate::ui::transcript::{ - wrap_lines, Term, Transcript, + wrap_lines, RowRef, Term, Transcript, }; use crate::ui::{context, extensions, markdown, model_picker}; @@ -144,7 +145,8 @@ fn paint_pacing( } } -/// Fine-grained redraw reasons so spinner ticks can skip history rebuilds. +/// Fine-grained redraw reasons, so token and spinner frames can reuse the +/// header and status lines. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Dirty { /// Finished transcript (blocks, scroll, selection, fold). @@ -197,18 +199,6 @@ impl Dirty { } } -/// Viewport fingerprint for reusing the history portion of `chat_buf`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct HistReuseKey { - hist_len: usize, - start: usize, - hist_view_end: usize, - sticky: bool, - focus_scroll: bool, - selected: Option, - width: u16, -} - /// Chat-mode geometry. The input owns the terminal's bottom edge; every /// transient surface grows upward into the conversation plane. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -431,12 +421,11 @@ pub struct App { /// running metadata, so a token only rebuilds that suffix. tail_stable_len: usize, tail_buf: Vec>, - chat_buf: Vec>, - /// Lines in `chat_buf` that are sticky + history (before live tail). - hist_prefix_len: usize, - hist_reuse_key: Option, - /// Absolute transcript line for each rendered row in `chat_buf`. - chat_line_map: Vec>, + /// What each painted conversation row shows (None for the sticky header + /// and the live tail), by block and row in it: input handled before the + /// next paint can wrap history above the view, which moves every + /// absolute row index below it. + chat_line_map: Vec>, chat_draw_area: Rect, /// Where the composer text last painted, so the wheel and the mouse can /// tell the prompt apart from the conversation above it. @@ -722,9 +711,6 @@ impl App { tail_content_len: 0, tail_stable_len: 0, tail_buf: Vec::new(), - chat_buf: Vec::new(), - hist_prefix_len: 0, - hist_reuse_key: None, chat_line_map: Vec::new(), chat_draw_area: Rect::default(), composer_draw_area: Rect::default(), @@ -942,8 +928,6 @@ impl App { self.tail_content_len = 0; self.tail_stable_len = 0; self.tail_buf.clear(); - self.hist_prefix_len = 0; - self.hist_reuse_key = None; self.chat_line_map.clear(); self.chat_draw_area = Rect::default(); self.composer_draw_area = Rect::default(); @@ -1599,12 +1583,15 @@ impl App { (cell, row) } - fn transcript_position(&self, column: u16, row: u16) -> Option<(usize, usize)> { + /// The transcript line and column under the pointer: the text the last + /// frame painted there, wherever it sits now. + fn transcript_position(&mut self, column: u16, row: u16) -> Option<(usize, usize)> { if !rect_contains(self.chat_draw_area, column, row) { return None; } let rendered_row = row.saturating_sub(self.chat_draw_area.y) as usize; - let line = self.chat_line_map.get(rendered_row).copied().flatten()?; + let painted = self.chat_line_map.get(rendered_row).copied().flatten()?; + let line = self.transcript.line_of(painted)?; let x = column.saturating_sub(self.chat_draw_area.x) as usize; Some((line, x)) } @@ -3489,8 +3476,10 @@ impl App { /// Finished transcript plus the live tail, bottom anchored, honoring the /// scroll offset (0 follows the latest output). /// - /// When only the live tail is dirty (spinner / tokens), the history prefix - /// of `chat_buf` is reused and the tail is re-stitched. + /// Only the blocks the viewport shows are wrapped, and every row is + /// painted straight from its block's cache or the tail: a frame clones + /// no lines except the selected block's, which take the selection + /// surface. fn draw_chat(&mut self, frame: &mut Frame, area: Rect) { let layout_started = Instant::now(); let mut content_w = area.width; @@ -3501,7 +3490,6 @@ impl App { self.perf_selection_ms = 0.0; return; } - let chat_dirty = self.dirty.chat; // Start from the previous frame's scrollbar decision. Re-deciding // from the full width on every paint re-wrapped the entire @@ -3511,7 +3499,7 @@ impl App { if self.scrollbar_reserved && area.width > 1 { content_w = area.width - 1; } - // A width change re-wraps every block, and the bottom-anchored + // A width change re-measures every block, and the bottom-anchored // offset would resolve to different content afterward. Anchor the // history line at the viewport bottom by content and restore it // after the re-wrap; positions inside the live tail keep the @@ -3527,17 +3515,20 @@ impl App { self.transcript.set_width(content_w); let mut tail_len = self.rebuild_tail(content_w); - let mut hist_len = self.transcript.len(); - let mut total = hist_len + tail_len; let visible = area.height as usize; + // Whether history overflows is exact even though most blocks are + // only estimated: an estimate never exceeds the wrap, and history + // short enough to fit is wrapped whole to count it. + let total = self + .transcript + .len_exact_up_to(visible.saturating_sub(tail_len)) + + tail_len; if total > visible && content_w == area.width && area.width > 1 { // Overflow began: rewrap once with a dedicated one-cell track. self.scrollbar_reserved = true; content_w = area.width - 1; self.transcript.set_width(content_w); tail_len = self.rebuild_tail(content_w); - hist_len = self.transcript.len(); - total = hist_len + tail_len; } else if total <= visible && content_w < area.width { // Fits again at the narrowed width, so it also fits at the full // width (a wider wrap never yields more lines): reclaim the @@ -3546,8 +3537,6 @@ impl App { content_w = area.width; self.transcript.set_width(content_w); tail_len = self.rebuild_tail(content_w); - hist_len = self.transcript.len(); - total = hist_len + tail_len; } // Keep a scrolled-up reader stationary as the live tail changes. // History pushes already bump the offset; the tail below history @@ -3563,11 +3552,12 @@ impl App { } self.last_content_w = content_w; self.last_tail_len = tail_len; + // Wrap what the view shows, now that the offset says where it is. + self.transcript.settle_view(visible, tail_len); + let hist_len = self.transcript.len(); + let total = hist_len + tail_len; if total == 0 && self.pending_approval.is_none() { - self.chat_buf.clear(); self.chat_line_map.clear(); - self.hist_prefix_len = 0; - self.hist_reuse_key = None; self.chat_draw_area = Rect::default(); self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0; self.perf_selection_ms = 0.0; @@ -3582,66 +3572,19 @@ impl App { let end = total - offset; let start = end.saturating_sub(visible); - // Fingerprint sticky presence without cloning spans; clone only if we rebuild. - let has_sticky = offset > 0 && self.transcript.has_sticky_user(start); - let focus_scroll = self.focus == Focus::Scrollback; - let selected = self.transcript.selected(); - let hist_view_end = end.min(hist_len); - let reuse_key = HistReuseKey { - hist_len, - start, - hist_view_end, - sticky: has_sticky, - focus_scroll, - selected, - width: content_w, - }; - - let rebuild_hist = chat_dirty - || self.hist_reuse_key != Some(reuse_key) - || self.hist_prefix_len > self.chat_buf.len(); - - if rebuild_hist { - self.chat_buf.clear(); - self.chat_line_map.clear(); - // One clone of sticky spans: take ownership and insert the gutter. - if has_sticky { - if let Some(mut s) = self.transcript.sticky_user_line(start) { - s.spans - .insert(0, Span::styled("❯ ", Style::default().fg(theme::DIM()))); - self.chat_buf.push(s); - self.chat_line_map.push(None); - } - } - let budget = visible.saturating_sub(self.chat_buf.len()); - let view_end = start.saturating_add(budget).min(hist_view_end); - let selected_bi = if focus_scroll { selected } else { None }; - // Single clone per viewport history line (reuse path skips this). - self.transcript - .fill_viewport(&mut self.chat_buf, start, view_end, selected_bi); - self.chat_line_map.extend((start..view_end).map(Some)); - self.hist_prefix_len = self.chat_buf.len(); - self.hist_reuse_key = Some(reuse_key); - } else { - self.chat_buf.truncate(self.hist_prefix_len); - self.chat_line_map.truncate(self.hist_prefix_len); - } - - // Stitch visible tail after the history prefix. - let budget = visible.saturating_sub(self.chat_buf.len()); - let mut idx = start.max(hist_len); - let mut taken = 0usize; - while taken < budget && idx < end { - let ti = idx - hist_len; - if ti < self.tail_buf.len() { - self.chat_buf.push(self.tail_buf[ti].clone()); - self.chat_line_map.push(None); - } - idx += 1; - taken += 1; - } - - let pad = area.height.saturating_sub(self.chat_buf.len() as u16); + // Rows top to bottom: the sticky user header while scrolled away + // from it, the history in view, then the live tail below it. + let sticky_rows = usize::from(offset > 0 && self.transcript.has_sticky_user(start)); + let view_end = start + .saturating_add(visible - sticky_rows) + .min(end.min(hist_len)); + let history_rows = view_end.saturating_sub(start); + let tail_rows = (start.max(hist_len)..end) + .take(visible - sticky_rows - history_rows) + .count(); + let rows = sticky_rows + history_rows + tail_rows; + + let pad = area.height.saturating_sub(rows as u16); let draw_area = Rect { x: area.x, y: area.y + pad, @@ -3649,7 +3592,46 @@ impl App { height: area.height - pad, }; self.chat_draw_area = draw_area; - Paragraph::new(self.chat_buf.as_slice()).render(draw_area, frame.buffer_mut()); + let row_at = |row: usize| Rect { + y: draw_area.y + row as u16, + height: 1, + ..draw_area + }; + if sticky_rows > 0 { + if let Some(line) = self.transcript.sticky_user_line(start) { + let gutter = Span::styled("❯ ", Style::default().fg(theme::DIM())); + paint_line(frame.buffer_mut(), row_at(0), Some(&gutter), line); + } + } + let selected_bi = if self.focus == Focus::Scrollback { + self.transcript.selected() + } else { + None + }; + self.chat_line_map.clear(); + self.chat_line_map.extend(std::iter::repeat_n(None, sticky_rows)); + self.transcript.paint_rows( + frame.buffer_mut(), + Rect { + height: history_rows as u16, + ..row_at(sticky_rows) + }, + start, + view_end, + selected_bi, + &mut self.chat_line_map, + ); + self.chat_line_map.resize(sticky_rows + history_rows, None); + self.chat_line_map.extend(std::iter::repeat_n(None, tail_rows)); + let tail_from = start.max(hist_len) - hist_len; + for (row, line) in self.tail_buf[tail_from..tail_from + tail_rows].iter().enumerate() { + paint_line( + frame.buffer_mut(), + row_at(sticky_rows + history_rows + row), + None, + line, + ); + } self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0; let selection_started = Instant::now(); @@ -4297,14 +4279,14 @@ fn truncate_replay_output(output: &str) -> String { fn paint_text_selection( buffer: &mut ratatui::buffer::Buffer, transcript: &mut Transcript, - line_map: &[Option], + line_map: &[Option], area: Rect, ) { // One palette read for the whole overlay: the accessor takes the theme // lock, and the inner loop touches every selected cell per frame. let select_bg = theme::SELECT(); - for (row, line_idx) in line_map.iter().copied().enumerate() { - let Some(line_idx) = line_idx else { + for (row, painted) in line_map.iter().copied().enumerate() { + let Some(line_idx) = painted.and_then(|at| transcript.line_of(at)) else { continue; }; let Some((start_col, end_col)) = transcript.selection_columns(line_idx) else { @@ -4596,7 +4578,6 @@ mod tests { use ratatui::layout::Rect; use ratatui::style::Modifier; use ratatui::text::Line; - use ratatui::widgets::{Paragraph, Widget}; use ratatui::Terminal; use serde_json::json; use std::fs; @@ -5177,12 +5158,11 @@ mod tests { assert!(transcript.update_text_selection_at(0, 7)); transcript.finish_text_selection(); - let mut lines = Vec::new(); - transcript.fill_viewport(&mut lines, 0, 1, None); let area = Rect::new(0, 0, 20, 1); let mut buffer = Buffer::empty(area); - Paragraph::new(lines).render(area, &mut buffer); - paint_text_selection(&mut buffer, &mut transcript, &[Some(0)], area); + let mut rows = Vec::new(); + transcript.paint_rows(&mut buffer, area, 0, 1, None, &mut rows); + paint_text_selection(&mut buffer, &mut transcript, &rows, area); assert_eq!(buffer[(1, 0)].bg, theme::USER_BG()); assert_eq!(buffer[(2, 0)].bg, theme::SELECT()); @@ -6412,6 +6392,50 @@ mod tests { } } + /// First-paint, resize, and steady-frame cost at ten thousand blocks: + /// the frames that wrapped the whole history before blocks wrapped + /// lazily, and the frame that cloned every visible row. Not a + /// correctness test; run with: + /// cargo test -p openmax --bin openmax --release -- --ignored --nocapture measure_resize_and_first_paint + #[test] + #[ignore] + fn measure_resize_and_first_paint_at_scale() { + use std::time::Instant; + const ROWS: u16 = 45; + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, 10_000); + let frame_at = |app: &mut App, width: u16| { + let mut terminal = Terminal::new(TestBackend::new(width, ROWS)).unwrap(); + app.dirty.mark_chat(); + let allocations = crate::allocations(); + let started = Instant::now(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + let ms = started.elapsed().as_secs_f64() * 1e3; + (ms, crate::allocations() - allocations) + }; + let (first_ms, first_allocs) = frame_at(&mut app, 140); + // A window dragged narrower and wider: every frame a new width. + let mut resize = Vec::new(); + for width in [120, 139, 100, 141, 90, 133, 72, 140, 110, 125] { + resize.push(frame_at(&mut app, width)); + } + let mut steady = Vec::new(); + for _ in 0..50 { + steady.push(frame_at(&mut app, 125)); + } + let worst = |samples: &[(f64, u64)]| { + samples.iter().fold((0f64, 0u64), |(ms, n), &(m, a)| (ms.max(m), n.max(a))) + }; + let (resize_ms, resize_allocs) = worst(&resize); + let (steady_ms, steady_allocs) = worst(&steady); + println!( + "MEASURE 10000 blocks, {ROWS} rows: first paint {first_ms:.3} ms / {first_allocs} allocs, \ + resize max {resize_ms:.3} ms / {resize_allocs} allocs, \ + steady max {steady_ms:.3} ms / {steady_allocs} allocs" + ); + fs::remove_dir_all(dir).unwrap(); + } + /// The approval timeout is persisted without the `Error:` prefix /// (agent::tool_message_content keeps it verbatim so the model reads the /// stop instruction), so replay must classify it as the failure it was: @@ -6776,6 +6800,354 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + /// A long history, as a resumed marathon session leaves it: prose that + /// wraps at narrow widths, folded and failed tool cards, and notices. + fn push_long_history(app: &mut App, blocks: usize) { + let output = "src/ledger.rs:42: let hash = sha256(&bytes); // a grep or test log line\n" + .repeat(6); + for index in 0..blocks { + match index % 4 { + 0 => app.insert_user_block(&format!( + "turn {index}: inspect the ledger reconciliation and fix the failing test" + )), + 1 => app.transcript.push_assistant(crate::ui::markdown::render(&format!( + "Reply {index} with **markdown** long enough to wrap below a hundred \ + columns, a list:\n\n- alpha item\n- beta item\n\n```rust\nlet value = \ + compute(input);\n```" + ))), + 2 => { + let ok = index % 8 == 2; + let compact = + crate::ui::tool_card::tool_block("bash", "cargo test", ok, &output, None); + app.transcript.push_tool(compact, output.clone(), ok); + } + _ => app.transcript.push(vec![Line::from(format!("history line {index}"))]), + } + } + } + + /// A width change, the first paint of a long history, and a replay into + /// a painted window wrap only the blocks the viewport shows. Wrapping the + /// whole history made each of them linear in the session's length, every + /// block's wrap and its allocations on the UI loop in one frame. Every + /// block is at least one row, so a viewport of `ROWS` rows shows at most + /// `ROWS` blocks. + #[test] + fn resize_first_paint_and_replay_wrap_only_the_blocks_in_view() { + const BLOCKS: usize = 10_000; + const ROWS: u16 = 40; + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, BLOCKS); + + let wraps_during = |app: &mut App, width: u16| { + let before = crate::ui::transcript::block_wraps(); + render_app(app, width, ROWS); + crate::ui::transcript::block_wraps() - before + }; + let first_paint = wraps_during(&mut app, 100); + assert!( + first_paint <= usize::from(ROWS), + "the first paint wrapped {first_paint} of {BLOCKS} blocks" + ); + for width in [72, 130, 99] { + let resize = wraps_during(&mut app, width); + assert!( + resize <= usize::from(ROWS), + "a resize to {width} columns wrapped {resize} of {BLOCKS} blocks" + ); + } + + // Replay pushes every persisted message at the painted width. + let before = crate::ui::transcript::block_wraps(); + push_long_history(&mut app, BLOCKS); + let replay = crate::ui::transcript::block_wraps() - before; + let next_paint = wraps_during(&mut app, 99); + assert!( + replay + next_paint <= usize::from(ROWS), + "a replay wrapped {replay} blocks and its paint {next_paint}" + ); + fs::remove_dir_all(dir).unwrap(); + } + + /// One thing a reader or the agent does to the conversation view. + #[derive(Clone, Copy, Debug)] + enum ViewStep { + Size(u16, u16), + Up(usize), + Down(usize), + Top, + Follow, + Arrive(usize), + Stream, + Done, + Find(usize), + Prev, + Fold, + Expand, + Pop, + Focus, + } + + fn apply_view_step(app: &mut App, step: ViewStep, index: usize) { + match step { + ViewStep::Size(..) => {} + ViewStep::Up(n) => app.transcript.scroll_up(n), + ViewStep::Down(n) => app.transcript.scroll_down(n), + ViewStep::Top => app.transcript.select_first(), + ViewStep::Follow => app.transcript.follow(), + ViewStep::Arrive(kind) => { + let text = format!( + "arrival {index} while scrolled, long enough to wrap at narrow widths" + ); + match kind % 3 { + 0 => app.transcript.push_assistant(crate::ui::markdown::render(&text)), + 1 => app.insert_user_block(&text), + _ => { + let output = format!("{text}\n").repeat(4); + let compact = crate::ui::tool_card::tool_block( + "bash", "cargo test", false, &output, None, + ); + app.transcript.push_tool(compact, output, false); + } + } + } + ViewStep::Stream => app.on_agent_event(AgentEvent::Token { + text: format!("streamed line {index} of a reply that wraps when narrow\n"), + }), + ViewStep::Done => { + let text = app.stream_text.clone(); + app.on_agent_event(AgentEvent::MessageDone { text }); + } + ViewStep::Find(block) => app.transcript.select_find_match(block), + ViewStep::Prev => app.transcript.select_prev(), + ViewStep::Fold => { + app.transcript.toggle_fold_selected(); + } + ViewStep::Expand => { + app.transcript.expand_last_tool(); + } + ViewStep::Pop => { + app.transcript.pop_last_user(); + } + ViewStep::Focus => { + app.focus = match app.focus { + Focus::Composer => Focus::Scrollback, + Focus::Scrollback => Focus::Composer, + }; + } + } + } + + /// Paint `steps` into an app that wraps lazily and one that wraps every + /// block up front, both starting from `history` blocks, and require the + /// same frame after every step. Only the scrollbar marker, a proportion + /// of the estimated total, may differ. + fn assert_lazy_paints_like_eager(history: usize, steps: &[ViewStep]) { + let (mut lazy, lazy_dir) = app_fixture(); + let (mut eager, eager_dir) = app_fixture(); + eager.transcript.eager = true; + for app in [&mut lazy, &mut eager] { + push_long_history(app, history); + } + let (mut width, mut height) = (100, 30); + for (index, &step) in steps.iter().enumerate() { + if let ViewStep::Size(w, h) = step { + (width, height) = (w, h); + } + for app in [&mut lazy, &mut eager] { + apply_view_step(app, step, index); + } + // The header row names each fixture's own directory. + let painted = |app: &mut App| -> Vec { + rows(&render_app(app, width, height)) + .into_iter() + .skip(1) + .map(|row| row.replace('▐', " ")) + .collect() + }; + assert_eq!( + painted(&mut lazy), + painted(&mut eager), + "{history} blocks, step {index} ({step:?}) painted differently at {width}x{height}" + ); + } + fs::remove_dir_all(lazy_dir).unwrap(); + fs::remove_dir_all(eager_dir).unwrap(); + } + + /// `count` seeded steps of every kind over a history of `blocks` blocks. + fn random_view_steps(seed: u64, count: usize, blocks: usize) -> Vec { + use ViewStep::*; + let mut seed = seed; + let mut roll = |bound: usize| { + seed = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (seed >> 33) as usize % bound + }; + (0..count) + .map(|_| match roll(22) { + 0 | 1 => Size(20 + roll(121) as u16, 6 + roll(35) as u16), + 2..=5 => Up(1 + roll(80)), + 6..=9 => Down(1 + roll(80)), + 10 => Top, + 11 => Follow, + 12 => Arrive(roll(3)), + 13 | 14 => Stream, + 15 => Done, + 16 => Find(roll(blocks + 20)), + 17 => Prev, + 18 => Fold, + 19 => Expand, + 20 => Pop, + _ => Focus, + }) + .collect() + } + + /// Lazy wrapping is invisible: across scrolls, resizes, arrivals while + /// scrolled up, a streamed reply, find, block navigation, folds, and a + /// removed prompt, every frame paints exactly what wrapping every block + /// up front paints. Blocks off screen count their estimated heights, so + /// this pins that a scroll moves by exactly the rows it names and that + /// an estimate turning exact never moves what a scrolled reader is + /// looking at. A scripted walk, then seeded random ones over a long + /// history and over one short enough to fit on screen. + #[test] + fn lazy_wrapping_paints_exactly_what_eager_wrapping_paints() { + use ViewStep::*; + let scripted = [ + Size(100, 30), Up(45), Size(40, 30), Down(3), Down(3), Down(3), Down(9), + Up(200), Size(72, 24), Top, Down(7), Size(120, 24), Down(13), Size(50, 24), + Arrive(0), Arrive(1), Arrive(2), Down(20), Down(20), Down(20), Stream, Up(30), + Stream, Size(64, 18), Stream, Down(5), Done, Down(40), Down(40), Follow, + Size(90, 20), Up(100), Size(33, 20), Down(25), Follow, Stream, Stream, + Focus, Find(37), Prev, Prev, Fold, Size(45, 16), Find(150), Done, Follow, + ]; + assert_lazy_paints_like_eager(400, &scripted); + for seed in [1, 2] { + assert_lazy_paints_like_eager(400, &random_view_steps(seed, 300, 400)); + assert_lazy_paints_like_eager(6, &random_view_steps(seed, 300, 6)); + } + } + + /// Heap allocations of one frame that repaints the conversation, after + /// earlier frames settled wrapping, the scrollbar, and every cache. + fn repaint_allocations(app: &mut App, width: u16, height: u16) -> u64 { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + for _ in 0..2 { + terminal.draw(|frame| app.draw(frame)).unwrap(); + app.dirty.clear(); + } + app.dirty.mark_chat(); + let before = crate::allocations(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + crate::allocations() - before + } + + /// A steady frame paints the visible rows straight from the block + /// caches. Cloning every visible line into a frame-sized vector, and + /// again into the paragraph that painted it, cost allocations per + /// visible row on every frame. So a taller window may not allocate more + /// per frame than a short one, and a frame allocates less than once per + /// row it shows. + #[test] + fn steady_frames_allocate_nothing_per_visible_row() { + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, 10_000); + let short = repaint_allocations(&mut app, 100, 20); + let tall = repaint_allocations(&mut app, 100, 80); + assert!( + tall <= short, + "80 rows allocated {tall} times per frame, 20 rows {short}" + ); + assert!(tall < 80, "a frame showing 80 rows allocated {tall} times"); + fs::remove_dir_all(dir).unwrap(); + } + + /// A press, drag, and release select the text the last frame painted + /// under the pointer, even when wheel input handled before the next + /// paint wrapped history above the view. Wrapping a block changes its + /// height and so the index of every row below it: resolved through the + /// last frame's row indices, the gesture selected and copied text from + /// rows above the one under the pointer. + #[tokio::test] + async fn a_selection_after_an_unpainted_scroll_takes_the_painted_text() { + let (mut app, dir) = app_fixture(); + // A word to a row leaves most of each row empty, so every block + // wraps to far more rows than its estimate counts. + for block in 0..300 { + let words: Vec = + (0..10).map(|word| format!("w{block:04}x{word:02}abcdefg")).collect(); + app.transcript.push_user(vec![Line::from(words.join(" "))]); + } + for step in 0u16..12 { + let painted = render_app(&mut app, 29, 30); + let area = app.chat_draw_area; + // Wheel input queued behind the frame, handled before the next. + for _ in 0..1 + step % 3 { + app.on_term_event(mouse(MouseEventKind::ScrollUp, area.x, area.y)) + .await + .unwrap(); + } + // Below the sticky header row, a new row each step so no two + // presses make a double click. + let row = area.y + 1 + (step * 7) % (area.height - 1); + let (from, to) = (area.x + 2, area.x + 11); + for (kind, column) in [ + (MouseEventKind::Down(MouseButton::Left), from), + (MouseEventKind::Drag(MouseButton::Left), to), + (MouseEventKind::Up(MouseButton::Left), to), + ] { + app.on_term_event(mouse(kind, column, row)).await.unwrap(); + } + let shown: String = (from..=to).map(|x| painted[(x, row)].symbol()).collect(); + assert_eq!( + app.transcript.selected_text().unwrap_or_default(), + shown.trim(), + "step {step}: the copy is not the text painted on row {row}" + ); + app.transcript.clear_text_selection(); + } + fs::remove_dir_all(dir).unwrap(); + } + + /// A prompt a hook rejects is rolled back from above the notices its + /// turn already painted. A selection handled before the next paint + /// takes the notice under the pointer, not the one that slid into its + /// place in the transcript. + #[tokio::test] + async fn a_selection_after_a_rejected_prompt_takes_the_painted_notice() { + let (mut app, dir) = app_fixture(); + app.insert_user_block("a prompt a hook rejects"); + app.pending_submit = Some("a prompt a hook rejects".into()); + for index in 0..3 { + app.on_agent_event(AgentEvent::HookFailed { + hook: "lint".into(), + event: "user_prompt_submit".into(), + detail: format!("notice {index}"), + }); + } + let painted = render_app(&mut app, 80, 20); + app.on_agent_event(AgentEvent::Done { + stop_reason: "blocked".into(), + }); + let shown = rows(&painted); + let row = shown.iter().position(|text| text.contains("notice 0")).unwrap(); + let byte = shown[row].find("notice 0").unwrap(); + let from = shown[row][..byte].chars().count() as u16; + let (row, to) = (row as u16, from + 7); + for (kind, column) in [ + (MouseEventKind::Down(MouseButton::Left), from), + (MouseEventKind::Drag(MouseButton::Left), to), + (MouseEventKind::Up(MouseButton::Left), to), + ] { + app.on_term_event(mouse(kind, column, row)).await.unwrap(); + } + assert_eq!(app.transcript.selected_text().as_deref(), Some("notice 0")); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn growing_taller_releases_the_scrollbar_column_with_one_rewrap() { let (mut app, dir) = app_fixture(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a72e6d3a..e25f96dd 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1810,6 +1810,57 @@ fn example_row(verdict: &open_max_core::doctor::ExampleVerdict) -> String { open_max_core::text::one_line(&row) } +/// Heap allocations made by the calling thread, for tests that bound what a +/// frame allocates. Per thread, so tests running in parallel do not count +/// each other's work. +#[cfg(test)] +pub fn allocations() -> u64 { + alloc_count::ALLOCATIONS.with(std::cell::Cell::get) +} + +#[cfg(test)] +mod alloc_count { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + + thread_local! { + // Const-initialized with no destructor: reading it never allocates, + // which the allocator itself depends on. + pub static ALLOCATIONS: Cell = const { Cell::new(0) }; + } + + fn count() { + let _ = ALLOCATIONS.try_with(|n| n.set(n.get() + 1)); + } + + pub struct Counting; + + unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + count(); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + count(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + count(); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } +} + +#[cfg(test)] +#[global_allocator] +static COUNTING_ALLOCATOR: alloc_count::Counting = alloc_count::Counting; + #[cfg(test)] pub fn test_temp_dir(prefix: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/tui/src/ui/text.rs b/crates/tui/src/ui/text.rs index 3dd48ab4..61793357 100644 --- a/crates/tui/src/ui/text.rs +++ b/crates/tui/src/ui/text.rs @@ -2,6 +2,10 @@ use std::borrow::Cow; +use ratatui::buffer::{Buffer, CellWidth}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span, StyledGrapheme}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -88,6 +92,68 @@ fn escape_len(seq: &[u8]) -> Option { } } +/// Paint `line` into the one-row `area` exactly as an unwrapped +/// `Paragraph` holding it paints it, from a borrowed line. A paragraph +/// takes owned text, so painting a frame's rows through one cloned each row +/// on every frame. `prefix` is painted as though it led the line, as a +/// gutter inserted into a clone of it would be. +pub fn paint_line(buf: &mut Buffer, area: Rect, prefix: Option<&Span<'_>>, line: &Line<'_>) { + let area = area.intersection(buf.area); + if area.is_empty() { + return; + } + // A span inside a line is styled over the line's own style. + let line_style = Style::default().patch(line.style); + let graphemes = || { + prefix + .into_iter() + .flat_map(move |span| span.styled_graphemes(line_style)) + .chain(line.styled_graphemes(Style::default())) + }; + let max = usize::from(area.width); + // The paragraph keeps graphemes up to the first one that would overflow + // the row, skipping any wider than the whole row, then aligns what it + // kept. + let mut x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Left => 0, + alignment => { + let mut used = 0usize; + for grapheme in graphemes() { + let width = usize::from(grapheme.symbol.cell_width()); + if width > max { + continue; + } + if used + width > max { + break; + } + used += width; + } + let used = used as u16; + match alignment { + Alignment::Center => (area.width / 2).saturating_sub(used / 2), + _ => area.width.saturating_sub(used), + } + } + }; + let mut used = 0usize; + for StyledGrapheme { symbol, style } in graphemes() { + let width = usize::from(symbol.cell_width()); + if width > max { + continue; + } + if used + width > max { + break; + } + used += width; + if width == 0 { + continue; + } + let symbol = if symbol.is_empty() { " " } else { symbol }; + buf[(area.x + x, area.y)].set_symbol(symbol).set_style(style); + x += width as u16; + } +} + /// Clip to `max` terminal cells without splitting a grapheme cluster. pub fn clip(text: &str, max: usize) -> String { if max == 0 { @@ -195,6 +261,50 @@ pub fn line_bounds(text: &str, offset: usize) -> (usize, usize) { mod tests { use super::*; + /// `paint_line` must paint every cell exactly as the paragraph it + /// replaced: wide and zero-width graphemes, controls the renderer drops, + /// overflow, a wide grapheme straddling the edge, line and span styles, + /// alignment, and a gutter prefix. + #[test] + fn paint_line_paints_exactly_what_a_paragraph_paints() { + use ratatui::style::{Color, Modifier}; + use ratatui::widgets::{Paragraph, Widget}; + + let red = Style::default().fg(Color::Red); + let on_blue = Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD); + let lines = vec![ + Line::from("plain ascii text"), + Line::from("a line far wider than the narrowest row it is painted into"), + Line::from(vec![Span::styled("漢字", red), Span::raw("ab漢"), Span::styled("字z", on_blue)]), + Line::from("e\u{301}x 👩\u{200d}💻 tab\there esc\u{1b}[31mred\u{7}bell"), + Line::from(vec![Span::raw("joined e"), Span::styled("\u{301} across spans", red)]), + Line::from(vec![Span::raw("styled line"), Span::raw(" two")]).style(on_blue), + Line::from("centered 漢字").alignment(Alignment::Center), + Line::from("right 字").alignment(Alignment::Right), + Line::from("too wide to center at all here").alignment(Alignment::Center), + Line::from(""), + Line::default().style(on_blue), + ]; + let gutter = Span::styled("❯ ", Style::default().fg(Color::DarkGray)); + for line in &lines { + for width in [1u16, 2, 3, 7, 12, 40, 80] { + for prefix in [None, Some(&gutter)] { + let area = Rect::new(2, 1, width, 1); + let canvas = Rect::new(0, 0, width + 4, 3); + let mut expected = Buffer::empty(canvas); + let mut owned = line.clone(); + if let Some(prefix) = prefix { + owned.spans.insert(0, prefix.clone()); + } + Paragraph::new(vec![owned]).render(area, &mut expected); + let mut painted = Buffer::empty(canvas); + paint_line(&mut painted, area, prefix, line); + assert_eq!(painted, expected, "{line:?} at {width} columns, prefix {prefix:?}"); + } + } + } + } + #[test] fn clip_obeys_cell_width_and_grapheme_boundaries() { assert_eq!(clip("abcdef", 4), "abc…"); diff --git a/crates/tui/src/ui/transcript.rs b/crates/tui/src/ui/transcript.rs index 95e900a6..1f05cfe6 100644 --- a/crates/tui/src/ui/transcript.rs +++ b/crates/tui/src/ui/transcript.rs @@ -4,7 +4,16 @@ //! per-block wrap caches. Tools fold by default; selection and sticky user //! headers support dual-focus navigation. Scroll offset is in wrapped lines //! from the bottom; 0 follows the latest output. - +//! +//! Blocks wrap lazily. A width change, a first paint, or a replay sizes +//! every block from its source lines' measured widths (an estimate that is +//! exact unless a line wraps, and never more than the wrap produces) and +//! wraps only the blocks the viewport shows; the rest are wrapped when they +//! scroll into view. Wrapping the whole history on each of those made them +//! linear in the session's length. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::prelude::CrosstermBackend; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -13,7 +22,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthChar; use crate::theme; -use crate::ui::text::{cell_width, strip_escapes, TAB_WIDTH}; +use crate::ui::text::{cell_width, paint_line, strip_escapes, TAB_WIDTH}; /// Frames go through one large buffer per flush: bare `Stdout` is /// line-buffered at 1 KiB, which turns a busy streaming frame into dozens of @@ -31,6 +40,9 @@ pub enum BlockKind { struct Block { kind: BlockKind, + /// Push order, never reused, so ids rise along `Transcript::blocks`. + /// Names the block for rows painted before a removal shifted its index. + id: u64, /// Full content when expanded (or the only content when not foldable). raw: Vec>, /// Compact body when foldable; shown while `folded`. @@ -44,22 +56,100 @@ struct Block { /// allocation per key. Fold-independent (it reads compact/full_output/ /// raw, never the folded view), so no invalidation path exists. search_lower: String, + /// Cell widths of `raw` and `compact`, measured once at push: what + /// sizes the block at a width it has not been wrapped at. + raw_cells: LineCells, + compact_cells: LineCells, + /// Rows the block takes in the index at the transcript's width: the + /// length of `cache` once it is wrapped there, the estimate until then. + height: usize, cache_width: u16, cache_folded: bool, cache: Vec>, cache_maps: Vec>, + /// Plain text of the source lines shown (`compact` while folded), which + /// selection offsets and every wrapped row's map point into. selectable: String, selectable_chars: usize, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] struct CachedLineMap { - /// Character offsets into `Block::selectable_text`. + /// Character offsets into `Block::selectable`. start: usize, end: usize, + /// The same range in bytes, so a row's text is a slice of `selectable` + /// rather than a third copy of the block's text. + byte_start: usize, + byte_end: usize, /// Terminal column where selectable content starts after UI gutters. x_offset: usize, - text: String, +} + +/// Painted width of each source line, measured the way the wrapper measures +/// it, so a block can be sized at any width without wrapping it. +#[derive(Default)] +struct LineCells { + cells: Box<[u32]>, + widest: u32, +} + +impl LineCells { + fn measure(lines: &[Line<'static>]) -> Self { + let cells: Box<[u32]> = lines + .iter() + .map(|line| u32::try_from(line_cells(line)).unwrap_or(u32::MAX)) + .collect(); + let widest = cells.iter().copied().max().unwrap_or(0); + Self { cells, widest } + } + + /// Rows these lines wrap to at `width` columns, never more than + /// `wrap_lines_mapped` produces: a row holds at most `width` measured + /// cells and a line takes at least one row. Exact when every line fits, + /// which is the common case and costs no per-line work. + fn rows(&self, width: usize) -> usize { + if self.widest as usize <= width { + return self.cells.len(); + } + self.cells + .iter() + .map(|&cells| (cells as usize).div_ceil(width).max(1)) + .sum() + } +} + +/// Cells `wrap_lines_mapped` counts for one line: a tab as [`TAB_WIDTH`], +/// each other character (ASCII) or grapheme (otherwise) as it paints. A +/// grapheme counts at most a tab's width. Every row has room for that much, +/// so only a malformed wider cluster can overflow a row, and it gets a row +/// to itself: counting it whole could size the block above what the wrap +/// produces. +fn line_cells(line: &Line<'_>) -> usize { + if line.spans.iter().all(|span| span.content.is_ascii()) { + return line + .spans + .iter() + .flat_map(|span| span.content.chars()) + .map(|ch| if ch == '\t' { TAB_WIDTH } else { ch.width().unwrap_or(0) }) + .sum(); + } + // Graphemes can join across span boundaries, so measure the joined line + // exactly as the wrapper splits it. + let plain: String = line.spans.iter().map(|span| span.content.as_ref()).collect(); + plain + .graphemes(true) + .map(|grapheme| cell_width(grapheme).min(TAB_WIDTH)) + .sum() +} + +/// Columns a block's source lines wrap to at transcript width `width`. +fn content_width(kind: BlockKind, width: u16) -> u16 { + let gutter = match kind { + BlockKind::User => 2, + BlockKind::Assistant | BlockKind::Tool | BlockKind::System => 0, + }; + width.saturating_sub(gutter).max(8) } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -105,6 +195,14 @@ fn lower_for_search(text: &str) -> String { #[cfg(test)] thread_local! { static SEARCH_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static BLOCK_WRAPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Blocks wrapped on this thread so far: the oracle for the promise that a +/// resize, a first paint, or a replay wraps only what the viewport shows. +#[cfg(test)] +pub(crate) fn block_wraps() -> usize { + BLOCK_WRAPS.with(std::cell::Cell::get) } impl Block { @@ -114,11 +212,15 @@ impl Block { let search_lower = lower_for_search(&selectable); Self { kind, + id: 0, + raw_cells: LineCells::measure(&raw), + compact_cells: LineCells::default(), raw, compact: None, folded: false, full_output: None, search_lower, + height: 0, cache_width: 0, cache_folded: false, cache: Vec::new(), @@ -133,8 +235,7 @@ impl Block { /// keystroke (the card's one clipped diagnostic line rarely is the /// reason). The user can fold either state back by hand. fn tool(compact: Vec>, full_output: String, ok: bool) -> Self { - let selectable = lines_to_plain(&compact); - let selectable_chars = selectable.chars().count(); + let header_text = lines_to_plain(&compact); // Search covers the compact header plus the whole output, matching // what `search_text_line` reads back out; the folded view is not // part of it. The output is searched as the screen shows it, or a @@ -142,10 +243,10 @@ impl Block { // for the hidden code matched. Stripping never removes a newline, so // line slots still agree with the raw output. let visible = strip_escapes(&full_output); - let search_lower = if selectable.is_empty() { + let search_lower = if header_text.is_empty() { lower_for_search(&visible) } else { - lower_for_search(&format!("{selectable}\n{visible}")) + lower_for_search(&format!("{header_text}\n{visible}")) }; let header = compact .first() @@ -169,13 +270,25 @@ impl Block { .add_modifier(Modifier::ITALIC), ))); } + // The copy text is whichever view is on screen: the card while + // folded, the output while open. + let selectable = if ok { + header_text + } else { + lines_to_plain(&full_lines) + }; + let selectable_chars = selectable.chars().count(); Self { kind: BlockKind::Tool, + id: 0, + raw_cells: LineCells::measure(&full_lines), + compact_cells: LineCells::measure(&compact), raw: full_lines, compact: Some(compact), folded: ok, full_output: Some(full_output), search_lower, + height: 0, cache_width: 0, cache_folded: ok, cache: Vec::new(), @@ -194,19 +307,48 @@ impl Block { &self.raw } + /// Whether `cache` holds this block wrapped at `width` as it is now + /// folded. + fn is_wrapped_at(&self, width: u16) -> bool { + self.cache_width == width && self.cache_folded == self.folded && !self.cache.is_empty() + } + + /// Rows the block takes at transcript width `width`: exact once + /// wrapped there, otherwise the estimate from its measured line widths + /// (plus the spacer row), which never exceeds what the wrap produces. + fn measure(&self, width: u16) -> usize { + if width == 0 { + return 0; + } + if self.is_wrapped_at(width) { + return self.cache.len(); + } + let cells = if self.folded && self.compact.is_some() { + &self.compact_cells + } else { + &self.raw_cells + }; + cells.rows(usize::from(content_width(self.kind, width))) + 1 + } + + /// Show the block folded or open. The copy text follows the view; the + /// wrap does too, at the next `ensure_cache`. + fn set_folded(&mut self, folded: bool) { + self.folded = folded; + self.selectable = lines_to_plain(self.source_lines()); + self.selectable_chars = self.selectable.chars().count(); + self.invalidate(); + } + fn ensure_cache(&mut self, width: u16) { - if self.cache_width == width && self.cache_folded == self.folded && !self.cache.is_empty() { + if self.is_wrapped_at(width) { return; } + #[cfg(test)] + BLOCK_WRAPS.with(|counter| counter.set(counter.get() + 1)); self.cache_width = width; self.cache_folded = self.folded; - self.selectable = lines_to_plain(self.source_lines()); - self.selectable_chars = self.selectable.chars().count(); - let gutter = match self.kind { - BlockKind::User => 2, - BlockKind::Assistant | BlockKind::Tool | BlockKind::System => 0, - }; - let content_width = width.saturating_sub(gutter).max(8); + let content_width = content_width(self.kind, width); let (wrapped, maps) = wrap_lines_mapped(self.source_lines(), content_width); self.cache.clear(); self.cache_maps.clear(); @@ -239,15 +381,40 @@ pub struct WrapAnchor { lines_into_block: usize, } +/// A painted history row by its content: the block and the row in that +/// block's wrap. An absolute row index names other text once a block above +/// it changes height, and wrapping does that between any two frames (a +/// scroll wraps the blocks it crosses), so a pointer event resolved through +/// the last frame's indices would land rows above the text under the +/// pointer. A block index shifts as well, when a rejected prompt is removed +/// from above the row, so the block is named by its id; the index it had +/// when painted only saves the search while nothing was removed. This +/// names the painted text until its own block changes. See +/// [`Transcript::paint_rows`] and [`Transcript::line_of`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RowRef { + block: usize, + id: u64, + row: usize, +} + #[derive(Default)] pub struct Transcript { blocks: Vec, + /// Id of the next block pushed. + next_id: u64, /// First wrapped line of each block; `total` closes the last range. The /// per-block caches own the only copy of every wrapped line, so - /// rebuilding after a fold toggle or width change is index arithmetic, - /// never a clone of the session's lines. + /// re-measuring after a fold toggle or width change is index + /// arithmetic, never a clone of the session's lines. A block not yet + /// wrapped at the current width counts its estimated height. block_starts: Vec, - /// Wrapped lines across all block caches. + /// Entries of `block_starts` from this block on are stale. A height + /// change marks only its own suffix, so wrapping the blocks at the + /// bottom of history never walks the whole index. + stale_from: usize, + /// Rows across all blocks (sum of their heights), kept current by every + /// height change. total: usize, width: u16, offset: usize, @@ -258,12 +425,16 @@ pub struct Transcript { unread: usize, selected: Option, text_selection: Option, - dirty: bool, - /// Full re-wraps performed (every block invalidated by a width change). - /// Oracle for the draw path's promise that steady-state frames never - /// re-wrap history. + /// Rows and live-tail rows of the viewport the last frame settled. + view: (usize, usize), + /// Width changes, each of which re-measures every block. Oracle for the + /// draw path's promise that steady-state frames never change the width. #[cfg(test)] pub(crate) rewraps: u64, + /// Wrap every block as soon as it is pushed or the width changes: the + /// eager shape, which tests hold the lazy one against. + #[cfg(test)] + pub(crate) eager: bool, } impl Transcript { @@ -286,21 +457,31 @@ impl Transcript { if lines.is_empty() { return; } - if self.width == 0 { - self.blocks.push(Block::new(kind, lines)); - self.dirty = true; - return; - } - if self.dirty { - self.ensure_index(); + self.push_block(Block::new(kind, lines)); + } + + /// Index a new last block by its estimated height; it is wrapped when a + /// frame shows it. While scrolled up the view holds still, so the offset + /// grows by the rows the block adds below it. With the view's bottom + /// edge inside the live tail, those rows move the tail under the edge, + /// so they are counted exactly. + fn push_block(&mut self, mut block: Block) { + block.id = self.next_id; + self.next_id += 1; + let bi = self.blocks.len(); + let wrap_now = self.offset > 0 && self.offset < self.view.1; + #[cfg(test)] + let wrap_now = wrap_now || self.eager; + self.blocks.push(block); + if wrap_now && self.width > 0 { + self.blocks[bi].ensure_cache(self.width); } - let prev_len = self.total; - self.blocks.push(Block::new(kind, lines)); - let bi = self.blocks.len() - 1; - self.append_block_index(bi); - if self.offset > 0 { - let added = self.total.saturating_sub(prev_len); - self.offset = self.offset.saturating_add(added); + let height = self.blocks[bi].measure(self.width); + self.blocks[bi].height = height; + self.total += height; + self.mark_stale(bi); + if self.width > 0 && self.offset > 0 { + self.offset = self.offset.saturating_add(height); self.unread += 1; } } @@ -316,7 +497,14 @@ impl Transcript { let Some(i) = self.blocks.iter().rposition(|b| b.kind == BlockKind::User) else { return false; }; - self.blocks.remove(i); + // The view keeps its offset (the draw path clamps it once the view + // is measured), so it moves by the rows removed. + let held = self.held_block(); + self.wrap_held(i, held); + self.wrap_across(i, held, -(self.blocks[i].height as isize)); + let removed = self.blocks.remove(i); + self.total -= removed.height; + self.mark_stale(i); // Selection / sticky indices past the removed block must retreat. if let Some(sel) = self.selected { if sel == i { @@ -343,9 +531,6 @@ impl Transcript { }); } } - self.dirty = true; - self.ensure_index(); - self.offset = self.offset.min(self.total); true } @@ -355,25 +540,12 @@ impl Transcript { /// `ok` picks the initial fold state; see [`Block::tool`]. pub fn push_tool(&mut self, compact: Vec>, full_output: String, ok: bool) { - if self.width == 0 { - self.blocks.push(Block::tool(compact, full_output, ok)); - self.dirty = true; - return; - } - if self.dirty { - self.ensure_index(); - } - let prev_len = self.total; - self.blocks.push(Block::tool(compact, full_output, ok)); - let bi = self.blocks.len() - 1; - self.append_block_index(bi); - if self.offset > 0 { - let added = self.total.saturating_sub(prev_len); - self.offset = self.offset.saturating_add(added); - self.unread += 1; - } + self.push_block(Block::tool(compact, full_output, ok)); } + /// Re-measure every block for a new width. Blocks keep whatever they + /// last wrapped (a block wrapped at this width before needs no work); + /// the rest count their estimates until a frame shows them. pub fn set_width(&mut self, width: u16) { if width != self.width { #[cfg(test)] @@ -381,12 +553,23 @@ impl Transcript { self.rewraps += 1; } self.width = width; - for b in &mut self.blocks { - b.invalidate(); + #[cfg(test)] + if self.eager && width > 0 { + for block in &mut self.blocks { + block.ensure_cache(width); + } } - self.dirty = true; - self.ensure_index(); - self.offset = self.offset.min(self.total); + // The offset is left for the draw path to clamp against the + // total once the view is measured: clamping to an estimate here + // would move a view whose edge is in the live tail by however + // far the estimate is off. + let mut total = 0; + for block in &mut self.blocks { + block.height = block.measure(width); + total += block.height; + } + self.total = total; + self.mark_stale(0); } } @@ -412,17 +595,17 @@ impl Transcript { } /// Wrapped-line distance from the bottom of history to the anchored - /// content, after any re-wraps since capture. + /// content, after any re-wraps since capture. The anchored block is + /// wrapped first, so the distance into it is measured on the rows the + /// frame will paint. pub fn resolve_anchor(&mut self, anchor: WrapAnchor) -> usize { - self.ensure_index(); - let Some(&start) = self.block_starts.get(anchor.block) else { + if anchor.block >= self.blocks.len() { return 0; - }; - let end = self - .block_starts - .get(anchor.block + 1) - .copied() - .unwrap_or(self.total); + } + self.wrap_block(anchor.block); + self.ensure_index(); + let start = self.block_starts[anchor.block]; + let end = start + self.blocks[anchor.block].height; let index = (start + anchor.lines_into_block).min(end.saturating_sub(1)); self.total - index } @@ -433,36 +616,188 @@ impl Transcript { self.offset = lines_from_bottom; } - fn rebuild_index(&mut self) { - self.block_starts.clear(); - self.total = 0; - if self.width == 0 { - self.dirty = false; + fn mark_stale(&mut self, bi: usize) { + self.stale_from = self.stale_from.min(bi); + } + + /// Bring `block_starts` up to date from the first stale block on. + fn ensure_index(&mut self) { + if self.stale_from >= self.blocks.len() && self.block_starts.len() == self.blocks.len() { return; } - for bi in 0..self.blocks.len() { - self.block_starts.push(self.total); - self.blocks[bi].ensure_cache(self.width); - self.total += self.blocks[bi].cache.len(); + let from = self.stale_from.min(self.block_starts.len()).min(self.blocks.len()); + self.block_starts.truncate(from); + let mut row = match from.checked_sub(1) { + Some(prev) => self.block_starts[prev] + self.blocks[prev].height, + None => 0, + }; + for block in &self.blocks[from..] { + self.block_starts.push(row); + row += block.height; } - self.dirty = false; + debug_assert_eq!(row, self.total, "block heights and the row total disagree"); + self.stale_from = self.blocks.len(); } - /// Incrementally append one newly pushed block to the index. - /// `bi` must be the last block; width is unchanged and the index is - /// current. - fn append_block_index(&mut self, bi: usize) { + /// Wrap block `bi` at the current width unless it already is, and + /// return the rows that added: never negative, since an estimate never + /// exceeds the wrap. + fn wrap_block(&mut self, bi: usize) -> isize { + if self.width == 0 || self.blocks[bi].is_wrapped_at(self.width) { + return 0; + } self.blocks[bi].ensure_cache(self.width); - self.block_starts.push(self.total); - self.total += self.blocks[bi].cache.len(); + let before = self.blocks[bi].height; + let height = self.blocks[bi].cache.len(); + self.set_height(bi, height); + height as isize - before as isize + } + + fn set_height(&mut self, bi: usize, height: usize) { + self.total = self.total - self.blocks[bi].height + height; + self.blocks[bi].height = height; + self.mark_stale(bi); + } + + /// Fold or open block `bi`. It is wrapped at once: a fold is toggled on + /// the block in view, and the view keeps its offset across the change, + /// as it does for any content change, so it moves by the rows the fold + /// adds or removes. + fn refold(&mut self, bi: usize, folded: bool) { + let held = self.held_block(); + self.wrap_held(bi, held); + let before = self.blocks[bi].height; + self.blocks[bi].set_folded(folded); + if self + .text_selection + .is_some_and(|selection| selection_contains_block(selection, bi)) + { + self.text_selection = None; + } + if self.width > 0 { + self.blocks[bi].ensure_cache(self.width); + } + let height = self.blocks[bi].measure(self.width); + // Still indexed at its old height: the crossing is measured in the + // layout the view moves from. + self.wrap_across(bi, held, height as isize - before as isize); + self.set_height(bi, height); } - fn ensure_index(&mut self) { - if self.dirty - || (self.block_starts.is_empty() && !self.blocks.is_empty() && self.width > 0) - { - self.rebuild_index(); + /// Wrap the rows the view is about to move across when block `bi` + /// changes height by `delta` under an unchanged offset. Only a block at + /// or below the `held` edge moves the view; it then moves by `delta` + /// rows, which must be wrapped to land where wrapped rows would. + fn wrap_across(&mut self, bi: usize, held: Option, delta: isize) { + if !held.is_some_and(|held| bi >= held) { + return; + } + if delta < 0 { + self.settle(delta.unsigned_abs(), 0); + } else { + self.settle(0, delta.unsigned_abs()); + } + } + + /// Wrap exactly the blocks a `rows`-row viewport shows over a live tail + /// of `tail_len` rows, holding the content on screen still while their + /// estimates turn exact. Remembers the geometry, so a scroll before the + /// next frame knows which rows it crosses. + pub fn settle_view(&mut self, rows: usize, tail_len: usize) { + self.view = (rows, tail_len); + self.settle(0, 0); + } + + /// Wrap the blocks covering the last settled viewport extended by + /// `above` rows over it and `below` rows under it, holding the view + /// still while their estimates turn exact. The offset names an edge, + /// that many rows above the bottom: the bottom of the view, or, scrolled + /// past the top, a row above it. A block that grows at or below that + /// edge must grow the offset with it, and one that ends above it must + /// not; a block straddling it keeps its rows above it, as a resize + /// anchor counts them. So the view leaves the top only once the rows + /// above the edge, all of them in the window by then, are exact. + /// Following needs nothing (the bottom is held by construction), and + /// neither does an edge in the live tail, which history above it cannot + /// move. + /// + /// Estimates never exceed the wrap, so a growing block only pushes + /// others out of the range: the loop wraps at most the blocks the first + /// range covered, never the history beyond it. + fn settle(&mut self, above: usize, below: usize) { + let (rows, tail_len) = self.view; + if self.width == 0 || rows == 0 { + return; + } + loop { + self.ensure_index(); + let total = self.total + tail_len; + let top_offset = total.saturating_sub(rows); + let end = total - self.offset.min(top_offset); + let start = end.saturating_sub(rows); + let from = start.saturating_sub(above); + let to = end.saturating_add(below).min(self.total); + if from >= to { + return; + } + let (Some((first, _)), Some((last, _))) = (self.locate(from), self.locate(to - 1)) + else { + return; + }; + let held = self.held_block(); + let mut wrapped = false; + for bi in first..=last { + wrapped |= self.wrap_held(bi, held); + } + if !wrapped { + return; + } + } + } + + /// The first block at or below the edge the offset names (see + /// [`Self::settle`]); none while following or with the edge in the live + /// tail. + fn held_block(&mut self) -> Option { + if self.offset == 0 { + return None; + } + self.ensure_index(); + let edge = (self.total + self.view.1).saturating_sub(self.offset); + self.locate(edge).map(|(bi, _)| bi) + } + + /// Wrap block `bi` unless it already is, growing the offset with it when + /// it sits at or below the `held` block, so the view holds still. + /// Returns whether it wrapped. + fn wrap_held(&mut self, bi: usize, held: Option) -> bool { + if self.width == 0 || self.blocks[bi].is_wrapped_at(self.width) { + return false; + } + let added = self.wrap_block(bi); + if held.is_some_and(|held| bi >= held) { + self.offset = self.offset.saturating_add_signed(added); } + true + } + + /// Rows in the whole transcript, exact whenever there are at most + /// `limit` of them: the draw path asks whether history fits on screen. + /// Estimates never exceed the wrap, so a total above `limit` is above + /// it once wrapped too; at or below it, blocks are wrapped from the + /// newest until the count is exact or passes `limit`. Every block is at + /// least one row, so that wraps at most `limit + 1` blocks. + pub fn len_exact_up_to(&mut self, limit: usize) -> usize { + if self.total > limit { + return self.total; + } + let held = self.held_block(); + let mut bi = self.blocks.len(); + while self.total <= limit && bi > 0 { + bi -= 1; + self.wrap_held(bi, held); + } + self.total } /// Block owning wrapped line `idx`, and the line's offset into that @@ -471,15 +806,29 @@ impl Transcript { if idx >= self.total { return None; } + debug_assert!( + self.stale_from >= self.blocks.len() && self.block_starts.len() == self.blocks.len(), + "located a line in a stale index" + ); let bi = self.block_starts.partition_point(|&start| start <= idx) - 1; Some((bi, idx - self.block_starts[bi])) } - /// Test-support oracle access: `draw_chat` paints via `fill_viewport`. + /// Wrap every block at the current width. Test-support only: the draw + /// path wraps what the viewport shows. #[cfg(test)] - pub fn lines(&mut self) -> Vec> { + pub fn wrap_all(&mut self) { + for bi in 0..self.blocks.len() { + self.wrap_block(bi); + } self.ensure_index(); + } + + /// Test-support oracle access: `draw_chat` paints via `paint_rows`. + #[cfg(test)] + pub fn lines(&mut self) -> Vec> { let mut out = Vec::new(); + self.wrap_all(); let total = self.total; self.fill_viewport(&mut out, 0, total, None); out @@ -491,14 +840,17 @@ impl Transcript { pub fn line_at(&mut self, idx: usize) -> Option<&Line<'static>> { self.ensure_index(); let (bi, li) = self.locate(idx)?; + self.wrap_block(bi); self.blocks[bi].cache.get(li) } /// Clone history lines `[start, end)` into `out` once each, straight - /// from the per-block caches. + /// from the per-block caches, after wrapping every block. Test-support + /// oracle for what `paint_rows` paints. /// /// When `selected_bi` is `Some`, lines belonging to that block receive a /// quiet background. Text selection is painted later as a buffer overlay. + #[cfg(test)] pub fn fill_viewport( &mut self, out: &mut Vec>, @@ -506,9 +858,9 @@ impl Transcript { end: usize, selected_bi: Option, ) { - self.ensure_index(); + self.wrap_all(); // Prove the index covers every cache mutation instead of trusting - // the dirty flag's call sites: a cache edited without marking dirty + // the call sites: a cache edited without re-measuring its block // would walk the loop below out of bounds. debug_assert!( self.total == self.blocks.iter().map(|b| b.cache.len()).sum::(), @@ -534,6 +886,70 @@ impl Transcript { } } + /// Paint history lines `[start, end)` into the rows of `area` from its + /// top, straight from the per-block caches: nothing is cloned except + /// the rows of `selected_bi`, which receive a quiet background. Text + /// selection is painted later as a buffer overlay. Every block in the + /// range must be wrapped, which [`Self::settle_view`] does for the + /// viewport. Appends to `painted` what each row shows, in order, for + /// pointer events to find it again. + pub fn paint_rows( + &mut self, + buf: &mut Buffer, + area: Rect, + start: usize, + end: usize, + selected_bi: Option, + painted: &mut Vec>, + ) { + self.ensure_index(); + let end = end.min(self.total).min(start.saturating_add(usize::from(area.height))); + let Some((mut bi, mut li)) = self.locate(start) else { + return; + }; + for (y, _) in (area.y..).zip(start..end) { + while li >= self.blocks[bi].height { + bi += 1; + li = 0; + } + let block = &self.blocks[bi]; + debug_assert!(block.is_wrapped_at(self.width), "painted an unwrapped block"); + let line = block.cache.get(li).filter(|_| block.is_wrapped_at(self.width)); + if let Some(line) = line { + let row = Rect { y, height: 1, ..area }; + if selected_bi == Some(bi) { + let line = surface_line(line.clone(), self.width, theme::BORDER()); + paint_line(buf, row, None, &line); + } else { + paint_line(buf, row, None, line); + } + } + painted.push(line.map(|_| RowRef { + block: bi, + id: block.id, + row: li, + })); + li += 1; + } + } + + /// The current index of a row a frame painted, or None once its block + /// is gone or no longer wraps to that row. Pointer events address rows + /// this way, so wrapping or a removal between the paint and the event + /// cannot move what they hit. + pub fn line_of(&mut self, at: RowRef) -> Option { + let bi = match self.blocks.get(at.block) { + Some(block) if block.id == at.id => at.block, + _ => self.blocks.binary_search_by_key(&at.id, |block| block.id).ok()?, + }; + let block = &self.blocks[bi]; + if !block.is_wrapped_at(self.width) || at.row >= block.cache.len() { + return None; + } + self.ensure_index(); + Some(self.block_starts[bi] + at.row) + } + pub fn len(&mut self) -> usize { self.ensure_index(); self.total @@ -594,11 +1010,19 @@ impl Transcript { self.offset } + /// Scroll toward older output by exactly `n` wrapped rows. The rows + /// crossed are wrapped first: counted by estimate, a scroll past history + /// no frame has shown would land on different content than the rows it + /// claims to move. pub fn scroll_up(&mut self, n: usize) { + self.settle(n, 0); self.offset = self.offset.saturating_add(n); } + /// Scroll toward newer output by exactly `n` wrapped rows; see + /// [`Self::scroll_up`]. pub fn scroll_down(&mut self, n: usize) { + self.settle(0, n); self.offset = self.offset.saturating_sub(n); if self.offset == 0 { self.unread = 0; @@ -722,7 +1146,10 @@ impl Transcript { return; } self.selected = Some(0); - self.ensure_index(); + // With a live tail taller than the view, this offset puts the + // bottom edge a tail's height below the top of history: wrap the + // rows it counts through. + self.wrap_rows_from(0, self.view.1); self.offset = self.total; } @@ -731,19 +1158,32 @@ impl Transcript { self.follow(); } + /// Put the end of block `bi` a live tail's height above the bottom of + /// the view. The block and the history rows that height reaches below + /// it are wrapped first, so the frame paints the rows the offset counts. fn scroll_to_block(&mut self, bi: usize) { - self.ensure_index(); - if bi >= self.block_starts.len() { + if bi >= self.blocks.len() { return; } - let end = self - .block_starts - .get(bi + 1) - .copied() - .unwrap_or(self.total); + self.wrap_block(bi); + self.wrap_rows_from(bi + 1, self.view.1); + self.ensure_index(); + let end = self.block_starts[bi] + self.blocks[bi].height; self.offset = self.total.saturating_sub(end); } + /// Wrap blocks from `first` on until they cover `rows` rows. + fn wrap_rows_from(&mut self, first: usize, rows: usize) { + let mut covered = 0; + for bi in first..self.blocks.len() { + if covered >= rows { + break; + } + self.wrap_block(bi); + covered += self.blocks[bi].height; + } + } + pub fn toggle_fold_selected(&mut self) -> bool { let Some(i) = self.selected else { return false; @@ -762,16 +1202,7 @@ impl Transcript { }; self.selected = Some(i); if self.blocks[i].folded { - self.blocks[i].folded = false; - self.blocks[i].invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, i)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + self.refold(i, false); true } else { self.toggle_fold_at(i) @@ -779,22 +1210,14 @@ impl Transcript { } fn toggle_fold_at(&mut self, i: usize) -> bool { - let Some(block) = self.blocks.get_mut(i) else { + let Some(block) = self.blocks.get(i) else { return false; }; if block.compact.is_none() { return false; } - block.folded = !block.folded; - block.invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, i)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + let folded = !block.folded; + self.refold(i, folded); true } @@ -846,16 +1269,7 @@ impl Transcript { && self.blocks[bi].folded && self.blocks[bi].compact.is_some() { - self.blocks[bi].folded = false; - self.blocks[bi].invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, bi)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + self.refold(bi, false); } self.selected = Some(bi); self.scroll_to_block(bi); @@ -1026,12 +1440,29 @@ impl Transcript { } } - fn hit_test(&mut self, line_idx: usize, x: usize) -> Option { + /// Block and column map of wrapped history line `line_idx`. Rows reach + /// here from a painted frame, whose blocks are wrapped; the wrap below + /// serves only callers that address a row no frame has shown. + fn row(&mut self, line_idx: usize) -> Option<(usize, CachedLineMap)> { self.ensure_index(); let (block, li) = self.locate(line_idx)?; - let map = self.blocks[block].cache_maps.get(li)?.as_ref()?; + if !self.blocks[block].is_wrapped_at(self.width) { + let held = self.held_block(); + self.wrap_held(block, held); + } + Some((block, (*self.blocks[block].cache_maps.get(li)?)?)) + } + + /// A row's text: its slice of the block's selectable text. + fn row_text(&self, block: usize, map: CachedLineMap) -> Option<&str> { + self.blocks[block].selectable.get(map.byte_start..map.byte_end) + } + + fn hit_test(&mut self, line_idx: usize, x: usize) -> Option { + let (block, map) = self.row(line_idx)?; + let text = self.row_text(block, map)?; let relative = x.saturating_sub(map.x_offset); - let offset = map.start + display_column_to_char_offset(&map.text, relative); + let offset = map.start + display_column_to_char_offset(text, relative); Some(TextPoint { block, offset: offset.min(map.end), @@ -1040,17 +1471,16 @@ impl Transcript { /// Highlight columns for an absolute wrapped history line. pub fn selection_columns(&mut self, line_idx: usize) -> Option<(usize, usize)> { - self.ensure_index(); let selection = self.text_selection?; if selection.is_empty() { return None; } let (start, end) = normalized_selection(selection); - let (block, li) = self.locate(line_idx)?; + let (block, map) = self.row(line_idx)?; if block < start.block || block > end.block { return None; } - let map = self.blocks[block].cache_maps.get(li)?.as_ref()?; + let text = self.row_text(block, map)?; let block_len = self.blocks.get(block)?.selectable_chars; let range_start = if block == start.block { start.offset @@ -1070,9 +1500,9 @@ impl Transcript { return None; } let start_col = - map.x_offset + char_offset_to_display_column(&map.text, local_start - map.start); + map.x_offset + char_offset_to_display_column(text, local_start - map.start); let end_col = - map.x_offset + char_offset_to_display_column(&map.text, local_end - map.start); + map.x_offset + char_offset_to_display_column(text, local_end - map.start); Some((start_col, end_col.max(start_col + 1))) } @@ -1123,18 +1553,20 @@ impl Transcript { self.sticky_user_block_idx(view_start_line).is_some() } - /// First line of the nearest user block above the viewport. - pub fn sticky_user_line(&mut self, view_start_line: usize) -> Option> { + /// First line of the nearest user block above the viewport, borrowed: + /// the frame paints it in place. + pub fn sticky_user_line(&mut self, view_start_line: usize) -> Option<&Line<'static>> { let i = self.sticky_user_block_idx(view_start_line)?; - self.blocks[i].source_lines().first().cloned() + self.blocks[i].source_lines().first() } - /// Test-support oracle for the selection marker `fill_viewport` paints. + /// Test-support oracle for the selection marker `paint_rows` paints. #[cfg(test)] - pub fn is_selected_block_for_line(&self, line_idx: usize) -> bool { + pub fn is_selected_block_for_line(&mut self, line_idx: usize) -> bool { let Some(sel) = self.selected else { return false; }; + self.ensure_index(); self.locate(line_idx).map(|(bi, _)| bi) == Some(sel) } @@ -1280,22 +1712,27 @@ fn wrap_lines_mapped( let width = width.max(8) as usize; let mut out = Vec::new(); let mut maps = Vec::new(); + // Where this line starts in the joined plain text, in chars and bytes. let mut logical_start = 0usize; + let mut logical_byte = 0usize; for line in lines { let chars: Vec<(char, Style)> = line .spans .iter() .flat_map(|s| s.content.chars().map(move |c| (c, s.style))) .collect(); + let line_bytes: usize = line.spans.iter().map(|s| s.content.len()).sum(); if chars.is_empty() { out.push(Line::default()); maps.push(Some(CachedLineMap { start: logical_start, end: logical_start, + byte_start: logical_byte, + byte_end: logical_byte, x_offset: 0, - text: String::new(), })); logical_start += 1; + logical_byte += 1; continue; } let indent = hanging_indent(&chars, width); @@ -1348,11 +1785,13 @@ fn wrap_lines_mapped( row.spans.insert(0, Span::raw(" ".repeat(indent))); } out.push(row); + // ASCII: a char is a byte. maps.push(Some(CachedLineMap { start: logical_start + start, end: logical_start + cut, + byte_start: logical_byte + start, + byte_end: logical_byte + cut, x_offset: if first_row { 0 } else { indent }, - text: chars[start..cut].iter().map(|(ch, _)| ch).collect(), })); start = cut; first_row = false; @@ -1361,12 +1800,13 @@ fn wrap_lines_mapped( let plain: String = chars.iter().map(|(ch, _)| ch).collect(); let mut graphemes = Vec::new(); let mut char_start = 0usize; - for grapheme in plain.graphemes(true) { + for (byte_start, grapheme) in plain.grapheme_indices(true) { let char_end = char_start + grapheme.chars().count(); graphemes.push(( char_start, cell_width(grapheme), grapheme.chars().all(char::is_whitespace), + byte_start, )); char_start = char_end; } @@ -1379,7 +1819,7 @@ fn wrap_lines_mapped( let mut end = start; let mut last_space: Option = None; while end < graphemes.len() { - let (_, width_here, is_space) = graphemes[end]; + let (_, width_here, is_space, _) = graphemes[end]; if used + width_here > avail { break; } @@ -1397,11 +1837,11 @@ fn wrap_lines_mapped( _ => end.max(start + 1), } }; - let from_char = graphemes[start].0; - let to_char = if cut == graphemes.len() { - chars.len() + let (from_char, from_byte) = (graphemes[start].0, graphemes[start].3); + let (to_char, to_byte) = if cut == graphemes.len() { + (chars.len(), plain.len()) } else { - graphemes[cut].0 + (graphemes[cut].0, graphemes[cut].3) }; let mut row = rebuild(&chars[from_char..to_char]); if !first_row && indent > 0 { @@ -1415,17 +1855,16 @@ fn wrap_lines_mapped( maps.push(Some(CachedLineMap { start: logical_start + from_char, end: logical_start + to_char, + byte_start: logical_byte + from_byte, + byte_end: logical_byte + to_byte, x_offset: if first_row { 0 } else { indent }, - text: chars[from_char..to_char] - .iter() - .map(|(ch, _)| ch) - .collect(), })); start = cut; first_row = false; } } logical_start += chars.len() + 1; + logical_byte += line_bytes + 1; } (out, maps) } @@ -2157,6 +2596,139 @@ mod tests { assert_eq!(text(&t.lines())[0], "hello "); } + /// Lines that wrap, fit, hang under a marker, carry tabs, wide and + /// combining graphemes, a cluster split across spans, controls, and + /// nothing at all. + fn awkward_lines() -> Vec> { + vec![ + Line::from("plain prose that runs well past any narrow width you could pick"), + Line::from("short"), + Line::from(""), + Line::from("• a bullet whose continuation rows hang under the marker text"), + Line::from("12. a numbered item that also hangs once it wraps around"), + Line::from("\tindented\twith\ttabs between words that wrap"), + Line::from("漢字テキストと english mixed 漢字テキスト wraps on wide cells"), + Line::from(vec![Span::raw("joined e"), Span::raw("\u{301} across spans é é é")]), + Line::from("e\u{301}e\u{301} 👩\u{200d}💻👩\u{200d}💻 emoji 👩\u{200d}💻 and marks"), + Line::from("a_single_unbroken_token_far_longer_than_the_row_it_lands_in"), + Line::from("bell\u{7} and esc\u{1b} controls"), + ] + } + + /// A block's estimate never exceeds the rows its wrap produces (the + /// draw path relies on it to wrap only the blocks in view), and equals + /// them whenever every line fits. + #[test] + fn estimates_never_exceed_the_wrap_and_are_exact_when_lines_fit() { + for kind in [BlockKind::User, BlockKind::Assistant] { + for width in 1..=90 { + let mut block = Block::new(kind, awkward_lines()); + let estimate = block.measure(width); + block.ensure_cache(width); + let exact = block.cache.len(); + assert!(estimate <= exact, "{kind:?} at {width}: {estimate} > {exact}"); + if block.raw_cells.widest as usize <= usize::from(content_width(kind, width)) { + assert_eq!(estimate, exact, "{kind:?} at {width}"); + } + } + } + // Wide enough for every line: the estimate is the wrap. + let mut block = Block::new(BlockKind::Assistant, awkward_lines()); + let estimate = block.measure(200); + block.ensure_cache(200); + assert_eq!(estimate, block.cache.len()); + } + + /// Each wrapped row's map names its text as a byte range of the block's + /// selectable text instead of carrying a copy; that range must hold + /// exactly the characters its char range counts. + #[test] + fn row_maps_slice_exactly_the_text_they_count() { + for width in [9u16, 13, 24, 40, 200] { + let mut t = Transcript::new(); + t.set_width(width); + t.push_user(awkward_lines()); + t.push_assistant(awkward_lines()); + t.push_tool(vec![Line::from("✓ cat notes")], "first\n\tsecond 漢字\n".into(), false); + t.wrap_all(); + for block in &t.blocks { + let chars: Vec = block.selectable.chars().collect(); + for map in block.cache_maps.iter().flatten() { + let sliced = &block.selectable[map.byte_start..map.byte_end]; + let counted: String = chars[map.start..map.end].iter().collect(); + assert_eq!(sliced, counted, "width {width}"); + } + } + } + } + + /// `paint_rows` paints, row for row, what the cloned viewport lines + /// paint through a paragraph, the selected block's surface included. + #[test] + fn paint_rows_paints_what_the_cloned_lines_paint() { + use ratatui::widgets::{Paragraph, Widget}; + let mut t = Transcript::new(); + t.set_width(30); + t.push_user(awkward_lines()); + t.push_assistant(crate::ui::markdown::render("A reply.\n\n```rust\nfn a() {}\n```")); + t.push_tool(vec![Line::from("✗ bash cargo test")], "error: one\nnote: two".into(), false); + t.push(vec![Line::from("a notice")]); + let total = t.lines().len(); + for selected in [None, Some(1), Some(2)] { + for (start, end) in [(0, total), (3, 17), (total - 4, total)] { + let area = Rect::new(1, 2, 30, (end - start) as u16); + let mut expected = Buffer::empty(Rect::new(0, 0, 32, area.bottom() + 1)); + let mut painted = expected.clone(); + let mut lines = Vec::new(); + t.fill_viewport(&mut lines, start, end, selected); + Paragraph::new(lines).render(area, &mut expected); + let mut rows = Vec::new(); + t.paint_rows(&mut painted, area, start, end, selected, &mut rows); + assert_eq!(painted, expected, "rows {start}..{end}, selected {selected:?}"); + // Each painted row is found again at the line it painted. + let found: Vec<_> = + rows.iter().map(|at| at.and_then(|at| t.line_of(at))).collect(); + assert_eq!(found, (start..end).map(Some).collect::>()); + } + } + } + + /// A prompt rejected by a hook is removed from above the notices the + /// hook already put on screen. Rows painted before the removal must + /// still name those notices: a row named by the block's index would + /// name the block that slid into it, so a selection before the next + /// paint would copy a different notice than the one under the pointer. + #[test] + fn painted_rows_keep_their_text_when_a_block_above_is_removed() { + let mut t = Transcript::new(); + t.set_width(30); + t.push_assistant(vec![Line::from("an earlier reply")]); + t.push_user(vec![Line::from("a prompt a hook rejects")]); + t.push(vec![Line::from("first hook notice")]); + t.push(vec![Line::from("second hook notice")]); + t.push(vec![Line::from("third hook notice")]); + let total = t.lines().len(); + let area = Rect::new(0, 0, 30, total as u16); + let mut rows = Vec::new(); + t.paint_rows(&mut Buffer::empty(area), area, 0, total, None, &mut rows); + let text_of = |t: &mut Transcript, at: Option| { + let line = at.and_then(|at| t.line_of(at))?; + t.line_at(line).map(|line| lines_to_plain(std::slice::from_ref(line))) + }; + let painted: Vec<_> = rows.iter().map(|&at| text_of(&mut t, at)).collect(); + let prompt = t.block_starts[1]..t.block_starts[2]; + assert!(t.pop_last_user()); + let found: Vec<_> = rows.iter().map(|&at| text_of(&mut t, at)).collect(); + // The removed prompt's rows name nothing; every other row still + // names the text it painted. + let expected: Vec<_> = painted + .into_iter() + .enumerate() + .map(|(row, text)| text.filter(|_| !prompt.contains(&row))) + .collect(); + assert_eq!(found, expected); + } + #[test] fn transcript_rewraps_on_width_change() { let mut t = Transcript::new(); @@ -2167,7 +2739,8 @@ mod tests { assert!(t.len() >= wide); } - // Release-only diagnostic for the synchronous resize path. It is ignored + // Release-only diagnostic for the synchronous resize path: re-measuring + // every block and wrapping the ones a 40-row view shows. It is ignored // in normal CI because elapsed-time assertions are machine-dependent. // Run with: // cargo test -p openmax --release -- --ignored --nocapture measure_transcript_resize @@ -2193,6 +2766,7 @@ mod tests { for width in [72, 120, 88, 100] { let started = Instant::now(); transcript.set_width(width); + transcript.settle_view(40, 0); std::hint::black_box(transcript.len()); eprintln!( "MEASURE transcript_resize source_lines={source_lines} width={width} elapsed_ms={:.3}", @@ -2607,6 +3181,7 @@ mod tests { assert!(t.has_sticky_user(view_start)); let sticky = t .sticky_user_line(view_start) + .cloned() .expect("sticky above mid viewport"); let plain: String = sticky.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( @@ -2623,6 +3198,7 @@ mod tests { assert!(t.has_sticky_user(view_start)); let sticky2 = t .sticky_user_line(view_start) + .cloned() .expect("sticky after appends"); assert_eq!(text(&[sticky]), text(&[sticky2])); // Absolute line maps for earlier history stay valid after appends. @@ -2814,7 +3390,7 @@ mod tests { "stable prose that stays where it was put", ))]); t.push_tool(vec![Line::from("✓ bash")], "line one\nline two".into(), true); - t.ensure_index(); + t.wrap_all(); let cached = t.blocks[0].cache[0].spans[0].content.as_ptr(); assert_eq!( t.line_at(0).unwrap().spans[0].content.as_ptr(), @@ -2847,6 +3423,7 @@ mod tests { ))]); t.push_tool(vec![Line::from("✓ bash")], output.clone(), true); } + t.wrap_all(); let lines = t.len(); // Each call alternates unfold/fold on the last tool block; only @@ -2858,10 +3435,12 @@ mod tests { } let fold_ms = t0.elapsed().as_secs_f64() * 1e3 / n as f64; - // A settled resize: every block re-wraps and the tables rebuild. + // A settled resize: every block is re-measured, the view's + // blocks re-wrap, and the tables rebuild. let t0 = Instant::now(); for w in [110u16, 120, 110, 120] { t.set_width(w); + t.settle_view(40, 0); } let resize_ms = t0.elapsed().as_secs_f64() * 1e3 / 4.0;