first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+262
View File
@@ -0,0 +1,262 @@
//! The TUI presenter: turns a root [`TuiView`]'s render output into a painted
//! [`TuiFrame`] (a cell [`TuiBuffer`] plus the absolute cursor cell).
//!
//! Placement: this is the additive `tui` submodule of `presenter` — the TUI
//! sibling of the GUI [`Presenter`](crate::Presenter) that owns the rest of
//! this module. Both backends genuinely have a presenter (layout + paint of a
//! window's view tree); keeping them under one module mirrors that symmetry
//! without gating any GUI path.
//!
//! # Layout pass ordering: measure → arrange → present → paint
//!
//! 1. **measure** — the root element is measured against a loose
//! [`TuiConstraint`] bounded by the target area, returning the size it wants
//! (within that box).
//! 2. **arrange** — that size is anchored at the area's origin, producing the
//! absolute rectangle the root occupies. Container elements recurse this
//! measure/arrange internally for their children (the presenter only drives
//! the root; the element tree composes itself).
//! 3. **present** — the tree is walked via [`TuiElement::present`] to record
//! parent/child *view* embeddings (from [`TuiChildView`]-style elements that
//! embed a sub-view), which are reported to the core's neutral view
//! hierarchy via [`AppContext::report_view_embeddings`]. That hierarchy is
//! what the responder chain and focus ancestor propagation walk — for TUI
//! views exactly as for GUI views.
//! 4. **paint** — the root paints into its arranged rectangle of a fresh
//! buffer. Each container paints its children into their sub-rectangles, so
//! the whole tree composites into one buffer.
//!
//! # Child views
//!
//! A child view is a full [`TuiView`] registered in the app. It is embedded by
//! resolving it through the app — [`AppContext::render_tui_view`] renders it to
//! its typed boxed element tree — and wrapping that output in a child-view
//! element during the *parent* view's render (which has app access). The
//! presenter then lays out and paints the composed tree, so the child's output
//! lands at exactly the area the layout allocated to it, and the present pass
//! records the embedded view as a child of its parent.
//!
//! [`TuiChildView`]: crate::elements::tui::TuiChildView
use crate::elements::tui::{
TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiPresentationContext, TuiRect,
};
use crate::{AppContext, EntityIdMap, TuiView, ViewHandle, WindowId, WindowInvalidation};
/// A painted frame: the composited cell [`TuiBuffer`] plus the absolute cursor
/// position (in buffer cell coordinates), if a focused element owns the cursor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TuiFrame {
/// The composited cell grid, covering the columns/rows up to the painted
/// area's right/bottom edge.
pub buffer: TuiBuffer,
/// The absolute `(x, y)` cell the terminal cursor should occupy, if any.
pub cursor: Option<(u16, u16)>,
}
impl TuiFrame {
/// A blank frame sized to cover `area`, with no cursor.
fn blank(area: TuiRect) -> Self {
Self {
buffer: TuiBuffer::empty(buffer_rect_for(area)),
cursor: None,
}
}
}
/// Lays out and paints a [`TuiView`]'s element tree into a [`TuiFrame`].
///
/// Mirrors the GUI [`Presenter`](crate::Presenter) pattern:
/// - [`invalidate`](Self::invalidate) re-renders only the views that changed
/// into `rendered_views`, leaving unchanged views' cached elements in place.
/// - [`present`](Self::present) performs layout (using the `rendered_views` map
/// via [`TuiLayoutContext`] so [`TuiChildView`] can find its child without a
/// nested render) and paint, then caches the root element in `last_element`
/// for event dispatch.
///
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
#[derive(Default)]
pub struct TuiPresenter {
/// Pre-rendered elements keyed by view id. Populated by [`invalidate`](Self::invalidate)
/// for each view that changed; consumed by [`TuiChildView`] during layout.
pub(crate) rendered_views: EntityIdMap<Box<dyn TuiElement>>,
/// The root element tree from the last [`present`](Self::present) call,
/// with all child views already laid out inside it. Reused as the starting
/// point for the next frame's layout (for unchanged child subtrees) and for
/// event dispatch between frames.
pub(crate) last_element: Option<Box<dyn TuiElement>>,
}
impl TuiPresenter {
pub fn new() -> Self {
Self::default()
}
/// Re-renders the views listed in `invalidation.updated` into `rendered_views`,
/// mirroring [`Presenter::invalidate`](crate::Presenter::invalidate).
///
/// Called by the runtime before each draw so that [`present`] finds
/// fresh elements for every changed view and stale-but-valid cached elements
/// for everything else.
///
/// [`present`]: Self::present
pub fn invalidate(
&mut self,
invalidation: &WindowInvalidation,
ctx: &AppContext,
window_id: WindowId,
) {
for &view_id in invalidation.updated.difference(&invalidation.removed) {
match ctx.render_tui_view(window_id, view_id) {
Ok(element) => {
self.rendered_views.insert(view_id, element);
}
Err(e) => log::warn!("TUI view {view_id:?} was not rendered: {e:?}"),
}
}
for &view_id in &invalidation.removed {
self.rendered_views.remove(&view_id);
}
}
/// Lays out and paints the root view's element tree into `area`.
///
/// The root element is taken from `rendered_views` (if the root was
/// re-rendered this frame by [`invalidate`](Self::invalidate)) or from
/// `last_element` (the previous frame's root). During layout, a
/// [`TuiLayoutContext`] carrying `rendered_views` is threaded through the
/// tree so [`TuiChildView`] can retrieve its child element without a nested
/// render call. The laid-out root is stored as `last_element` for the next
/// frame and for event dispatch.
///
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
pub fn present<V: TuiView>(
&mut self,
ctx: &mut AppContext,
root: &ViewHandle<V>,
area: TuiRect,
) -> TuiFrame {
let window_id = root.window_id(ctx);
let root_view_id = root.id();
// Element resolution order:
// 1. Fresh from rendered_views (populated by invalidate() this frame).
// 2. Cached last_element — ONLY when rendered_views is non-empty,
// meaning invalidate() was called and this view was not changed.
// If rendered_views is empty (no invalidate() was called), skip
// last_element: the root may be stale (e.g. view called notify()
// but the caller drives the presenter standalone without the
// runtime's invalidate() step).
// 3. Direct render fallback for callers that skip invalidate().
let Some(mut element) = self
.rendered_views
.remove(&root_view_id)
.or_else(|| {
if !self.rendered_views.is_empty() {
self.last_element.take()
} else {
None
}
})
.or_else(|| ctx.render_tui_view(window_id, root_view_id).ok())
else {
return TuiFrame::blank(area);
};
let mut layout_ctx = TuiLayoutContext {
rendered_views: &mut self.rendered_views,
};
let arranged = arrange(element.as_mut(), area, &mut layout_ctx, ctx);
let mut embeddings = EntityIdMap::default();
{
let mut present_ctx = TuiPresentationContext::new(
root_view_id,
&mut self.rendered_views,
&mut embeddings,
);
element.present(&mut present_ctx);
}
ctx.report_view_embeddings(window_id, embeddings);
let frame = paint(element.as_ref(), arranged, area, &mut self.rendered_views);
self.last_element = Some(element);
frame
}
/// Lays out and paints an already-rendered element tree into `area`.
///
/// Exposed for the runtime and tests that drive layout/paint for an element
/// tree produced outside the app's view registry. No view-ancestry is
/// recorded and no `rendered_views` state is consulted or updated.
pub fn present_element(
&mut self,
mut root: Box<dyn TuiElement>,
area: TuiRect,
app: &AppContext,
) -> TuiFrame {
let mut empty_views = EntityIdMap::default();
let mut layout_ctx = TuiLayoutContext {
rendered_views: &mut empty_views,
};
let arranged = arrange(root.as_mut(), area, &mut layout_ctx, app);
paint(root.as_ref(), arranged, area, &mut empty_views)
}
/// Returns a mutable reference to the root element from the last
/// [`present`](Self::present) call, for use by event dispatch.
pub fn last_element_mut(&mut self) -> Option<&mut Box<dyn TuiElement>> {
self.last_element.as_mut()
}
}
/// Measure the root against `area` and anchor the measured size at the area's
/// origin (the size is already within the area, but clamp defensively so
/// writes stay in bounds).
fn arrange(
root: &mut dyn TuiElement,
area: TuiRect,
ctx: &mut TuiLayoutContext,
app: &AppContext,
) -> TuiRect {
let measured = root.layout(TuiConstraint::loose(area.as_size()), ctx, app);
TuiRect::new(
area.x,
area.y,
measured.width.min(area.width),
measured.height.min(area.height),
)
}
/// Composite the tree into a fresh buffer and lift the root-relative cursor
/// offset to absolute coordinates. `rendered_views` is threaded through so
/// [`TuiChildView`] can look up its child during render and cursor passes.
///
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
fn paint(
root: &dyn TuiElement,
arranged: TuiRect,
area: TuiRect,
rendered_views: &mut EntityIdMap<Box<dyn TuiElement>>,
) -> TuiFrame {
let mut buffer = TuiBuffer::empty(buffer_rect_for(area));
let mut ctx = TuiLayoutContext { rendered_views };
root.render(arranged, &mut buffer, &mut ctx);
let cursor = root
.cursor_position(arranged, &mut ctx)
.map(|(x, y)| (arranged.x.saturating_add(x), arranged.y.saturating_add(y)));
TuiFrame { buffer, cursor }
}
/// The buffer rect needed to hold everything painted within `area`: it spans
/// from the origin to the area's right/bottom edge, so absolute coordinates
/// (including any area offset) index correctly.
fn buffer_rect_for(area: TuiRect) -> TuiRect {
TuiRect::new(0, 0, area.right(), area.bottom())
}
#[cfg(test)]
#[path = "tui_tests.rs"]
mod tests;
@@ -0,0 +1,437 @@
//! Headless tests for [`TuiPresenter`]. They drive layout + paint against local
//! [`TuiElement`] test-doubles and assert the composited [`TuiBuffer`] via
//! `to_lines` plus the surfaced cursor. The child-view tests register real
//! [`TuiView`]s in a test [`App`] and resolve them through the app, exactly as
//! the live elements do.
use super::TuiPresenter;
use crate::elements::tui::{
TuiBuffer, TuiBufferExt, TuiChildView, TuiConstraint, TuiElement, TuiLayoutContext,
TuiPresentationContext, TuiRect, TuiRectExt, TuiSize, TuiStyle,
};
use crate::platform::WindowStyle;
use crate::{
AddWindowOptions, App, AppContext, Entity, FocusContext, TuiView, TypedActionView, ViewContext,
ViewHandle,
};
// --- Test-double elements -------------------------------------------------
/// A single line of text: as wide as its content, one row tall.
struct TextDouble {
text: String,
}
impl TextDouble {
fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
}
}
fn width(&self) -> u16 {
self.text.chars().count() as u16
}
}
impl TuiElement for TextDouble {
fn layout(
&mut self,
constraint: TuiConstraint,
_ctx: &mut TuiLayoutContext,
_app: &AppContext,
) -> TuiSize {
constraint.clamp(TuiSize::new(self.width(), 1))
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
buffer.set_stringn(
area.x,
area.y,
&self.text,
usize::from(area.width),
TuiStyle::default(),
);
}
}
/// A vertical stack: each child is laid out at the column's width and stacked
/// top-to-bottom. Records per-child sizes at layout time so `render`/
/// `cursor_position` (which take `&self`) can place children consistently.
struct ColumnDouble {
children: Vec<Box<dyn TuiElement>>,
child_sizes: Vec<TuiSize>,
}
impl ColumnDouble {
fn new(children: Vec<Box<dyn TuiElement>>) -> Self {
Self {
children,
child_sizes: Vec::new(),
}
}
}
impl TuiElement for ColumnDouble {
fn layout(
&mut self,
constraint: TuiConstraint,
ctx: &mut TuiLayoutContext,
app: &AppContext,
) -> TuiSize {
self.child_sizes.clear();
let mut total_height = 0u16;
let mut max_width = 0u16;
for child in &mut self.children {
let available = TuiSize::new(
constraint.max.width,
constraint.max.height.saturating_sub(total_height),
);
let size = child.layout(TuiConstraint::loose(available), ctx, app);
total_height = total_height.saturating_add(size.height);
max_width = max_width.max(size.width);
self.child_sizes.push(size);
}
constraint.clamp(TuiSize::new(max_width, total_height))
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
let mut remaining = area;
for (child, size) in self.children.iter().zip(&self.child_sizes) {
let (row, rest) = remaining.split_top(size.height);
let child_area = TuiRect::new(row.x, row.y, size.width.min(row.width), row.height);
child.render(child_area, buffer, ctx);
remaining = rest;
}
}
fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) {
for child in &mut self.children {
child.present(ctx);
}
}
fn cursor_position(&self, area: TuiRect, ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
let mut remaining = area;
for (child, size) in self.children.iter().zip(&self.child_sizes) {
let (row, rest) = remaining.split_top(size.height);
let child_area = TuiRect::new(row.x, row.y, size.width.min(row.width), row.height);
if let Some((cx, cy)) = child.cursor_position(child_area, ctx) {
return Some((child_area.x - area.x + cx, child_area.y - area.y + cy));
}
remaining = rest;
}
None
}
}
/// A styled container that fills its area with `fill` then paints its child
/// inset by `padding` on every side.
struct ContainerDouble {
child: Box<dyn TuiElement>,
padding: u16,
fill: char,
child_size: TuiSize,
}
impl ContainerDouble {
fn new(child: Box<dyn TuiElement>, padding: u16, fill: char) -> Self {
Self {
child,
padding,
fill,
child_size: TuiSize::ZERO,
}
}
}
impl TuiElement for ContainerDouble {
fn layout(
&mut self,
constraint: TuiConstraint,
ctx: &mut TuiLayoutContext,
app: &AppContext,
) -> TuiSize {
let inset = self.padding.saturating_mul(2);
let inner_max = TuiSize::new(
constraint.max.width.saturating_sub(inset),
constraint.max.height.saturating_sub(inset),
);
let size = self.child.layout(TuiConstraint::loose(inner_max), ctx, app);
self.child_size = size;
constraint.clamp(TuiSize::new(
size.width.saturating_add(inset),
size.height.saturating_add(inset),
))
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
let fill = self.fill.to_string();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
if let Some(cell) = buffer.cell_mut((x, y)) {
cell.set_symbol(&fill);
}
}
}
let inner = area.inset(self.padding);
let child_area = TuiRect::new(
inner.x,
inner.y,
self.child_size.width.min(inner.width),
self.child_size.height.min(inner.height),
);
self.child.render(child_area, buffer, ctx);
}
fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) {
self.child.present(ctx);
}
}
/// A leaf that owns the cursor, reporting it at a fixed offset within its area.
struct CursorDouble {
offset: (u16, u16),
}
impl CursorDouble {
fn new(offset: (u16, u16)) -> Self {
Self { offset }
}
}
impl TuiElement for CursorDouble {
fn layout(
&mut self,
constraint: TuiConstraint,
_ctx: &mut TuiLayoutContext,
_app: &AppContext,
) -> TuiSize {
constraint.clamp(TuiSize::new(5, 1))
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
buffer.set_stringn(
area.x,
area.y,
"INPUT",
usize::from(area.width),
TuiStyle::default(),
);
}
fn cursor_position(&self, _area: TuiRect, _ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
Some(self.offset)
}
}
// --- Real views for the child-view recursion tests -------------------------
fn window_options() -> AddWindowOptions {
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
}
}
/// Minimal window root; never presented directly.
#[derive(Default)]
struct RootStub;
impl Entity for RootStub {
type Event = ();
}
impl TuiView for RootStub {
fn ui_name() -> &'static str {
"RootStub"
}
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
Box::new(())
}
}
impl TypedActionView for RootStub {
type Action = ();
}
/// A registered child view whose output is a single line of text.
struct LeafView;
impl Entity for LeafView {
type Event = ();
}
impl TuiView for LeafView {
fn ui_name() -> &'static str {
"LeafView"
}
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
Box::new(TextDouble::new("CHILD"))
}
}
/// A parent view: a header above an embedded child view, which it resolves
/// through the app at render time. Records `DescendentFocused` hook firings.
struct ParentView {
child: ViewHandle<LeafView>,
descendent_focus_events: usize,
}
impl Entity for ParentView {
type Event = ();
}
impl TuiView for ParentView {
fn ui_name() -> &'static str {
"ParentView"
}
fn render(&self, _app: &AppContext) -> Box<dyn TuiElement> {
let child = TuiChildView::new(&self.child);
Box::new(ColumnDouble::new(vec![
Box::new(TextDouble::new("HEADER")),
Box::new(child),
]))
}
fn on_focus(&mut self, focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {
if matches!(focus_ctx, FocusContext::DescendentFocused(_)) {
self.descendent_focus_events += 1;
}
}
}
// --- Tests ----------------------------------------------------------------
#[test]
fn paints_single_root_element_into_area() {
App::test((), |app| async move {
app.read(|app_ctx| {
let mut presenter = TuiPresenter::new();
let frame = presenter.present_element(
Box::new(TextDouble::new("HELLO")),
TuiRect::new(0, 0, 10, 1),
app_ctx,
);
assert_eq!(frame.buffer.to_lines(), vec!["HELLO "]);
assert_eq!(frame.cursor, None);
});
});
}
#[test]
fn composites_nested_container_column_text_with_offsets() {
App::test((), |app| async move {
app.read(|app_ctx| {
let column = ColumnDouble::new(vec![
Box::new(TextDouble::new("AB")),
Box::new(TextDouble::new("CDE")),
]);
let container = ContainerDouble::new(Box::new(column), 1, '.');
let mut presenter = TuiPresenter::new();
let frame =
presenter.present_element(Box::new(container), TuiRect::new(0, 0, 5, 4), app_ctx);
assert_eq!(
frame.buffer.to_lines(),
vec![".....", ".AB..", ".CDE.", "....."],
);
});
});
}
#[test]
fn surfaces_cursor_at_absolute_coordinates() {
App::test((), |app| async move {
app.read(|app_ctx| {
let column = ColumnDouble::new(vec![
Box::new(TextDouble::new("HEADER")),
Box::new(CursorDouble::new((2, 0))),
]);
let mut presenter = TuiPresenter::new();
let frame =
presenter.present_element(Box::new(column), TuiRect::new(0, 0, 8, 2), app_ctx);
// The cursor element sits on row 1 (below the header) at column 2.
assert_eq!(frame.cursor, Some((2, 1)));
});
});
}
#[test]
fn recurses_into_registered_child_view_and_reports_embeddings() {
App::test((), |mut app| async move {
let (window_id, _root) =
app.update(|ctx| ctx.add_tui_window(window_options(), |_| RootStub));
let child = app.update(|ctx| ctx.add_tui_view(window_id, |_| LeafView));
let child_view_id = child.id();
let parent = app.update(|ctx| {
ctx.add_tui_view(window_id, move |_| ParentView {
child,
descendent_focus_events: 0,
})
});
let mut presenter = TuiPresenter::new();
let frame = app.update(|ctx| {
let invalidation = ctx.take_all_invalidations_for_window(window_id);
presenter.invalidate(&invalidation, ctx, window_id);
presenter.present(ctx, &parent, TuiRect::new(0, 0, 8, 3))
});
// The child view's output is painted directly below the header, at the
// area the layout allocated to the embedded child-view element.
assert_eq!(
frame.buffer.to_lines(),
vec!["HEADER ", "CHILD ", " "],
);
// The presentation pass reported the embedded view into the core's
// neutral view hierarchy: the child's ancestor chain now runs through
// the parent.
assert_eq!(
app.read(|ctx| ctx.view_ancestors(window_id, child_view_id)),
vec![parent.id(), child_view_id],
);
});
}
#[test]
fn focusing_embedded_child_fires_descendent_focus_on_parent() {
App::test((), |mut app| async move {
let (window_id, _root) =
app.update(|ctx| ctx.add_tui_window(window_options(), |_| RootStub));
let child = app.update(|ctx| ctx.add_tui_view(window_id, |_| LeafView));
let child_for_view = child.clone();
let parent = app.update(|ctx| {
ctx.add_tui_view(window_id, move |_| ParentView {
child: child_for_view,
descendent_focus_events: 0,
})
});
// Before any present pass the core knows nothing about the embedding.
assert_eq!(parent.read(&app, |view, _| view.descendent_focus_events), 0);
// A present pass reports the embedding into `view_parents`...
let mut presenter = TuiPresenter::new();
app.update(|ctx| {
let invalidation = ctx.take_all_invalidations_for_window(window_id);
presenter.invalidate(&invalidation, ctx, window_id);
presenter.present(ctx, &parent, TuiRect::new(0, 0, 8, 3))
});
// ...so focusing the embedded child walks the ancestor chain and fires
// the parent's `on_focus` hook with `DescendentFocused`.
child.update(&mut app, |_, ctx| ctx.focus_self());
assert_eq!(app.focused_view_id(window_id), Some(child.id()));
assert_eq!(parent.read(&app, |view, _| view.descendent_focus_events), 1);
});
}