Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 120 additions & 21 deletions crates/warpui_core/src/elements/tui/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,61 +14,121 @@
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(
i32::from(buffer_origin.x),
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<R>(
&mut self,
origin: TuiScreenPosition,
size: TuiSize,
paint: impl FnOnce(&mut TuiPaintSurface<'_>) -> R,
) -> Option<R> {
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<W: TuiWidget>(
&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);
}
Expand All @@ -95,27 +155,66 @@ impl<'a> TuiPaintSurface<'a> {
true
}

fn contained_buffer_rect(&self, origin: TuiScreenPosition, size: TuiSize) -> Option<TuiRect> {
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<VisibleWidgetArea> {
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<TuiRect> {
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<TuiRect> {
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<TuiPoint> {
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))
}
}

Expand Down
102 changes: 101 additions & 1 deletion crates/warpui_core/src/elements/tui/buffer_tests.rs
Original file line number Diff line number Diff line change
@@ -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))
Expand Down Expand Up @@ -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);
}
33 changes: 8 additions & 25 deletions crates/warpui_core/src/elements/tui/clipped.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<TuiSize> {
Expand Down
Loading
Loading