Skip to content

Commit eda0085

Browse files
authored
[2/4] TUI: bound clipped viewport painting (#14582)
## Description Stack 2 of 4. Removes the full-height scratch buffer from `TuiClipped` and paints children directly through a nested, buffer-enforced clip. `TuiText` supplies the logical row offset to ratatui so only the visible paragraph window is painted. The clip is enforced for cell, style, and widget writes; it is not only scene/hit-test metadata. Regression coverage includes nested clipping, translated rows, negative bounds, cursor visibility, and a retained long child that must not overwrite simulated input/footer rows. Benchmark at 120×50 versus stack PR 1: - Many completed blocks, 10,000: 64.0 → 29.1 µs/frame (55% faster). - Retained rich response, 1,000 rows: 2.30 → 0.925 ms (60% faster). - Retained rich response, 10,000 rows: 24.5 → 8.56 ms (65% faster). - Invalidated rich response, 10,000 rows: 25.7 → 9.38 ms (63% faster). - Retained streaming tail at end, 10,000 rows: 33.9 → 17.2 ms (49% faster). Stack: #14575 ← **2/4** → `oz/tui-retained-frame-overhead` → `oz/tui-streaming-height-reconciliation`. Agent conversation: https://staging.warp.dev/conversation/2b6af7be-b227-4b20-aaf4-23c9a8494b81 Stack plan: https://staging.warp.dev/drive/notebook/yM2YcQsILFsFzwixBDbD9p Performance plan: https://staging.warp.dev/drive/notebook/LHzsFd1B8XbQ8AnUqB88LS ## Linked Issue N/A — Warp Agent CLI transcript rendering performance. ## Testing - `cargo nextest run -p warpui_core --features tui` — 555 passed, 7 skipped. - `cargo nextest run -p warp_tui` — 902 passed. - Strict Clippy passed for `warpui_core --features tui` and `warp_tui --features test-util`. - `cargo bench -p warp_tui --features test-util --bench transcript_bench` - `./script/format` - Signed-in 120×40 tmux PTY: zero state and unsent input repainted correctly. - [x] I have manually tested my changes locally with `./script/run-tui` ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode CHANGELOG-IMPROVEMENT: Improved scrolling and typing performance for long Warp Agent CLI transcripts.
1 parent 53d770b commit eda0085

6 files changed

Lines changed: 273 additions & 49 deletions

File tree

crates/warpui_core/src/elements/tui/buffer.rs

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,61 +14,121 @@
1414
use ratatui::buffer::CellWidth;
1515
pub use ratatui::buffer::{Buffer as TuiBuffer, Cell};
1616
pub use ratatui::style::{Color, Modifier, Style as TuiStyle};
17-
use ratatui::widgets::Widget;
17+
use ratatui::widgets::{Paragraph, Widget};
1818

19-
use super::geometry::{TuiPoint, TuiRect, TuiSize};
19+
use super::geometry::{TuiPoint, TuiRect, TuiRectExt, TuiSize};
2020
use super::scene::TuiScreenPosition;
21+
/// A ratatui widget that can render a framework-computed visible row window.
22+
///
23+
/// Implementations translate `clipped_rows_above` into the widget's own
24+
/// logical content offset. Elements submit the complete widget through
25+
/// [`TuiPaintSurface::render_widget`]; the paint surface owns visibility and
26+
/// clipping decisions.
27+
pub trait TuiWidget {
28+
/// Paints the visible widget area after omitting logical rows clipped above it.
29+
fn render_visible(self, area: TuiRect, clipped_rows_above: u16, buffer: &mut TuiBuffer);
30+
}
31+
32+
impl TuiWidget for Paragraph<'_> {
33+
fn render_visible(self, area: TuiRect, clipped_rows_above: u16, buffer: &mut TuiBuffer) {
34+
self.scroll((clipped_rows_above, 0)).render(area, buffer);
35+
}
36+
}
37+
struct VisibleWidgetArea {
38+
area: TuiRect,
39+
clipped_columns_left: u16,
40+
clipped_rows_above: u16,
41+
}
2142

2243
/// Absolute-coordinate paint access to one ratatui buffer.
2344
pub struct TuiPaintSurface<'a> {
2445
buffer: &'a mut TuiBuffer,
2546
screen_origin: TuiScreenPosition,
2647
buffer_origin: TuiPoint,
48+
clip: TuiRect,
2749
}
2850

