diff --git a/crates/warpui_core/src/elements/tui/buffer.rs b/crates/warpui_core/src/elements/tui/buffer.rs index 2927dac800..f3add8fc54 100644 --- a/crates/warpui_core/src/elements/tui/buffer.rs +++ b/crates/warpui_core/src/elements/tui/buffer.rs @@ -14,22 +14,45 @@ use ratatui::buffer::CellWidth; pub use ratatui::buffer::{Buffer as TuiBuffer, Cell}; pub use ratatui::style::{Color, Modifier, Style as TuiStyle}; -use ratatui::widgets::Widget; +use ratatui::widgets::{Paragraph, Widget}; -use super::geometry::{TuiPoint, TuiRect, TuiSize}; +use super::geometry::{TuiPoint, TuiRect, TuiRectExt, TuiSize}; use super::scene::TuiScreenPosition; +/// A ratatui widget that can render a framework-computed visible row window. +/// +/// Implementations translate `clipped_rows_above` into the widget's own +/// logical content offset. Elements submit the complete widget through +/// [`TuiPaintSurface::render_widget`]; the paint surface owns visibility and +/// clipping decisions. +pub trait TuiWidget { + /// Paints the visible widget area after omitting logical rows clipped above it. + fn render_visible(self, area: TuiRect, clipped_rows_above: u16, buffer: &mut TuiBuffer); +} + +impl TuiWidget for Paragraph<'_> { + fn render_visible(self, area: TuiRect, clipped_rows_above: u16, buffer: &mut TuiBuffer) { + self.scroll((clipped_rows_above, 0)).render(area, buffer); + } +} +struct VisibleWidgetArea { + area: TuiRect, + clipped_columns_left: u16, + clipped_rows_above: u16, +} /// Absolute-coordinate paint access to one ratatui buffer. pub struct TuiPaintSurface<'a> { buffer: &'a mut TuiBuffer, screen_origin: TuiScreenPosition, buffer_origin: TuiPoint, + clip: TuiRect, } impl<'a> TuiPaintSurface<'a> { /// Creates an identity-mapped surface over `buffer`. pub fn new(buffer: &'a mut TuiBuffer) -> Self { let buffer_origin = TuiPoint::new(buffer.area.x, buffer.area.y); + let clip = buffer.area; Self { buffer, screen_origin: TuiScreenPosition::new( @@ -37,38 +60,75 @@ impl<'a> TuiPaintSurface<'a> { i32::from(buffer_origin.y), ), buffer_origin, + clip, } } + /// Reborrows this surface through an additional absolute screen-space clip. + /// + /// All cell, style, and widget writes performed by `paint` are restricted + /// to the intersection of this clip, the parent clip, and the backing + /// buffer. Returns `None` without painting when the clip is fully outside + /// the parent surface. + pub fn with_clip( + &mut self, + origin: TuiScreenPosition, + size: TuiSize, + paint: impl FnOnce(&mut TuiPaintSurface<'_>) -> R, + ) -> Option { + let clip = self.clipped_buffer_rect(origin, size)?; + let mut clipped = TuiPaintSurface { + buffer: &mut *self.buffer, + screen_origin: self.screen_origin, + buffer_origin: self.buffer_origin, + clip, + }; + Some(paint(&mut clipped)) + } /// Maps `screen_origin` to the top-left cell of `buffer`. pub fn mapped(buffer: &'a mut TuiBuffer, screen_origin: TuiScreenPosition) -> Self { + let clip = buffer.area; Self { buffer_origin: TuiPoint::new(buffer.area.x, buffer.area.y), buffer, screen_origin, + clip, } } - /// Renders a ratatui widget within absolute screen bounds. - pub fn render_widget( + /// Renders a widget within the visible part of its absolute screen bounds. + pub fn render_widget( &mut self, - widget: impl Widget, origin: TuiScreenPosition, size: TuiSize, + widget: W, ) -> bool { - let Some(area) = self.contained_buffer_rect(origin, size) else { + let Some(visible) = self.visible_widget_buffer_area(origin, size) else { return false; }; - widget.render(area, self.buffer); + if visible.area.width == size.width { + widget.render_visible(visible.area, visible.clipped_rows_above, self.buffer); + return true; + } + + let scratch_area = TuiRect::new(0, 0, size.width, visible.area.height); + let mut scratch = TuiBuffer::empty(scratch_area); + widget.render_visible(scratch_area, visible.clipped_rows_above, &mut scratch); + for row in 0..visible.area.height { + for column in 0..visible.area.width { + let source_column = visible.clipped_columns_left.saturating_add(column); + self.buffer[(visible.area.x + column, visible.area.y + row)] = + scratch[(source_column, row)].clone(); + } + } true } /// Applies `style` to the visible part of the absolute screen bounds. pub fn set_style(&mut self, origin: TuiScreenPosition, size: TuiSize, style: TuiStyle) { - let Some(area) = self.buffer_rect(origin, size) else { + let Some(area) = self.clipped_buffer_rect(origin, size) else { return; }; - let area = area.intersection(self.buffer.area); if !area.is_empty() { self.buffer.set_style(area, style); } @@ -95,27 +155,66 @@ impl<'a> TuiPaintSurface<'a> { true } - fn contained_buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option { - let area = self.buffer_rect(origin, size)?; - (area.intersection(self.buffer.area) == area).then_some(area) + fn visible_widget_buffer_area( + &self, + origin: TuiScreenPosition, + size: TuiSize, + ) -> Option { + let (x, y) = self.signed_buffer_point(origin)?; + let right = x.checked_add(i64::from(size.width))?; + let bottom = y.checked_add(i64::from(size.height))?; + let clip_left = i64::from(self.clip.x); + let clip_right = i64::from(self.clip.right()); + let visible_left = x.max(clip_left); + let visible_right = right.min(clip_right); + let visible_top = y.max(i64::from(self.clip.y)); + let visible_bottom = bottom.min(i64::from(self.clip.bottom())); + if visible_left >= visible_right || visible_top >= visible_bottom { + return None; + } + Some(VisibleWidgetArea { + area: TuiRect::new( + u16::try_from(visible_left).ok()?, + u16::try_from(visible_top).ok()?, + u16::try_from(visible_right.checked_sub(visible_left)?).ok()?, + u16::try_from(visible_bottom.checked_sub(visible_top)?).ok()?, + ), + clipped_columns_left: u16::try_from(visible_left.checked_sub(x)?).ok()?, + clipped_rows_above: u16::try_from(visible_top.checked_sub(y)?).ok()?, + }) } - fn buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option { - let origin = self.buffer_point(origin)?; - origin.x.checked_add(size.width)?; - origin.y.checked_add(size.height)?; - Some(TuiRect::new(origin.x, origin.y, size.width, size.height)) + fn clipped_buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option { + let (x, y) = self.signed_buffer_point(origin)?; + let right = x.checked_add(i64::from(size.width))?; + let bottom = y.checked_add(i64::from(size.height))?; + let left = x.max(i64::from(self.clip.x)); + let top = y.max(i64::from(self.clip.y)); + let right = right.min(i64::from(self.clip.right())); + let bottom = bottom.min(i64::from(self.clip.bottom())); + if left >= right || top >= bottom { + return None; + } + Some(TuiRect::new( + u16::try_from(left).ok()?, + u16::try_from(top).ok()?, + u16::try_from(right.checked_sub(left)?).ok()?, + u16::try_from(bottom.checked_sub(top)?).ok()?, + )) } fn buffer_point(&self, position: TuiScreenPosition) -> Option { + let (x, y) = self.signed_buffer_point(position)?; + let point = TuiPoint::new(u16::try_from(x).ok()?, u16::try_from(y).ok()?); + self.clip.contains_point(point).then_some(point) + } + + fn signed_buffer_point(&self, position: TuiScreenPosition) -> Option<(i64, i64)> { let x = i64::from(self.buffer_origin.x) .checked_add(i64::from(position.x).checked_sub(i64::from(self.screen_origin.x))?)?; let y = i64::from(self.buffer_origin.y) .checked_add(i64::from(position.y).checked_sub(i64::from(self.screen_origin.y))?)?; - Some(TuiPoint::new( - u16::try_from(x).ok()?, - u16::try_from(y).ok()?, - )) + Some((x, y)) } } diff --git a/crates/warpui_core/src/elements/tui/buffer_tests.rs b/crates/warpui_core/src/elements/tui/buffer_tests.rs index 06ee01fb2f..7568c7b816 100644 --- a/crates/warpui_core/src/elements/tui/buffer_tests.rs +++ b/crates/warpui_core/src/elements/tui/buffer_tests.rs @@ -1,6 +1,10 @@ use ratatui::style::{Color, Style}; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; -use crate::elements::tui::{TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiRect, TuiScreenPosition}; +use crate::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, +}; fn buffer(width: u16, height: u16) -> TuiBuffer { TuiBuffer::empty(TuiRect::new(0, 0, width, height)) @@ -87,3 +91,99 @@ fn surface_writes_outside_the_mapping_fail_closed() { assert_eq!(b.to_lines(), vec![" "]); } + +#[test] +fn widget_renders_only_visible_rows() { + let mut b = buffer(3, 2); + let mut surface = TuiPaintSurface::new(&mut b); + assert!(surface.render_widget( + TuiScreenPosition::new(0, -2), + TuiSize::new(3, 4), + Paragraph::new(vec![ + Line::from("a"), + Line::from("b"), + Line::from("c"), + Line::from("d"), + ]), + )); + + assert_eq!(b.to_lines(), vec!["c ", "d "]); +} +#[test] +fn widget_renders_visible_columns_when_horizontally_clipped() { + let mut b = buffer(4, 2); + let mut surface = TuiPaintSurface::new(&mut b); + assert_eq!( + surface.with_clip( + TuiScreenPosition::new(1, 0), + TuiSize::new(2, 2), + |surface| { + surface.render_widget( + TuiScreenPosition::new(0, -1), + TuiSize::new(4, 3), + Paragraph::new(vec![ + Line::from("abcd"), + Line::from("efgh"), + Line::from("ijkl"), + ]), + ) + }, + ), + Some(true), + ); + assert_eq!(b.to_lines(), vec![" fg ", " jk "]); +} + +#[test] +fn set_style_clips_negative_screen_bounds() { + let mut b = buffer(2, 2); + let mut surface = TuiPaintSurface::new(&mut b); + + surface.set_style( + TuiScreenPosition::new(0, -1), + TuiSize::new(2, 2), + Style::default().fg(Color::Red), + ); + + assert_eq!(b[(0, 0)].fg, Color::Red); + assert_eq!(b[(0, 1)].fg, Color::Reset); +} + +#[test] +fn nested_surface_clip_contains_cells_styles_and_widgets() { + let mut b = buffer(3, 4); + let mut surface = TuiPaintSurface::new(&mut b); + + surface.with_clip( + TuiScreenPosition::new(0, 1), + TuiSize::new(3, 2), + |surface| { + assert!(surface.cell_mut(TuiScreenPosition::new(0, 0)).is_none()); + surface + .cell_mut(TuiScreenPosition::new(0, 1)) + .unwrap() + .set_symbol("x"); + surface.set_style( + TuiScreenPosition::new(0, 0), + TuiSize::new(3, 4), + Style::default().fg(Color::Red), + ); + assert!(surface.render_widget( + TuiScreenPosition::new(0, 0), + TuiSize::new(3, 4), + Paragraph::new(vec![ + Line::from("a"), + Line::from("b"), + Line::from("c"), + Line::from("d"), + ]), + )); + }, + ); + + assert_eq!(b.to_lines(), vec![" ", "b ", "c ", " "]); + assert_eq!(b[(0, 0)].fg, Color::Reset); + assert_eq!(b[(0, 1)].fg, Color::Red); + assert_eq!(b[(0, 2)].fg, Color::Red); + assert_eq!(b[(0, 3)].fg, Color::Reset); +} diff --git a/crates/warpui_core/src/elements/tui/clipped.rs b/crates/warpui_core/src/elements/tui/clipped.rs index 77e829c43a..d888583b3c 100644 --- a/crates/warpui_core/src/elements/tui/clipped.rs +++ b/crates/warpui_core/src/elements/tui/clipped.rs @@ -6,9 +6,9 @@ //! the child rows before the first visible row. use super::{ - TuiBuffer, TuiClipBounds, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, - TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiRect, - TuiScreenPoint, TuiScreenPosition, TuiScreenRect, TuiSize, + TuiClipBounds, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, + TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, + TuiScreenRect, TuiSize, }; use crate::AppContext; @@ -52,7 +52,7 @@ impl TuiClipped { /// Sets the child row rendered at the top of the clipped viewport. /// /// The child still lays out and renders from its own logical row 0. The - /// clipped viewport then copies a window out of that rendered child buffer: + /// clipped viewport translates its paint origin so that /// `viewport_origin_y` is the child row that appears at viewport y=0. /// /// ```text @@ -119,33 +119,16 @@ impl TuiElement for TuiClipped { if size.width == 0 || size.height == 0 { return; } - let child_size = self - .child + self.child .size() .expect("TuiClipped child size must be retained after layout"); - let child_area = TuiRect::new( - 0, - 0, - size.width.max(child_size.width), - self.child_height(size.height).max(child_size.height), - ); - let mut child_buffer = TuiBuffer::empty(child_area); let clip = TuiScreenRect::new(screen_origin, size); let child_origin = origin.offset(0, -i32::from(self.viewport_origin_y)); ctx.with_scene_layer(TuiClipBounds::BoundedByActiveLayerAnd(clip), |ctx| { - let mut child_surface = TuiPaintSurface::mapped(&mut child_buffer, child_origin); - self.child.render(child_origin, &mut child_surface, ctx); + surface.with_clip(origin, size, |surface| { + self.child.render(child_origin, surface, ctx); + }); }); - - for y in 0..size.height { - let source_y = y.saturating_add(self.viewport_origin_y); - for x in 0..size.width { - surface.set_cell( - origin.offset(i32::from(x), i32::from(y)), - child_buffer[(x, source_y)].clone(), - ); - } - } } fn size(&self) -> Option { diff --git a/crates/warpui_core/src/elements/tui/clipped_tests.rs b/crates/warpui_core/src/elements/tui/clipped_tests.rs index 974f7857bd..9a9845b530 100644 --- a/crates/warpui_core/src/elements/tui/clipped_tests.rs +++ b/crates/warpui_core/src/elements/tui/clipped_tests.rs @@ -65,6 +65,46 @@ fn renders_from_the_requested_logical_row() { ); } +#[test] +fn direct_paint_does_not_escape_the_clipped_viewport() { + App::test((), |app| async move { + app.read(|app_ctx| { + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let mut child = TuiText::new("hidden\nvisible\nmust not leak") + .truncate() + .finish(); + child.layout( + TuiConstraint::loose(TuiSize::new(13, u16::MAX)), + &mut layout_ctx, + app_ctx, + ); + let mut clipped = TuiClipped::from_laid_out_child(child, 1, TuiSize::new(13, 1)); + + let mut buffer = crate::elements::tui::TuiBuffer::empty(TuiRect::new(0, 0, 13, 3)); + buffer.set_string( + 0, + 1, + "input footer ", + crate::elements::tui::TuiStyle::default(), + ); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + let mut surface = TuiPaintSurface::new(&mut buffer); + clipped.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + + assert_eq!( + crate::elements::tui::TuiBufferExt::to_lines(&buffer), + vec![ + "visible ".to_owned(), + "input footer ".to_owned(), + " ".to_owned(), + ], + ); + }); + }); +} #[test] fn layout_preserves_child_width_and_reports_visible_height() { App::test((), |app| async move { diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 88d0e10a4e..a7632f0ad9 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -52,7 +52,9 @@ mod text_helpers; mod viewported_list; pub use animated::TuiAnimated; -pub use buffer::{Cell, Color, Modifier, TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiStyle}; +pub use buffer::{ + Cell, Color, Modifier, TuiBuffer, TuiBufferExt, TuiPaintSurface, TuiStyle, TuiWidget, +}; pub use child_view::TuiChildView; pub use clipped::TuiClipped; pub use collapsible::tui_collapsible; diff --git a/crates/warpui_core/src/elements/tui/text.rs b/crates/warpui_core/src/elements/tui/text.rs index 031427b9e6..c3c0d19ba8 100644 --- a/crates/warpui_core/src/elements/tui/text.rs +++ b/crates/warpui_core/src/elements/tui/text.rs @@ -291,7 +291,7 @@ impl TuiElement for TuiText { if size.width == 0 || size.height == 0 { return; } - surface.render_widget(self.paragraph(size.width), origin, size); + surface.render_widget(origin, size, self.paragraph(size.width)); } fn size(&self) -> Option {