2951
impl<'a> TuiPaintSurface<'a> {
3052
/// Creates an identity-mapped surface over `buffer`.
3153
pub fn new(buffer: &'a mut TuiBuffer) -> Self {
3254
let buffer_origin = TuiPoint::new(buffer.area.x, buffer.area.y);
55+
let clip = buffer.area;
3356
Self {
3457
buffer,
3558
screen_origin: TuiScreenPosition::new(
3659
i32::from(buffer_origin.x),
3760
i32::from(buffer_origin.y),
3861
),
3962
buffer_origin,
63+
clip,
4064
}
4165
}
66+
/// Reborrows this surface through an additional absolute screen-space clip.
67+
///
68+
/// All cell, style, and widget writes performed by `paint` are restricted
69+
/// to the intersection of this clip, the parent clip, and the backing
70+
/// buffer. Returns `None` without painting when the clip is fully outside
71+
/// the parent surface.
72+
pub fn with_clip<R>(
73+
&mut self,
74+
origin: TuiScreenPosition,
75+
size: TuiSize,
76+
paint: impl FnOnce(&mut TuiPaintSurface<'_>) -> R,
77+
) -> Option<R> {
78+
let clip = self.clipped_buffer_rect(origin, size)?;
79+
let mut clipped = TuiPaintSurface {
80+
buffer: &mut *self.buffer,
81+
screen_origin: self.screen_origin,
82+
buffer_origin: self.buffer_origin,
83+
clip,
84+
};
85+
Some(paint(&mut clipped))
86+
}
4287

4388
/// Maps `screen_origin` to the top-left cell of `buffer`.
4489
pub fn mapped(buffer: &'a mut TuiBuffer, screen_origin: TuiScreenPosition) -> Self {
90+
let clip = buffer.area;
4591
Self {
4692
buffer_origin: TuiPoint::new(buffer.area.x, buffer.area.y),
4793
buffer,
4894
screen_origin,
95+
clip,
4996
}
5097
}
5198

52-
/// Renders a ratatui widget within absolute screen bounds.
53-
pub fn render_widget(
99+
/// Renders a widget within the visible part of its absolute screen bounds.
100+
pub fn render_widget<W: TuiWidget>(
54101
&mut self,
55-
widget: impl Widget,
56102
origin: TuiScreenPosition,
57103
size: TuiSize,
104+
widget: W,
58105
) -> bool {
59-
let Some(area) = self.contained_buffer_rect(origin, size) else {
106+
let Some(visible) = self.visible_widget_buffer_area(origin, size) else {
60107
return false;
61108
};
62-
widget.render(area, self.buffer);
109+
if visible.area.width == size.width {
110+
widget.render_visible(visible.area, visible.clipped_rows_above, self.buffer);
111+
return true;
112+
}
113+
114+
let scratch_area = TuiRect::new(0, 0, size.width, visible.area.height);
115+
let mut scratch = TuiBuffer::empty(scratch_area);
116+
widget.render_visible(scratch_area, visible.clipped_rows_above, &mut scratch);
117+
for row in 0..visible.area.height {
118+
for column in 0..visible.area.width {
119+
let source_column = visible.clipped_columns_left.saturating_add(column);
120+
self.buffer[(visible.area.x + column, visible.area.y + row)] =
121+
scratch[(source_column, row)].clone();
122+
}
123+
}
63124
true
64125
}
65126

66127
/// Applies `style` to the visible part of the absolute screen bounds.
67128
pub fn set_style(&mut self, origin: TuiScreenPosition, size: TuiSize, style: TuiStyle) {
68-
let Some(area) = self.buffer_rect(origin, size) else {
129+
let Some(area) = self.clipped_buffer_rect(origin, size) else {
69130
return;
70131
};
71-
let area = area.intersection(self.buffer.area);
72132
if !area.is_empty() {
73133
self.buffer.set_style(area, style);
74134
}
@@ -95,27 +155,66 @@ impl<'a> TuiPaintSurface<'a> {
95155
true
96156
}
97157

98-
fn contained_buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option<TuiRect> {
99-
let area = self.buffer_rect(origin, size)?;
100-
(area.intersection(self.buffer.area) == area).then_some(area)
158+
fn visible_widget_buffer_area(
159+
&self,
160+
origin: TuiScreenPosition,
161+
size: TuiSize,
162+
) -> Option<VisibleWidgetArea> {
163+
let (x, y) = self.signed_buffer_point(origin)?;
164+
let right = x.checked_add(i64::from(size.width))?;
165+
let bottom = y.checked_add(i64::from(size.height))?;
166+
let clip_left = i64::from(self.clip.x);
167+
let clip_right = i64::from(self.clip.right());
168+
let visible_left = x.max(clip_left);
169+
let visible_right = right.min(clip_right);
170+
let visible_top = y.max(i64::from(self.clip.y));
171+
let visible_bottom = bottom.min(i64::from(self.clip.bottom()));
172+
if visible_left >= visible_right || visible_top >= visible_bottom {
173+
return None;
174+
}
175+
Some(VisibleWidgetArea {
176+
area: TuiRect::new(
177+
u16::try_from(visible_left).ok()?,
178+
u16::try_from(visible_top).ok()?,
179+
u16::try_from(visible_right.checked_sub(visible_left)?).ok()?,
180+
u16::try_from(visible_bottom.checked_sub(visible_top)?).ok()?,
181+
),
182+
clipped_columns_left: u16::try_from(visible_left.checked_sub(x)?).ok()?,
183+
clipped_rows_above: u16::try_from(visible_top.checked_sub(y)?).ok()?,
184+
})
101185
}
102186

103-
fn buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option<TuiRect> {
104-
let origin = self.buffer_point(origin)?;
105-
origin.x.checked_add(size.width)?;
106-
origin.y.checked_add(size.height)?;
107-
Some(TuiRect::new(origin.x, origin.y, size.width, size.height))
187+
fn clipped_buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option<TuiRect> {
188+
let (x, y) = self.signed_buffer_point(origin)?;
189+
let right = x.checked_add(i64::from(size.width))?;
190+
let bottom = y.checked_add(i64::from(size.height))?;
191+
let left = x.max(i64::from(self.clip.x));
192+
let top = y.max(i64::from(self.clip.y));
193+
let right = right.min(i64::from(self.clip.right()));
194+
let bottom = bottom.min(i64::from(self.clip.bottom()));
195+
if left >= right || top >= bottom {
196+
return None;
197+
}
198+
Some(TuiRect::new(
199+
u16::try_from(left).ok()?,
200+
u16::try_from(top).ok()?,
201+
u16::try_from(right.checked_sub(left)?).ok()?,
202+
u16::try_from(bottom.checked_sub(top)?).ok()?,
203+
))
108204
}
109205

110206
fn buffer_point(&self, position: TuiScreenPosition) -> Option<TuiPoint> {
207+
let (x, y) = self.signed_buffer_point(position)?;
208+
let point = TuiPoint::new(u16::try_from(x).ok()?, u16::try_from(y).ok()?);
209+
self.clip.contains_point(point).then_some(point)
210+
}
211+
212+
fn signed_buffer_point(&self, position: TuiScreenPosition) -> Option<(i64, i64)> {
111213
let x = i64::from(self.buffer_origin.x)
112214
.checked_add(i64::from(position.x).checked_sub(i64::from(self.screen_origin.x))?)?;
113215
let y = i64::from(self.buffer_origin.y)
114216
.checked_add(i64::from(position.y).checked_sub(i64::from(self.screen_origin.y))?)?;
115-
Some(TuiPoint::new(
116-
u16::try_from(x).ok()?,
117-
u16::try_from(y).ok()?,
118-
))
217+
Some((x, y))
119218
}
120219
}
121220

crates/warpui_core/src/elements/tui/buffer_tests.rs

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use ratatui::style::{Color, Style};
2+
use ratatui::text::Line;
3+
use ratatui::widgets::Paragraph;
24

3-
use crate::elements::tui::{TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiRect, TuiScreenPosition};
5+
use crate::elements::tui::{
6+
TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize,
7+
};
48

59
fn buffer(width: u16, height: u16) -> TuiBuffer {
610
TuiBuffer::empty(TuiRect::new(0, 0, width, height))
@@ -87,3 +91,99 @@ fn surface_writes_outside_the_mapping_fail_closed() {
8791

8892
assert_eq!(b.to_lines(), vec![" "]);
8993
}
94+
95+
#[test]
96+
fn widget_renders_only_visible_rows() {
97+
let mut b = buffer(3, 2);
98+
let mut surface = TuiPaintSurface::new(&mut b);
99+
assert!(surface.render_widget(
100+
TuiScreenPosition::new(0, -2),
101+
TuiSize::new(3, 4),
102+
Paragraph::new(vec![
103+
Line::from("a"),
104+
Line::from("b"),
105+
Line::from("c"),
106+
Line::from("d"),
107+
]),
108+
));
109+
110+
assert_eq!(b.to_lines(), vec!["c ", "d "]);
111+
}
112+
#[test]
113+
fn widget_renders_visible_columns_when_horizontally_clipped() {
114+
let mut b = buffer(4, 2);
115+
let mut surface = TuiPaintSurface::new(&mut b);
116+
assert_eq!(
117+
surface.with_clip(
118+
TuiScreenPosition::new(1, 0),
119+
TuiSize::new(2, 2),
120+
|surface| {
121+
surface.render_widget(
122+
TuiScreenPosition::new(0, -1),
123+
TuiSize::new(4, 3),
124+
Paragraph::new(vec![
125+
Line::from("abcd"),
126+
Line::from("efgh"),
127+
Line::from("ijkl"),
128+
]),
129+
)
130+
},
131+
),
132+
Some(true),
133+
);
134+
assert_eq!(b.to_lines(), vec![" fg ", " jk "]);
135+
}
136+
137+
#[test]
138+
fn set_style_clips_negative_screen_bounds() {
139+
let mut b = buffer(2, 2);
140+
let mut surface = TuiPaintSurface::new(&mut b);
141+
142+
surface.set_style(
143+
TuiScreenPosition::new(0, -1),
144+
TuiSize::new(2, 2),
145+
Style::default().fg(Color::Red),
146+
);
147+
148+
assert_eq!(b[(0, 0)].fg, Color::Red);
149+
assert_eq!(b[(0, 1)].fg, Color::Reset);
150+
}
151+
152+
#[test]
153+
fn nested_surface_clip_contains_cells_styles_and_widgets() {
154+
let mut b = buffer(3, 4);
155+
let mut surface = TuiPaintSurface::new(&mut b);
156+
157+
surface.with_clip(
158+
TuiScreenPosition::new(0, 1),
159+
TuiSize::new(3, 2),
160+
|surface| {
161+
assert!(surface.cell_mut(TuiScreenPosition::new(0, 0)).is_none());
162+
surface
163+
.cell_mut(TuiScreenPosition::new(0, 1))
164+
.unwrap()
165+
.set_symbol("x");
166+
surface.set_style(
167+
TuiScreenPosition::new(0, 0),
168+
TuiSize::new(3, 4),
169+
Style::default().fg(Color::Red),
170+
);
171+
assert!(surface.render_widget(
172+
TuiScreenPosition::new(0, 0),
173+
TuiSize::new(3, 4),
174+
Paragraph::new(vec![
175+
Line::from("a"),
176+
Line::from("b"),
177+
Line::from("c"),
178+
Line::from("d"),
179+
]),
180+
));
181+
},
182+
);
183+
184+
assert_eq!(b.to_lines(), vec![" ", "b ", "c ", " "]);
185+
assert_eq!(b[(0, 0)].fg, Color::Reset);
186+
assert_eq!(b[(0, 1)].fg, Color::Red);
187+
assert_eq!(b[(0, 2)].fg, Color::Red);
188+
assert_eq!(b[(0, 3)].fg, Color::Reset);
189+
}

crates/warpui_core/src/elements/tui/clipped.rs

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
//! the child rows before the first visible row.
77
88
use super::{
9-
TuiBuffer, TuiClipBounds, TuiConstraint, TuiElement, TuiEvent, TuiEventContext,
10-
TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiRect,
11-
TuiScreenPoint, TuiScreenPosition, TuiScreenRect, TuiSize,
9+
TuiClipBounds, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext,
10+
TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition,
11+
TuiScreenRect, TuiSize,
1212
};
1313
use crate::AppContext;
1414

@@ -52,7 +52,7 @@ impl TuiClipped {
5252
/// Sets the child row rendered at the top of the clipped viewport.
5353
///
5454
/// The child still lays out and renders from its own logical row 0. The
55-
/// clipped viewport then copies a window out of that rendered child buffer:
55+
/// clipped viewport translates its paint origin so that
5656
/// `viewport_origin_y` is the child row that appears at viewport y=0.
5757
///
5858
/// ```text
@@ -119,33 +119,16 @@ impl TuiElement for TuiClipped {
119119
if size.width == 0 || size.height == 0 {
120120
return;
121121
}
122-
let child_size = self
123-
.child
122+
self.child
124123
.size()
125124
.expect("TuiClipped child size must be retained after layout");
126-
let child_area = TuiRect::new(
127-
0,
128-
0,
129-
size.width.max(child_size.width),
130-
self.child_height(size.height).max(child_size.height),
131-
);
132-
let mut child_buffer = TuiBuffer::empty(child_area);
133125
let clip = TuiScreenRect::new(screen_origin, size);
134126
let child_origin = origin.offset(0, -i32::from(self.viewport_origin_y));
135127
ctx.with_scene_layer(TuiClipBounds::BoundedByActiveLayerAnd(clip), |ctx| {
136-
let mut child_surface = TuiPaintSurface::mapped(&mut child_buffer, child_origin);
137-
self.child.render(child_origin, &mut child_surface, ctx);
128+
surface.with_clip(origin, size, |surface| {
129+
self.child.render(child_origin, surface, ctx);
130+
});
138131
});
139-
140-
for y in 0..size.height {
141-
let source_y = y.saturating_add(self.viewport_origin_y);
142-
for x in 0..size.width {
143-
surface.set_cell(
144-
origin.offset(i32::from(x), i32::from(y)),
145-
child_buffer[(x, source_y)].clone(),
146-
);
147-
}
148-
}
149132
}
150133

151134
fn size(&self) -> Option<TuiSize> {

0 commit comments

Comments
 (0)