diff --git a/crates/warpui_core/Cargo.toml b/crates/warpui_core/Cargo.toml index 91ebeea3db..72842081a6 100644 --- a/crates/warpui_core/Cargo.toml +++ b/crates/warpui_core/Cargo.toml @@ -112,3 +112,15 @@ woothee = "0.13.0" [build-dependencies] cfg_aliases.workspace = true + +# The TUI integration test references tui-only API, so it must be skipped +# entirely when the feature is off; `required-features` needs an explicit +# target section. +[[test]] +name = "tui_integration" +required-features = ["tui"] + +# Interactive manual smoke test for the TUI backend; tui-only API. +[[example]] +name = "tui_demo" +required-features = ["tui"] diff --git a/crates/warpui_core/examples/tui_demo.rs b/crates/warpui_core/examples/tui_demo.rs new file mode 100644 index 0000000000..5cb30a3123 --- /dev/null +++ b/crates/warpui_core/examples/tui_demo.rs @@ -0,0 +1,173 @@ +//! Interactive manual smoke test / showcase for the in-core TUI backend. +//! +//! Run it from a real terminal: +//! +//! ```sh +//! cargo run -p warpui_core --example tui_demo --features tui +//! ``` +//! +//! It drives the real [`TuiRuntime`] against your terminal and exercises: +//! - **paragraph word-wrapping** — resize the width to watch the paragraph +//! re-wrap on word boundaries, +//! - **wide-glyph rendering** — emoji, CJK, ZWJ sequences and a flag, to check +//! that wide / zero-width grapheme clusters keep their columns aligned, +//! - **the ratatui buffer diff** — only changed cells are re-emitted between +//! frames, and resizing reconciles instead of clearing (so no flicker), +//! - **vertical scrolling** — a long body scrolls in place under a fixed header. +//! +//! Keys: `j` / `↓` scroll down · `k` / `↑` scroll up · resize `↔` to re-wrap · +//! `q` / `Esc` quit. +//! +//! It uses [`App::test`] only to stand up the shared core without the GUI +//! platform; the TUI backend itself renders to stdout, not a GUI window. + +use std::cell::Cell; +use std::rc::Rc; + +use warpui_core::elements::tui::{ + Modifier, TuiColumn, TuiElement, TuiEventHandler, TuiParentElement, TuiStyle, TuiText, +}; +use warpui_core::platform::WindowStyle; +use warpui_core::runtime::TuiRuntime; +use warpui_core::{ + AddWindowOptions, App, AppContext, Entity, TuiView, TypedActionView, ViewContext, +}; + +/// A long line that mixes wide CJK, emoji, a ZWJ family/snowman and a flag, so +/// wrapping + grapheme-cluster width handling can be eyeballed as it reflows. +const WRAPPING_PARAGRAPH: &str = "Resize the terminal horizontally to watch this \ +paragraph re-wrap on word boundaries. It deliberately mixes wide CJK 日本語 と 世界, \ +emoji 😀 🎉 🚀, a polar-bear ZWJ sequence 🐻\u{200d}❄\u{fe0f}, a family 👨\u{200d}👩\u{200d}👧\u{200d}👦, \ +and a flag 🇺🇸 so you can confirm that wide and zero-width grapheme clusters keep \ +their columns aligned as the text reflows to the available width."; + +/// Scroll actions, dispatched as typed actions through the shared core so the +/// runtime's typed-action path is exercised end to end. +#[derive(Debug, Clone, Copy)] +enum Scroll { + Down, + Up, +} + +struct ShowcaseView { + body: Vec, + scroll: usize, + quit: Rc>, +} + +impl ShowcaseView { + fn new(quit: Rc>) -> Self { + let emojis = [ + "🦊", + "🚀", + "🎉", + "🐻\u{200d}❄\u{fe0f}", + "🇺🇸", + "✨", + "🧠", + "📦", + ]; + let body = (0..40) + .map(|i| { + let emoji = emojis[i % emojis.len()]; + format!("row {i:02} {emoji} the quick brown fox jumps over 世界 ──────") + }) + .collect(); + Self { + body, + scroll: 0, + quit, + } + } +} + +impl Entity for ShowcaseView { + type Event = (); +} + +impl TuiView for ShowcaseView { + fn ui_name() -> &'static str { + "ShowcaseView" + } + + fn render(&self, _ctx: &AppContext) -> Box { + let bold = TuiStyle::default().add_modifier(Modifier::BOLD); + let dim = TuiStyle::default().add_modifier(Modifier::DIM); + + let mut rows: Vec> = Vec::new(); + rows.push(Box::new( + TuiText::new("WarpUI · TUI showcase") + .with_style(bold) + .truncate(), + )); + rows.push(Box::new( + TuiText::new("j/↓ scroll · k/↑ up · resize ↔ to re-wrap · q quit") + .with_style(dim) + .truncate(), + )); + rows.push(Box::new(TuiText::new(" "))); + // Wrapping paragraph: default (word-wrap) policy, so it reflows to width. + rows.push(Box::new(TuiText::new(WRAPPING_PARAGRAPH))); + rows.push(Box::new(TuiText::new(" "))); + rows.push(Box::new( + TuiText::new(format!("scroll {}/{}", self.scroll, self.body.len())) + .with_style(dim) + .truncate(), + )); + rows.push(Box::new(TuiText::new("──────── body ────────").truncate())); + // Scrollable body: feed rows from the scroll offset; the column clips + // whatever doesn't fit at the bottom, so the list scrolls in place. + for line in &self.body[self.scroll.min(self.body.len())..] { + rows.push(Box::new(TuiText::new(line.clone()).truncate())); + } + + let quit_for_q = self.quit.clone(); + let quit_for_esc = self.quit.clone(); + Box::new( + TuiEventHandler::new(TuiColumn::new().with_children(rows)) + .on_key("j", |_, ctx, _| ctx.dispatch_typed_action(Scroll::Down)) + .on_key("down", |_, ctx, _| ctx.dispatch_typed_action(Scroll::Down)) + .on_key("k", |_, ctx, _| ctx.dispatch_typed_action(Scroll::Up)) + .on_key("up", |_, ctx, _| ctx.dispatch_typed_action(Scroll::Up)) + .on_key("q", move |_, _, _| quit_for_q.set(true)) + .on_key("escape", move |_, _, _| quit_for_esc.set(true)), + ) + } +} + +impl TypedActionView for ShowcaseView { + type Action = Scroll; + + fn handle_action(&mut self, action: &Scroll, ctx: &mut ViewContext) { + let max = self.body.len().saturating_sub(1); + match action { + Scroll::Down => self.scroll = (self.scroll + 1).min(max), + Scroll::Up => self.scroll = self.scroll.saturating_sub(1), + } + // Mark the view dirty so the runtime repaints with the new offset. + ctx.notify(); + } +} + +fn main() { + App::test((), |mut app| async move { + let quit = Rc::new(Cell::new(false)); + let quit_for_view = quit.clone(); + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + move |_| ShowcaseView::new(quit_for_view), + ) + }); + + let mut runtime = + TuiRuntime::enter(&app, window_id, root).expect("enter the alternate screen"); + let quit_for_loop = quit.clone(); + runtime + .run_until(&mut app, move |_| quit_for_loop.get()) + .expect("run the TUI loop"); + }); +} diff --git a/crates/warpui_core/src/core/tui_view_tests.rs b/crates/warpui_core/src/core/tui_view_tests.rs index 1cf4c6c588..6410efffaf 100644 --- a/crates/warpui_core/src/core/tui_view_tests.rs +++ b/crates/warpui_core/src/core/tui_view_tests.rs @@ -5,7 +5,9 @@ //! additively alongside the full GUI test suite under `--features tui`. use super::*; -use crate::elements::tui::{TuiBuffer, TuiConstraint, TuiElement, TuiRect, TuiSize}; +use crate::elements::tui::{ + TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiRect, TuiSize, +}; use crate::platform::WindowStyle; /// A GUI root view hosting TUI views: under the additive design, GUI and TUI @@ -147,15 +149,11 @@ impl Entity for ActionView { pub struct TuiEmpty; impl TuiElement for TuiEmpty { - fn layout(&mut self, _constraint: TuiConstraint) -> TuiSize { + fn layout(&mut self, _constraint: TuiConstraint, _ctx: &mut TuiLayoutContext) -> TuiSize { TuiSize::ZERO } - fn render(&self, _area: TuiRect, _buffer: &mut TuiBuffer) {} - - fn desired_height(&self, _width: u16) -> u16 { - 0 - } + fn render(&self, _area: TuiRect, _buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {} } impl TuiView for ActionView { diff --git a/crates/warpui_core/src/elements/tui/buffer.rs b/crates/warpui_core/src/elements/tui/buffer.rs index 76be58ebdd..61a271ebc2 100644 --- a/crates/warpui_core/src/elements/tui/buffer.rs +++ b/crates/warpui_core/src/elements/tui/buffer.rs @@ -13,7 +13,7 @@ use ratatui::buffer::CellWidth; pub use ratatui::buffer::{Buffer as TuiBuffer, Cell}; -pub use ratatui::style::Style as TuiStyle; +pub use ratatui::style::{Color, Modifier, Style as TuiStyle}; /// Headless rendering of a [`TuiBuffer`] to one `String` per row. pub trait TuiBufferExt { diff --git a/crates/warpui_core/src/elements/tui/child_view.rs b/crates/warpui_core/src/elements/tui/child_view.rs index e092fd6596..ed0bb44786 100644 --- a/crates/warpui_core/src/elements/tui/child_view.rs +++ b/crates/warpui_core/src/elements/tui/child_view.rs @@ -17,63 +17,72 @@ //! the action origin for the duration of the subtree's dispatch. use super::{ - TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiPresentationContext, TuiRect, TuiSize, + TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiLayoutContext, + TuiPresentationContext, TuiRect, TuiSize, }; use crate::{AppContext, EntityId, Event, TuiView, ViewHandle}; +/// Embeds a registered [`TuiView`] as a node in the element tree, mirroring +/// the GUI's `ChildView` design: the child element is never cached in this +/// struct. Instead, every pass (layout, render, present, dispatch) temporarily +/// removes the child from [`TuiLayoutContext::rendered_views`] (or +/// [`TuiPresentationContext::rendered_views`]), uses it, and returns it — the +/// same move-in/move-out pattern the GUI uses through its `PaintContext` and +/// `EventContext`. pub struct TuiChildView { view_id: EntityId, - child: Box, } impl TuiChildView { - /// Renders `handle`'s view now and embeds the resulting element tree. - /// - /// The child is rendered through the handle directly (typed, no erasure) - /// rather than through [`AppContext::render_tui_view`]: a child view is - /// embedded during its *parent's* render, and autotracking does not allow - /// nested `render_view` frames. The child's `Tracked` reads therefore - /// attribute to the ancestor view whose render is active — which still - /// invalidates the window for the TUI's full-frame repaint. - pub fn new(handle: &ViewHandle, app: &AppContext) -> Self - where - V: TuiView, - { + pub fn new(handle: &ViewHandle) -> Self { Self { view_id: handle.id(), - child: handle.read(app, |view, app| view.render(app)), } } - /// Constructs a child view directly from an already-rendered element, - /// bypassing the live `App`. Used by headless tests to exercise the - /// embedding/recursion contract without standing up a real view. + /// Inserts a pre-rendered element directly into `rendered_views` for + /// headless tests that exercise the embedding/recursion contract without + /// a full presenter. Returns the `TuiChildView` node that will look up the + /// element from `rendered_views` during each pass. #[cfg(test)] - pub(crate) fn from_rendered(view_id: EntityId, child: Box) -> Self { - Self { view_id, child } + pub(crate) fn from_rendered( + view_id: EntityId, + child: Box, + rendered_views: &mut std::collections::HashMap>, + ) -> Self { + rendered_views.insert(view_id, child); + Self { view_id } } -} -impl TuiElement for TuiChildView { - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize { - self.child.layout(constraint) + /// Constructs a bare child-view node for tests — no element pre-inserted. + /// The caller must populate `rendered_views` separately before any pass. + #[cfg(test)] + pub(crate) fn for_view_id(view_id: EntityId) -> Self { + Self { view_id } } +} - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { - self.child.render(area, buffer); +impl TuiElement for TuiChildView { + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> TuiSize { + ctx.use_view(self.view_id, |child, ctx| child.layout(constraint, ctx)) + .unwrap_or_else(|| { + log::warn!("TuiChildView: no element found for {:?}", self.view_id); + TuiSize::ZERO + }) } - fn desired_height(&self, width: u16) -> u16 { - self.child.desired_height(width) + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) { + ctx.use_view(self.view_id, |child, ctx| child.render(area, buffer, ctx)); } - fn cursor_position(&self, area: TuiRect) -> Option<(u16, u16)> { - self.child.cursor_position(area) + fn cursor_position(&self, area: TuiRect, ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> { + ctx.use_view(self.view_id, |child, ctx| child.cursor_position(area, ctx)) + .flatten() } fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { ctx.enter_child(self.view_id); - self.child.present(ctx); + ctx.use_view(self.view_id, |child, ctx| child.present(ctx)); ctx.exit_child(); } @@ -81,13 +90,17 @@ impl TuiElement for TuiChildView { &mut self, event: &Event, area: TuiRect, - ctx: &mut TuiEventContext, + event_ctx: &mut TuiEventContext, + ctx: &mut TuiLayoutContext, app: &AppContext, ) -> bool { - let previous_origin = ctx.set_origin_view(Some(self.view_id)); - let handled = self.child.dispatch_event(event, area, ctx, app); - ctx.set_origin_view(previous_origin); - handled + ctx.use_view(self.view_id, |child, ctx| { + let previous_origin = event_ctx.set_origin_view(Some(self.view_id)); + let handled = child.dispatch_event(event, area, event_ctx, ctx, app); + event_ctx.set_origin_view(previous_origin); + handled + }) + .unwrap_or(false) } } diff --git a/crates/warpui_core/src/elements/tui/child_view_tests.rs b/crates/warpui_core/src/elements/tui/child_view_tests.rs index c51e9378af..a09abe95ac 100644 --- a/crates/warpui_core/src/elements/tui/child_view_tests.rs +++ b/crates/warpui_core/src/elements/tui/child_view_tests.rs @@ -2,38 +2,38 @@ use std::collections::HashMap; use super::TuiChildView; use crate::elements::tui::{ - TuiBuffer, TuiBufferExt, TuiElement, TuiPresentationContext, TuiRect, TuiText, + TuiBuffer, TuiBufferExt, TuiElement, TuiLayoutContext, TuiPresentationContext, TuiRect, TuiText, }; use crate::EntityId; #[test] fn embeds_and_renders_the_stub_at_the_given_area() { - let view = TuiChildView::from_rendered(EntityId::from_usize(1), Box::new(TuiText::new("Z"))); + let mut rendered_views = HashMap::new(); + let view = TuiChildView::from_rendered( + EntityId::from_usize(1), + Box::new(TuiText::new("Z")), + &mut rendered_views, + ); let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, 3, 1)); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; // Render offset one column in: the embedded glyph must land at x = 1. - view.render(TuiRect::new(1, 0, 2, 1), &mut buffer); + view.render(TuiRect::new(1, 0, 2, 1), &mut buffer, &mut ctx); assert_eq!(buffer.to_lines(), vec![" Z "]); } -#[test] -fn delegates_desired_height_to_the_embedded_element() { - let view = TuiChildView::from_rendered( - EntityId::from_usize(1), - Box::new(TuiText::new("AB\nCD").truncate()), - ); - assert_eq!(view.desired_height(2), 2); -} - #[test] fn present_records_the_child_as_a_child_of_the_current_view() { let root = EntityId::from_usize(7); let child = EntityId::from_usize(8); + let mut rendered_views = HashMap::new(); let mut parent_by_child = HashMap::new(); { - let mut ctx = TuiPresentationContext::new(root, &mut parent_by_child); - let mut view = TuiChildView::from_rendered(child, Box::new(())); + let mut ctx = TuiPresentationContext::new(root, &mut rendered_views, &mut parent_by_child); + let mut view = TuiChildView::from_rendered(child, Box::new(()), ctx.rendered_views); view.present(&mut ctx); } @@ -45,12 +45,17 @@ fn present_nests_grandchildren_under_their_immediate_parent() { let root = EntityId::from_usize(1); let child = EntityId::from_usize(2); let grandchild = EntityId::from_usize(3); + let mut rendered_views = HashMap::new(); let mut parent_by_child = HashMap::new(); { - let mut ctx = TuiPresentationContext::new(root, &mut parent_by_child); - let nested = TuiChildView::from_rendered(grandchild, Box::new(())); - let mut view = TuiChildView::from_rendered(child, Box::new(nested)); + let mut ctx = TuiPresentationContext::new(root, &mut rendered_views, &mut parent_by_child); + // grandchild must be in rendered_views so the nested TuiChildView + // node can find it during the present pass. + TuiChildView::from_rendered(grandchild, Box::new(()), ctx.rendered_views); + // The child's element is a TuiChildView that embeds the grandchild. + let nested_child_view = Box::new(TuiChildView::for_view_id(grandchild)); + let mut view = TuiChildView::from_rendered(child, nested_child_view, ctx.rendered_views); view.present(&mut ctx); } diff --git a/crates/warpui_core/src/elements/tui/column.rs b/crates/warpui_core/src/elements/tui/column.rs index a66868750e..51d750e1ff 100644 --- a/crates/warpui_core/src/elements/tui/column.rs +++ b/crates/warpui_core/src/elements/tui/column.rs @@ -17,14 +17,18 @@ //! available height are clipped. use super::{ - TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiPresentationContext, TuiRect, - TuiRectExt, TuiSize, + TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiLayoutContext, + TuiPresentationContext, TuiRect, TuiRectExt, TuiSize, }; use crate::{AppContext, Event}; #[derive(Default)] pub struct TuiColumn { children: Vec>, + /// Sizes returned by each child's `layout()` call; populated during layout + /// so `render`, `cursor_position`, and `dispatch_event` don't need to + /// re-invoke `desired_height` (which has no context). + child_sizes: Vec, } impl TuiColumn { @@ -40,36 +44,52 @@ impl Extend> for TuiColumn { } impl TuiElement for TuiColumn { - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize { + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> TuiSize { let width = constraint.constrain_width(constraint.max.width); let mut total_height: u16 = 0; + self.child_sizes.clear(); for child in &mut self.children { - let child_height = child.desired_height(width); - let child_constraint = - TuiConstraint::new(TuiSize::new(width, 0), TuiSize::new(width, child_height)); - let size = child.layout(child_constraint); + // Use the remaining available height rather than desired_height so + // child views (which have no locally-cached size) get a valid + // budget. The child's layout clamps to its actual content height. + let remaining_height = constraint.max.height.saturating_sub(total_height); + let child_constraint = TuiConstraint::loose(TuiSize::new(width, remaining_height)); + let size = child.layout(child_constraint, ctx); total_height = total_height.saturating_add(size.height); + self.child_sizes.push(size); } TuiSize::new(width, constraint.constrain_height(total_height)) } - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) { let mut remaining = area; - for child in &self.children { + for (child, size) in self.children.iter().zip(&self.child_sizes) { if remaining.is_empty() { break; } - let child_height = child.desired_height(remaining.width); - let (slot, rest) = remaining.split_top(child_height); - child.render(slot, buffer); + let (slot, rest) = remaining.split_top(size.height); + child.render(slot, buffer, ctx); remaining = rest; } } - fn desired_height(&self, width: u16) -> u16 { - self.children.iter().fold(0, |total, child| { - total.saturating_add(child.desired_height(width)) - }) + 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) { + if remaining.is_empty() { + break; + } + let (slot, rest) = remaining.split_top(size.height); + if let Some((cx, cy)) = child.cursor_position(slot, ctx) { + // Offset is relative to the slot, not the full area. + return Some(( + slot.x.saturating_sub(area.x) + cx, + slot.y.saturating_sub(area.y) + cy, + )); + } + remaining = rest; + } + None } fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { @@ -82,20 +102,20 @@ impl TuiElement for TuiColumn { &mut self, event: &Event, area: TuiRect, - ctx: &mut TuiEventContext, + event_ctx: &mut TuiEventContext, + ctx: &mut TuiLayoutContext, app: &AppContext, ) -> bool { - // Offer the event to each child in its rendered slot (mirroring - // `render`'s stacking); the first child to handle it consumes it. - // Children clipped past the available height see no events. + // Offer the event to each child in its rendered slot (mirrors render's + // stacking); the first child to handle it consumes it. Children clipped + // past the available height see no events. let mut remaining = area; - for child in &mut self.children { + for (child, size) in self.children.iter_mut().zip(&self.child_sizes) { if remaining.is_empty() { break; } - let child_height = child.desired_height(remaining.width); - let (slot, rest) = remaining.split_top(child_height); - if child.dispatch_event(event, slot, ctx, app) { + let (slot, rest) = remaining.split_top(size.height); + if child.dispatch_event(event, slot, event_ctx, ctx, app) { return true; } remaining = rest; diff --git a/crates/warpui_core/src/elements/tui/column_tests.rs b/crates/warpui_core/src/elements/tui/column_tests.rs index 1e83c09836..5aef731fdb 100644 --- a/crates/warpui_core/src/elements/tui/column_tests.rs +++ b/crates/warpui_core/src/elements/tui/column_tests.rs @@ -5,7 +5,8 @@ use std::rc::Rc; use super::TuiColumn; use crate::elements::tui::{ TuiBuffer, TuiBufferExt, TuiChildView, TuiConstraint, TuiElement, TuiEventContext, - TuiEventHandler, TuiParentElement, TuiPresentationContext, TuiRect, TuiSize, TuiText, + TuiEventHandler, TuiLayoutContext, TuiParentElement, TuiPresentationContext, TuiRect, TuiSize, + TuiText, }; use crate::event::KeyEventDetails; use crate::keymap::Keystroke; @@ -13,7 +14,15 @@ use crate::{App, EntityId, Event}; fn render_to_lines(element: &dyn TuiElement, size: TuiSize) -> Vec { let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, size.width, size.height)); - element.render(TuiRect::new(0, 0, size.width, size.height), &mut buffer); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + element.render( + TuiRect::new(0, 0, size.width, size.height), + &mut buffer, + &mut ctx, + ); buffer.to_lines() } @@ -23,8 +32,11 @@ fn stacks_two_children_top_to_bottom() { .with_child(Box::new(TuiText::new("AA"))) .with_child(Box::new(TuiText::new("BB"))); - assert_eq!(column.desired_height(2), 2); - let size = column.layout(TuiConstraint::loose(TuiSize::new(2, 10))); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = column.layout(TuiConstraint::loose(TuiSize::new(2, 10)), &mut ctx); assert_eq!(size, TuiSize::new(2, 2)); assert_eq!( @@ -36,12 +48,18 @@ fn stacks_two_children_top_to_bottom() { #[test] fn sums_multi_row_children_at_the_correct_offsets() { // The middle child spans two rows, so the trailing child must land on row 3. - let column = TuiColumn::new() + let mut column = TuiColumn::new() .with_child(Box::new(TuiText::new("A"))) .with_child(Box::new(TuiText::new("BB\nCC").truncate())) .with_child(Box::new(TuiText::new("D"))); - assert_eq!(column.desired_height(2), 4); + // Layout must be called before render so TuiColumn.child_sizes is populated. + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = column.layout(TuiConstraint::loose(TuiSize::new(2, 4)), &mut ctx); + assert_eq!(size, TuiSize::new(2, 4)); assert_eq!( render_to_lines(&column, TuiSize::new(2, 4)), vec!["A ", "BB", "CC", "D "], @@ -55,7 +73,15 @@ fn clamps_total_height_to_the_constraint_and_clips_overflow() { .with_child(Box::new(TuiText::new("BB\nCC").truncate())) .with_child(Box::new(TuiText::new("D"))); - let size = column.layout(TuiConstraint::new(TuiSize::ZERO, TuiSize::new(2, 3))); + // Layout populates child_sizes; render and dispatch rely on them. + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = column.layout( + TuiConstraint::new(TuiSize::ZERO, TuiSize::new(2, 3)), + &mut ctx, + ); assert_eq!(size, TuiSize::new(2, 3)); // Only the first three rows fit; the final child is clipped away. @@ -72,13 +98,17 @@ fn present_recurses_into_children() { let mut parent_by_child = HashMap::new(); { - let mut ctx = TuiPresentationContext::new(root, &mut parent_by_child); + let mut rendered_views_for_child = HashMap::new(); + let mut ctx = + TuiPresentationContext::new(root, &mut rendered_views_for_child, &mut parent_by_child); + let child_node = TuiChildView::from_rendered( + embedded, + Box::new(TuiText::new("body")), + ctx.rendered_views, + ); let mut column = TuiColumn::new() .with_child(Box::new(TuiText::new("header"))) - .with_child(Box::new(TuiChildView::from_rendered( - embedded, - Box::new(TuiText::new("body")), - ))); + .with_child(Box::new(child_node)); column.present(&mut ctx); } @@ -119,11 +149,18 @@ fn dispatch_event_offers_children_in_order_and_stops_when_handled() { }), )); + // Layout must run before dispatch so TuiColumn.child_sizes is populated. let mut event_ctx = TuiEventContext::default(); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + column.layout(TuiConstraint::loose(TuiSize::new(10, 5)), &mut ctx); let handled = column.dispatch_event( &key_event("x"), TuiRect::new(0, 0, 10, 5), &mut event_ctx, + &mut ctx, app_ctx, ); diff --git a/crates/warpui_core/src/elements/tui/container.rs b/crates/warpui_core/src/elements/tui/container.rs index cc6c791eb4..2862c4dc2f 100644 --- a/crates/warpui_core/src/elements/tui/container.rs +++ b/crates/warpui_core/src/elements/tui/container.rs @@ -20,8 +20,8 @@ use ratatui::style::Color; use super::{ - TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiPresentationContext, TuiRect, - TuiRectExt, TuiSize, TuiStyle, + TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiLayoutContext, + TuiPresentationContext, TuiRect, TuiRectExt, TuiSize, TuiStyle, }; use crate::{AppContext, Event}; @@ -82,13 +82,13 @@ impl TuiContainer { } impl TuiElement for TuiContainer { - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize { + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> TuiSize { let total = self.inset().saturating_mul(2); let inner_max = TuiSize::new( constraint.max.width.saturating_sub(total), constraint.max.height.saturating_sub(total), ); - let inner = self.child.layout(TuiConstraint::loose(inner_max)); + let inner = self.child.layout(TuiConstraint::loose(inner_max), ctx); let size = TuiSize::new( inner.width.saturating_add(total), inner.height.saturating_add(total), @@ -96,7 +96,7 @@ impl TuiElement for TuiContainer { constraint.clamp(size) } - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) { if area.is_empty() { return; } @@ -109,31 +109,30 @@ impl TuiElement for TuiContainer { draw_border(area, buffer, self.painted_border_style()); } - self.child.render(area.inset(self.inset()), buffer); - } - - fn desired_height(&self, width: u16) -> u16 { - let total = self.inset().saturating_mul(2); - let inner_width = width.saturating_sub(total); - self.child.desired_height(inner_width).saturating_add(total) + self.child.render(area.inset(self.inset()), buffer, ctx); } fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { self.child.present(ctx); } + fn cursor_position(&self, area: TuiRect, ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> { + self.child.cursor_position(area.inset(self.inset()), ctx) + } + fn dispatch_event( &mut self, event: &Event, area: TuiRect, - ctx: &mut TuiEventContext, + event_ctx: &mut TuiEventContext, + ctx: &mut TuiLayoutContext, app: &AppContext, ) -> bool { if area.is_empty() { return false; } self.child - .dispatch_event(event, area.inset(self.inset()), ctx, app) + .dispatch_event(event, area.inset(self.inset()), event_ctx, ctx, app) } } diff --git a/crates/warpui_core/src/elements/tui/container_tests.rs b/crates/warpui_core/src/elements/tui/container_tests.rs index c99874b203..4866bcc0c7 100644 --- a/crates/warpui_core/src/elements/tui/container_tests.rs +++ b/crates/warpui_core/src/elements/tui/container_tests.rs @@ -7,7 +7,7 @@ use ratatui::style::Color; use super::TuiContainer; use crate::elements::tui::{ TuiBuffer, TuiBufferExt, TuiChildView, TuiConstraint, TuiElement, TuiEventContext, - TuiEventHandler, TuiPresentationContext, TuiRect, TuiSize, TuiText, + TuiEventHandler, TuiLayoutContext, TuiPresentationContext, TuiRect, TuiSize, TuiText, }; use crate::event::KeyEventDetails; use crate::keymap::Keystroke; @@ -15,14 +15,21 @@ use crate::{App, EntityId, Event}; fn render_to_lines(element: &dyn TuiElement, size: TuiSize) -> Vec { let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, size.width, size.height)); - element.render(TuiRect::new(0, 0, size.width, size.height), &mut buffer); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + element.render( + TuiRect::new(0, 0, size.width, size.height), + &mut buffer, + &mut ctx, + ); buffer.to_lines() } #[test] fn padding_offsets_the_child() { let container = TuiContainer::new(TuiText::new("X")).with_padding(1); - assert_eq!(container.desired_height(3), 3); assert_eq!( render_to_lines(&container, TuiSize::new(3, 3)), vec![" ", " X ", " "], @@ -45,7 +52,11 @@ fn border_and_padding_compose() { .with_padding(1); // Child inset by 2 (border + padding) on each side: 1x1 child -> 5x5 total. - let size = container.layout(TuiConstraint::loose(TuiSize::new(20, 20))); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = container.layout(TuiConstraint::loose(TuiSize::new(20, 20)), &mut ctx); assert_eq!(size, TuiSize::new(5, 5)); assert_eq!( @@ -61,7 +72,11 @@ fn background_fills_the_padding_area() { .with_background(Color::Blue); let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, 3, 3)); - container.render(TuiRect::new(0, 0, 3, 3), &mut buffer); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + container.render(TuiRect::new(0, 0, 3, 3), &mut buffer, &mut ctx); // A padding cell carries the background fill... assert_eq!(buffer[(0, 0)].bg, Color::Blue); @@ -76,9 +91,10 @@ fn present_recurses_into_the_child() { let mut parent_by_child = HashMap::new(); { - let mut ctx = TuiPresentationContext::new(root, &mut parent_by_child); - let mut container = - TuiContainer::new(TuiChildView::from_rendered(embedded, Box::new(()))).with_border(); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiPresentationContext::new(root, &mut rendered_views, &mut parent_by_child); + let child_node = TuiChildView::from_rendered(embedded, Box::new(()), ctx.rendered_views); + let mut container = TuiContainer::new(child_node).with_border(); container.present(&mut ctx); } @@ -108,8 +124,17 @@ fn dispatch_event_forwards_to_the_child_inside_the_inset() { is_composing: false, }; let mut event_ctx = TuiEventContext::default(); - let handled = - container.dispatch_event(&event, TuiRect::new(0, 0, 9, 9), &mut event_ctx, app_ctx); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let handled = container.dispatch_event( + &event, + TuiRect::new(0, 0, 9, 9), + &mut event_ctx, + &mut ctx, + app_ctx, + ); assert!(handled); assert_eq!(hits.get(), 1); diff --git a/crates/warpui_core/src/elements/tui/event.rs b/crates/warpui_core/src/elements/tui/event.rs index 15bd34c617..32f5023eef 100644 --- a/crates/warpui_core/src/elements/tui/event.rs +++ b/crates/warpui_core/src/elements/tui/event.rs @@ -38,7 +38,9 @@ pub struct TuiEventContext { origin_view_id: Option, } -#[allow(dead_code)] +/// A typed action queued during element-tree dispatch, attributed to the view +/// whose subtree raised it. Drained by the runtime, which dispatches it +/// through the shared responder chain rooted at the origin view. pub(crate) struct TuiDispatchedAction { pub(crate) origin_view_id: EntityId, pub(crate) action: Box, @@ -58,12 +60,10 @@ impl TuiEventContext { }); } - #[allow(dead_code)] pub(crate) fn take_updates(&mut self) -> Vec { std::mem::take(&mut self.updates) } - #[allow(dead_code)] pub(crate) fn take_typed_actions(&mut self) -> Vec { std::mem::take(&mut self.typed_actions) } @@ -71,7 +71,6 @@ impl TuiEventContext { /// Sets the view that subsequently dispatched actions are attributed to, /// returning the previous origin so callers can restore it when leaving the /// view's subtree. - #[allow(dead_code)] pub(crate) fn set_origin_view(&mut self, view_id: Option) -> Option { std::mem::replace(&mut self.origin_view_id, view_id) } diff --git a/crates/warpui_core/src/elements/tui/event_handler.rs b/crates/warpui_core/src/elements/tui/event_handler.rs index e00a6df1a4..9c5f30fb94 100644 --- a/crates/warpui_core/src/elements/tui/event_handler.rs +++ b/crates/warpui_core/src/elements/tui/event_handler.rs @@ -17,7 +17,8 @@ //! ancestors can react. use super::{ - TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiPresentationContext, TuiRect, TuiSize, + TuiBuffer, TuiConstraint, TuiElement, TuiEventContext, TuiLayoutContext, + TuiPresentationContext, TuiRect, TuiSize, }; use crate::{AppContext, Event}; @@ -57,20 +58,16 @@ impl TuiEventHandler { } impl TuiElement for TuiEventHandler { - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize { - self.child.layout(constraint) + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> TuiSize { + self.child.layout(constraint, ctx) } - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { - self.child.render(area, buffer); + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) { + self.child.render(area, buffer, ctx); } - fn desired_height(&self, width: u16) -> u16 { - self.child.desired_height(width) - } - - fn cursor_position(&self, area: TuiRect) -> Option<(u16, u16)> { - self.child.cursor_position(area) + fn cursor_position(&self, area: TuiRect, ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> { + self.child.cursor_position(area, ctx) } fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { @@ -81,17 +78,18 @@ impl TuiElement for TuiEventHandler { &mut self, event: &Event, area: TuiRect, - ctx: &mut TuiEventContext, + event_ctx: &mut TuiEventContext, + ctx: &mut TuiLayoutContext, app: &AppContext, ) -> bool { - if self.child.dispatch_event(event, area, ctx, app) { + if self.child.dispatch_event(event, area, event_ctx, ctx, app) { return true; } if let Event::KeyDown { keystroke, .. } = event { for binding in &mut self.bindings { if binding.key == keystroke.key { - (binding.callback)(event, ctx, app); + (binding.callback)(event, event_ctx, app); return true; } } diff --git a/crates/warpui_core/src/elements/tui/event_handler_tests.rs b/crates/warpui_core/src/elements/tui/event_handler_tests.rs index 44383a51b8..1d20e88faf 100644 --- a/crates/warpui_core/src/elements/tui/event_handler_tests.rs +++ b/crates/warpui_core/src/elements/tui/event_handler_tests.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use super::TuiEventHandler; use crate::elements::tui::{ - TuiChildView, TuiElement, TuiEventContext, TuiPresentationContext, TuiRect, + TuiChildView, TuiElement, TuiEventContext, TuiLayoutContext, TuiPresentationContext, TuiRect, }; use crate::event::KeyEventDetails; use crate::keymap::Keystroke; @@ -35,14 +35,24 @@ fn invokes_callback_on_matching_key_and_reports_handled() { let area = TuiRect::new(0, 0, 4, 1); let mut event_ctx = TuiEventContext::default(); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; - let handled = - handler.dispatch_event(&key_event("enter"), area, &mut event_ctx, app_ctx); + let handled = handler.dispatch_event( + &key_event("enter"), + area, + &mut event_ctx, + &mut ctx, + app_ctx, + ); assert!(handled); assert_eq!(hits.get(), 1); // A non-matching key is left unhandled for ancestors, runs no callback. - let handled = handler.dispatch_event(&key_event("esc"), area, &mut event_ctx, app_ctx); + let handled = + handler.dispatch_event(&key_event("esc"), area, &mut event_ctx, &mut ctx, app_ctx); assert!(!handled); assert_eq!(hits.get(), 1); }); @@ -66,10 +76,15 @@ fn child_consumes_the_event_before_the_wrapper() { }); let mut event_ctx = TuiEventContext::default(); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; let handled = outer.dispatch_event( &key_event("enter"), TuiRect::new(0, 0, 1, 1), &mut event_ctx, + &mut ctx, app_ctx, ); @@ -87,8 +102,10 @@ fn present_recurses_into_the_wrapped_child() { let mut parent_by_child = HashMap::new(); { - let mut ctx = TuiPresentationContext::new(root, &mut parent_by_child); - let mut handler = TuiEventHandler::new(TuiChildView::from_rendered(embedded, Box::new(()))); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiPresentationContext::new(root, &mut rendered_views, &mut parent_by_child); + let child_node = TuiChildView::from_rendered(embedded, Box::new(()), ctx.rendered_views); + let mut handler = TuiEventHandler::new(child_node); handler.present(&mut ctx); } diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 16b6241829..72f4d266ac 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -35,7 +35,7 @@ mod geometry; mod parent; mod text; -pub use buffer::{Cell, TuiBuffer, TuiBufferExt, TuiStyle}; +pub use buffer::{Cell, Color, Modifier, TuiBuffer, TuiBufferExt, TuiStyle}; pub use child_view::TuiChildView; pub use column::TuiColumn; pub use container::TuiContainer; @@ -45,17 +45,47 @@ pub use geometry::{TuiConstraint, TuiRect, TuiRectExt, TuiSize}; pub use parent::TuiParentElement; pub use text::TuiText; +/// Carries the pre-rendered per-view element map through the layout pass, +/// mirroring the GUI's `LayoutContext`. [`TuiChildView`] uses it to look up +/// its child element (freshly rendered by [`TuiPresenter::invalidate`] if +/// the child was updated, or cached from the previous frame otherwise). +/// +/// [`TuiChildView`]: crate::elements::tui::TuiChildView +/// [`TuiPresenter::invalidate`]: crate::presenter::tui::TuiPresenter::invalidate +pub struct TuiLayoutContext<'a> { + /// Pre-rendered elements keyed by view id, consumed during layout. + pub rendered_views: &'a mut HashMap>, +} + +impl<'a> TuiLayoutContext<'a> { + /// Temporarily removes the element for `view_id` from `rendered_views`, + /// passes it (along with `self`) to `f`, then returns it. Mirrors the + /// GUI's `LayoutContext::layout` / `PaintContext::paint` / + /// `EventContext::dispatch_event_on_view` pattern. Returns the value + /// produced by `f`, or `None` if no element was registered for `view_id`. + pub(crate) fn use_view( + &mut self, + view_id: EntityId, + f: impl FnOnce(&mut Box, &mut Self) -> R, + ) -> Option { + let mut element = self.rendered_views.remove(&view_id)?; + let result = f(&mut element, self); + self.rendered_views.insert(view_id, element); + Some(result) + } +} + /// A node in the renderable tree: it measures itself against a constraint, /// then paints into a sub-rectangle of the buffer. /// -/// - [`layout`](TuiElement::layout): measure against a [`TuiConstraint`], -/// returning a [`TuiSize`] within it (see [`TuiConstraint::clamp`]). +/// - [`layout`](TuiElement::layout): measure against a [`TuiConstraint`] and +/// [`TuiLayoutContext`], returning a [`TuiSize`] within the constraint (see +/// [`TuiConstraint::clamp`]). The context carries the presenter's +/// pre-rendered view map so [`TuiChildView`](crate::elements::tui::TuiChildView) +/// can retrieve its child element. /// - [`render`](TuiElement::render): paint into `area` of `buffer`. `area` is /// the rect the parent allocated (its size is the value `layout` returned, /// clamped to what was available). -/// - [`desired_height`](TuiElement::desired_height): the height this element -/// wants at a given width, used by stacking containers before they have a -/// final height budget. /// - [`cursor_position`](TuiElement::cursor_position): where a text cursor /// should sit within `area`, if any (default: none). /// - [`present`](TuiElement::present): participate in the child-view recursion @@ -65,20 +95,23 @@ pub use text::TuiText; /// element, returning whether it was handled (default: not handled). pub trait TuiElement { /// Measures this element against `constraint`, returning the size it will - /// occupy (which must lie within `constraint`). - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize; + /// occupy (which must lie within `constraint`). `ctx` carries the + /// presenter's pre-rendered view map for child-view lookup. + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> TuiSize; - /// Paints this element into `area` of `buffer`. Implementations must confine - /// their writes to `area`; the buffer clips anything outside its own bounds - /// but does not clip to `area`. - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer); - - /// The height this element wants when laid out at `width` columns. - fn desired_height(&self, width: u16) -> u16; + /// Paints this element into `area` of `buffer`. `ctx` carries the + /// presenter's pre-rendered view map so [`TuiChildView`] can look up and + /// render its child element without caching it locally. + /// + /// [`TuiChildView`]: crate::elements::tui::TuiChildView + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext); /// The `(x, y)` cell, within `area`, where the terminal cursor should be - /// placed for this element, if it owns the cursor. - fn cursor_position(&self, _area: TuiRect) -> Option<(u16, u16)> { + /// placed for this element, if it owns the cursor. `ctx` is passed through + /// so [`TuiChildView`] can delegate to its child without caching it. + /// + /// [`TuiChildView`]: crate::elements::tui::TuiChildView + fn cursor_position(&self, _area: TuiRect, _ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> { None } @@ -88,13 +121,18 @@ pub trait TuiElement { fn present(&mut self, _ctx: &mut TuiPresentationContext<'_>) {} /// Offers `event` to this element within `area`, returning `true` if it was - /// handled. `ctx` collects deferred app updates and typed actions; `app` - /// provides read access to the shared core during dispatch. + /// handled. `event_ctx` collects deferred app updates and typed actions; + /// `ctx` carries the presenter's pre-rendered view map so [`TuiChildView`] + /// can look up and dispatch into its child; `app` provides read access to + /// the shared core during dispatch. + /// + /// [`TuiChildView`]: crate::elements::tui::TuiChildView fn dispatch_event( &mut self, _event: &Event, _area: TuiRect, - _ctx: &mut TuiEventContext, + _event_ctx: &mut TuiEventContext, + _ctx: &mut TuiLayoutContext, _app: &AppContext, ) -> bool { false @@ -105,15 +143,11 @@ pub trait TuiElement { /// as a placeholder child where the element's own rendering is irrelevant. #[cfg(test)] impl TuiElement for () { - fn layout(&mut self, _constraint: TuiConstraint) -> TuiSize { + fn layout(&mut self, _constraint: TuiConstraint, _ctx: &mut TuiLayoutContext) -> TuiSize { TuiSize::ZERO } - fn render(&self, _area: TuiRect, _buffer: &mut TuiBuffer) {} - - fn desired_height(&self, _width: u16) -> u16 { - 0 - } + fn render(&self, _area: TuiRect, _buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {} } /// Threads the current view ancestry through the element tree during the @@ -126,18 +160,19 @@ impl TuiElement for () { /// [`AppContext::report_view_embeddings`]. pub struct TuiPresentationContext<'a> { parent_by_child: &'a mut HashMap, + pub(crate) rendered_views: &'a mut HashMap>, view_stack: Vec, } impl<'a> TuiPresentationContext<'a> { - // Constructed by the TUI presenter (slice 03c); dead until then. - #[allow(dead_code)] pub(crate) fn new( root_view_id: EntityId, + rendered_views: &'a mut HashMap>, parent_by_child: &'a mut HashMap, ) -> Self { Self { parent_by_child, + rendered_views, view_stack: vec![root_view_id], } } @@ -159,4 +194,19 @@ impl<'a> TuiPresentationContext<'a> { .pop() .expect("a child view is entered before it is exited"); } + + /// Temporarily removes the element for `view_id` from `rendered_views`, + /// passes it (along with `self`) to `f`, then returns it — the same + /// move-in/move-out pattern the GUI's `EventContext::dispatch_event_on_view` + /// uses. Returns `None` if no element is registered for `view_id`. + pub(crate) fn use_view( + &mut self, + view_id: EntityId, + f: impl FnOnce(&mut Box, &mut Self) -> R, + ) -> Option { + let mut element = self.rendered_views.remove(&view_id)?; + let result = f(&mut element, self); + self.rendered_views.insert(view_id, element); + Some(result) + } } diff --git a/crates/warpui_core/src/elements/tui/text.rs b/crates/warpui_core/src/elements/tui/text.rs index b724a82972..34c8e7fdbd 100644 --- a/crates/warpui_core/src/elements/tui/text.rs +++ b/crates/warpui_core/src/elements/tui/text.rs @@ -23,7 +23,7 @@ use ratatui::widgets::{Paragraph, Widget, Wrap}; -use super::{TuiBuffer, TuiConstraint, TuiElement, TuiRect, TuiSize, TuiStyle}; +use super::{TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiRect, TuiSize, TuiStyle}; pub struct TuiText { text: String, @@ -52,6 +52,15 @@ impl TuiText { self } + /// The number of terminal rows this text occupies when laid out at `width` + /// columns. Matches what `layout` would return as the height component. + pub fn desired_height(&self, width: u16) -> u16 { + if self.text.is_empty() { + return 0; + } + u16::try_from(self.paragraph().line_count(width)).unwrap_or(u16::MAX) + } + /// The ratatui `Paragraph` backing this element's measure and paint. fn paragraph(&self) -> Paragraph<'_> { let paragraph = Paragraph::new(self.text.as_str()).style(self.style); @@ -64,7 +73,7 @@ impl TuiText { } impl TuiElement for TuiText { - fn layout(&mut self, constraint: TuiConstraint) -> TuiSize { + fn layout(&mut self, constraint: TuiConstraint, _ctx: &mut TuiLayoutContext) -> TuiSize { if self.text.is_empty() { return constraint.clamp(TuiSize::ZERO); } @@ -77,19 +86,12 @@ impl TuiElement for TuiText { ) } - fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) { if area.is_empty() { return; } Widget::render(self.paragraph(), area, buffer); } - - fn desired_height(&self, width: u16) -> u16 { - if self.text.is_empty() { - return 0; - } - u16::try_from(self.paragraph().line_count(width)).unwrap_or(u16::MAX) - } } #[cfg(test)] diff --git a/crates/warpui_core/src/elements/tui/text_tests.rs b/crates/warpui_core/src/elements/tui/text_tests.rs index f03c453bb5..cb54765422 100644 --- a/crates/warpui_core/src/elements/tui/text_tests.rs +++ b/crates/warpui_core/src/elements/tui/text_tests.rs @@ -1,11 +1,23 @@ +use std::collections::HashMap; + use ratatui::style::{Color, Modifier, Style}; use super::TuiText; -use crate::elements::tui::{TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiRect, TuiSize}; +use crate::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiLayoutContext, TuiRect, TuiSize, +}; fn render_to_lines(element: &dyn TuiElement, size: TuiSize) -> Vec { let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, size.width, size.height)); - element.render(TuiRect::new(0, 0, size.width, size.height), &mut buffer); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + element.render( + TuiRect::new(0, 0, size.width, size.height), + &mut buffer, + &mut ctx, + ); buffer.to_lines() } @@ -21,7 +33,11 @@ fn renders_a_single_short_line() { #[test] fn layout_reports_content_width_and_row_count() { let mut text = TuiText::new("hello world foo"); - let size = text.layout(TuiConstraint::loose(TuiSize::new(11, 10))); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = text.layout(TuiConstraint::loose(TuiSize::new(11, 10)), &mut ctx); // "hello world" packs onto row 1 (11 cols), "foo" wraps to row 2. assert_eq!(size, TuiSize::new(11, 2)); assert_eq!(text.desired_height(11), 2); @@ -75,7 +91,11 @@ fn applies_its_style_to_painted_cells() { let text = TuiText::new("a").with_style(style); let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, 1, 1)); - text.render(TuiRect::new(0, 0, 1, 1), &mut buffer); + let mut rendered_views = HashMap::new(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + text.render(TuiRect::new(0, 0, 1, 1), &mut buffer, &mut ctx); let cell = &buffer[(0, 0)]; assert_eq!(cell.symbol(), "a"); diff --git a/crates/warpui_core/src/lib.rs b/crates/warpui_core/src/lib.rs index 71ca597930..0f0827885d 100644 --- a/crates/warpui_core/src/lib.rs +++ b/crates/warpui_core/src/lib.rs @@ -22,6 +22,8 @@ pub mod platform; pub mod prelude; pub mod presenter; pub mod rendering; +#[cfg(feature = "tui")] +pub mod runtime; pub mod scene; pub mod telemetry; #[cfg(test)] diff --git a/crates/warpui_core/src/presenter.rs b/crates/warpui_core/src/presenter.rs index 63718e12fa..2b5323c83e 100644 --- a/crates/warpui_core/src/presenter.rs +++ b/crates/warpui_core/src/presenter.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "tui")] +pub mod tui; + use std::any::Any; use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; diff --git a/crates/warpui_core/src/presenter/tui.rs b/crates/warpui_core/src/presenter/tui.rs new file mode 100644 index 0000000000..84ea4fdd82 --- /dev/null +++ b/crates/warpui_core/src/presenter/tui.rs @@ -0,0 +1,254 @@ +//! 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 std::collections::HashMap; + +use crate::elements::tui::{ + TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiPresentationContext, TuiRect, +}; +use crate::{AppContext, EntityId, 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: HashMap>, + /// 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>, +} + +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( + &mut self, + ctx: &mut AppContext, + root: &ViewHandle, + 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); + + let mut embeddings = HashMap::new(); + { + 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, area: TuiRect) -> TuiFrame { + let mut empty_views = HashMap::new(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut empty_views, + }; + let arranged = arrange(root.as_mut(), area, &mut layout_ctx); + 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> { + 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) -> TuiRect { + let measured = root.layout(TuiConstraint::loose(area.as_size()), ctx); + 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 HashMap>, +) -> 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; diff --git a/crates/warpui_core/src/presenter/tui_tests.rs b/crates/warpui_core/src/presenter/tui_tests.rs new file mode 100644 index 0000000000..fb8aa38207 --- /dev/null +++ b/crates/warpui_core/src/presenter/tui_tests.rs @@ -0,0 +1,402 @@ +//! 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) -> 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>, + child_sizes: Vec, +} + +impl ColumnDouble { + fn new(children: Vec>) -> Self { + Self { + children, + child_sizes: Vec::new(), + } + } +} + +impl TuiElement for ColumnDouble { + fn layout(&mut self, constraint: TuiConstraint, ctx: &mut TuiLayoutContext) -> 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); + 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, + padding: u16, + fill: char, + child_size: TuiSize, +} + +impl ContainerDouble { + fn new(child: Box, 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) -> 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); + 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) -> 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 { + 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 { + 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, + 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 { + 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) { + if matches!(focus_ctx, FocusContext::DescendentFocused(_)) { + self.descendent_focus_events += 1; + } + } +} + +// --- Tests ---------------------------------------------------------------- + +#[test] +fn paints_single_root_element_into_area() { + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + Box::new(TextDouble::new("HELLO")), + TuiRect::new(0, 0, 10, 1), + ); + assert_eq!(frame.buffer.to_lines(), vec!["HELLO "]); + assert_eq!(frame.cursor, None); +} + +#[test] +fn composites_nested_container_column_text_with_offsets() { + 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)); + + assert_eq!( + frame.buffer.to_lines(), + vec![".....", ".AB..", ".CDE.", "....."], + ); +} + +#[test] +fn surfaces_cursor_at_absolute_coordinates() { + 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)); + + // 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); + }); +} diff --git a/crates/warpui_core/src/runtime/event_conversion.rs b/crates/warpui_core/src/runtime/event_conversion.rs new file mode 100644 index 0000000000..b7cdb5e8c7 --- /dev/null +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -0,0 +1,107 @@ +//! Conversion from raw crossterm input events to the shared +//! [`Event`](crate::Event) vocabulary, so TUI element/view dispatch is +//! identical to the GUI's. + +use ratatui::crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; + +use crate::event::KeyEventDetails; +use crate::keymap::Keystroke; +use crate::Event; + +/// Converts a raw crossterm event into the shared [`Event`] vocabulary, or +/// `None` if the event has no warp equivalent. +pub fn crossterm_event_to_warp_event(event: CrosstermEvent) -> Option { + match event { + CrosstermEvent::Key(key_event) => key_event_to_warp_event(key_event), + // TODO: Mouse events are not converted yet. TUI coordinates are integer + // cell (row, column) pairs that need a dedicated representation before + // they can be mapped into Warp's float-pixel Event system. + CrosstermEvent::Mouse(_) => None, + // TODO: FocusGained, FocusLost, and Paste have no Warp equivalents yet. + // If these are needed in the future, consider adding matching Warp events. + CrosstermEvent::FocusGained + | CrosstermEvent::FocusLost + | CrosstermEvent::Paste(_) + | CrosstermEvent::Resize(_, _) => None, + } +} + +fn key_event_to_warp_event(event: KeyEvent) -> Option { + // Only key presses map to a warp `KeyDown`; repeats/releases are ignored so + // dispatch matches the GUI's press-driven keystroke model. + if event.kind != KeyEventKind::Press { + return None; + } + + let key = key_name(event.code, event.modifiers)?; + let chars = match event.code { + KeyCode::Char(char) => char.to_string(), + _ => String::new(), + }; + + Some(Event::KeyDown { + keystroke: Keystroke { + ctrl: event.modifiers.contains(KeyModifiers::CONTROL), + alt: event.modifiers.contains(KeyModifiers::ALT), + shift: event.modifiers.contains(KeyModifiers::SHIFT), + cmd: event.modifiers.contains(KeyModifiers::SUPER), + meta: event.modifiers.contains(KeyModifiers::META), + key, + }, + chars, + details: KeyEventDetails { + key_without_modifiers: key_without_modifiers(event.code), + ..Default::default() + }, + is_composing: false, + }) +} + +/// The warp keystroke `key` name for a crossterm key code, or `None` for keys +/// with no warp equivalent (pure modifiers, lock keys, media keys, etc.). +fn key_name(code: KeyCode, modifiers: KeyModifiers) -> Option { + match code { + KeyCode::Backspace => Some("backspace".to_owned()), + KeyCode::Enter => Some("enter".to_owned()), + KeyCode::Left => Some("left".to_owned()), + KeyCode::Right => Some("right".to_owned()), + KeyCode::Up => Some("up".to_owned()), + KeyCode::Down => Some("down".to_owned()), + KeyCode::Home => Some("home".to_owned()), + KeyCode::End => Some("end".to_owned()), + KeyCode::PageUp => Some("pageup".to_owned()), + KeyCode::PageDown => Some("pagedown".to_owned()), + KeyCode::Tab | KeyCode::BackTab => Some("\t".to_owned()), + KeyCode::Delete => Some("delete".to_owned()), + KeyCode::Insert => Some("insert".to_owned()), + KeyCode::Esc => Some("escape".to_owned()), + KeyCode::F(number) if number <= 20 => Some(format!("f{number}")), + KeyCode::Char(' ') => Some(" ".to_owned()), + KeyCode::Char(char) if modifiers.contains(KeyModifiers::SHIFT) => Some(char.to_string()), + KeyCode::Char(char) => Some(char.to_lowercase().to_string()), + KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) + | KeyCode::F(_) => None, + } +} + +fn key_without_modifiers(code: KeyCode) -> Option { + match code { + KeyCode::Char(char) => Some(char.to_lowercase().to_string()), + _ => None, + } +} + +#[cfg(test)] +#[path = "event_conversion_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/runtime/event_conversion_tests.rs b/crates/warpui_core/src/runtime/event_conversion_tests.rs new file mode 100644 index 0000000000..688945c9a2 --- /dev/null +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -0,0 +1,87 @@ +use ratatui::crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; + +use super::crossterm_event_to_warp_event; +use crate::keymap::Keystroke; +use crate::Event; + +fn key(code: KeyCode, modifiers: KeyModifiers) -> Option { + crossterm_event_to_warp_event(CrosstermEvent::Key(KeyEvent::new(code, modifiers))) +} + +fn keystroke(code: KeyCode, modifiers: KeyModifiers) -> Keystroke { + match key(code, modifiers) { + Some(Event::KeyDown { keystroke, .. }) => keystroke, + other => panic!("expected a KeyDown, got {other:?}"), + } +} + +#[test] +fn printable_char_maps_to_lowercase_key_and_chars() { + let Some(Event::KeyDown { + keystroke, chars, .. + }) = key(KeyCode::Char('a'), KeyModifiers::empty()) + else { + panic!("expected KeyDown"); + }; + assert_eq!(keystroke.key, "a"); + assert_eq!(chars, "a"); + assert!(!keystroke.ctrl && !keystroke.alt && !keystroke.shift); +} + +#[test] +fn enter_and_escape_map_to_named_keys() { + assert_eq!( + keystroke(KeyCode::Enter, KeyModifiers::empty()).key, + "enter" + ); + assert_eq!(keystroke(KeyCode::Esc, KeyModifiers::empty()).key, "escape"); +} + +#[test] +fn arrow_keys_map_to_direction_names() { + assert_eq!(keystroke(KeyCode::Left, KeyModifiers::empty()).key, "left"); + assert_eq!( + keystroke(KeyCode::Right, KeyModifiers::empty()).key, + "right" + ); + assert_eq!(keystroke(KeyCode::Up, KeyModifiers::empty()).key, "up"); + assert_eq!(keystroke(KeyCode::Down, KeyModifiers::empty()).key, "down"); +} + +#[test] +fn ctrl_modifier_is_carried_into_keystroke() { + let keystroke = keystroke(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert!(keystroke.ctrl, "ctrl modifier should be set"); + assert_eq!(keystroke.key, "c"); +} + +#[test] +fn shifted_char_preserves_case() { + let keystroke = keystroke(KeyCode::Char('A'), KeyModifiers::SHIFT); + assert!(keystroke.shift); + assert_eq!(keystroke.key, "A"); +} + +#[test] +fn non_press_key_events_are_ignored() { + let mut event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); + event.kind = KeyEventKind::Release; + assert!(crossterm_event_to_warp_event(CrosstermEvent::Key(event)).is_none()); +} + +#[test] +fn pure_modifier_keys_have_no_warp_equivalent() { + let event = KeyEvent::new( + KeyCode::Modifier(ratatui::crossterm::event::ModifierKeyCode::LeftControl), + KeyModifiers::empty(), + ); + assert!(crossterm_event_to_warp_event(CrosstermEvent::Key(event)).is_none()); +} + +#[test] +fn resize_and_focus_events_are_ignored() { + assert!(crossterm_event_to_warp_event(CrosstermEvent::Resize(80, 24)).is_none()); + assert!(crossterm_event_to_warp_event(CrosstermEvent::FocusGained).is_none()); +} diff --git a/crates/warpui_core/src/runtime/mod.rs b/crates/warpui_core/src/runtime/mod.rs new file mode 100644 index 0000000000..f7d5cd2a8d --- /dev/null +++ b/crates/warpui_core/src/runtime/mod.rs @@ -0,0 +1,334 @@ +//! The TUI runtime, additive behind the `tui` feature: the alternate-screen +//! lifecycle and the draw + event loop that drives a [`TuiView`] through the +//! shared [`App`]. +//! +//! Placement: the GUI has no in-core analog of this module — its runtime is +//! the platform event loop in the `warpui` crate — so the TUI runtime stands +//! alone as an additive top-level module rather than a backend submodule of an +//! existing one. +//! +//! [`TuiRuntime`] mirrors the GUI's invalidate→redraw flow. On +//! [`enter`](TuiRuntime::enter) it puts the host terminal into raw mode + the +//! alternate screen (restored on drop) and subscribes to the window's +//! invalidation signal; [`run_until`](TuiRuntime::run_until) then repeatedly +//! redraws when dirty and polls crossterm for input, converting each event with +//! [`crossterm_event_to_warp_event`] and dispatching it — first through the +//! shared keymap (the focused view's responder chain, exactly like the GUI +//! window event path), then through the rendered element tree. +//! +//! The host terminal is abstracted behind [`TuiTerminal`] so the loop and the +//! frame renderer can be exercised headlessly against an in-memory writer +//! without a real tty. The concrete [`CrosstermTerminal`] is the production +//! implementation. + +use std::cell::Cell; +use std::io::{self, stdout, Stdout, Write}; +use std::rc::Rc; +use std::time::Duration; + +use ratatui::crossterm::cursor::{Hide, Show}; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, +}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; + +use crate::elements::tui::{TuiEventContext, TuiLayoutContext, TuiRect, TuiSize}; +use crate::presenter::tui::TuiPresenter; +use crate::{App, Event, TuiView, ViewHandle, WindowId}; + +mod event_conversion; +mod renderer; + +pub use event_conversion::crossterm_event_to_warp_event; +pub use renderer::TuiFrameRenderer; + +/// The host terminal the runtime draws to and reads input from. Abstracted so +/// the draw + event loop is testable against an in-memory target. +pub trait TuiTerminal { + /// The current terminal size in cells (each axis at least 1). + fn size(&self) -> io::Result; + + /// Blocks up to `timeout` for the next input event, returning `None` on + /// timeout. + fn poll_event(&mut self, timeout: Duration) -> io::Result>; + + /// The writer the renderer flushes frames to. + fn writer(&mut self) -> &mut dyn Write; +} + +/// Drives a single [`TuiView`] window: redraws it when invalidated and routes +/// input events back through the shared core. +pub struct TuiRuntime +where + R: TuiTerminal, +{ + window_id: WindowId, + root_view: ViewHandle, + presenter: TuiPresenter, + renderer: TuiFrameRenderer, + terminal: R, + dirty: Rc>, + last_size: Option, +} + +impl TuiRuntime +where + T: TuiView, +{ + /// Enters the alternate screen + raw mode and prepares to drive `root_view`. + /// The terminal is restored when the returned runtime is dropped. + pub fn enter(app: &App, window_id: WindowId, root_view: ViewHandle) -> io::Result { + let terminal = CrosstermTerminal::enter()?; + Ok(Self::with_terminal(app, window_id, root_view, terminal)) + } +} + +impl TuiRuntime +where + T: TuiView, + R: TuiTerminal, +{ + /// Builds a runtime over an arbitrary [`TuiTerminal`]. Subscribes to the + /// window's invalidation signal so a `notify` schedules a redraw, and marks + /// the runtime dirty so the first loop iteration paints. + pub fn with_terminal( + app: &App, + window_id: WindowId, + root_view: ViewHandle, + terminal: R, + ) -> Self { + let dirty = Rc::new(Cell::new(true)); + let dirty_for_callback = dirty.clone(); + app.on_window_invalidated(window_id, move |_, _| dirty_for_callback.set(true)); + Self { + window_id, + root_view, + presenter: TuiPresenter::new(), + renderer: TuiFrameRenderer::new(), + terminal, + dirty, + last_size: None, + } + } + + /// Runs the draw + input loop until `should_quit` returns `true`, redrawing + /// when invalidated (or resized) and dispatching converted input events. + pub fn run_until( + &mut self, + app: &mut App, + mut should_quit: impl FnMut(&App) -> bool, + ) -> io::Result<()> { + while !should_quit(app) { + self.draw_if_dirty(app)?; + // 250 ms is a standard event-poll heartbeat: short enough to feel + // responsive to resize, long enough to avoid busy-waiting. A timeout + // is not an error — `poll_event` returns `Ok(None)`, making the loop + // iteration a no-op before the next draw-if-dirty check. + self.poll_and_dispatch(app, Duration::from_millis(250))?; + } + Ok(()) + } + + /// The terminal this runtime draws to. Primarily useful for inspecting an + /// in-memory terminal's captured output in tests. + pub fn terminal(&self) -> &R { + &self.terminal + } + + fn draw_if_dirty(&mut self, app: &mut App) -> io::Result<()> { + let size = self.terminal.size()?; + if self.last_size != Some(size) { + self.dirty.set(true); + } + if !self.dirty.replace(false) { + return Ok(()); + } + + // Lay out and paint the view through the dedicated presenter, which + // resolves the root (and any embedded child views) through the app, + // reports the discovered view embeddings into the shared hierarchy, + // and returns a composited frame (buffer + cursor). + let area = TuiRect::new(0, 0, size.width, size.height); + let window_id = self.window_id; + let presenter = &mut self.presenter; + let root_view = &self.root_view; + let frame = app.update(|ctx| { + // Re-render only the views that changed this frame, then present + // the full tree (unchanged views reuse their cached elements). + let invalidation = ctx.take_all_invalidations_for_window(window_id); + presenter.invalidate(&invalidation, ctx, window_id); + presenter.present(ctx, root_view, area) + }); + + let mut writer = self.terminal.writer(); + self.renderer + .draw(&mut writer, &frame.buffer, frame.cursor)?; + self.last_size = Some(size); + Ok(()) + } + + fn poll_and_dispatch(&mut self, app: &mut App, timeout: Duration) -> io::Result<()> { + let Some(event) = self.terminal.poll_event(timeout)? else { + return Ok(()); + }; + + match event { + CrosstermEvent::Resize(_, _) => self.dirty.set(true), + event => { + if let Some(warp_event) = crossterm_event_to_warp_event(event) { + // Redraws are triggered by views calling `ctx.notify()`, which + // fires `on_window_invalidated` and sets the dirty flag. An event + // being handled is not itself a reason to redraw. + self.dispatch_event(app, &warp_event); + } + } + } + Ok(()) + } + + fn dispatch_event(&mut self, app: &mut App, event: &Event) -> bool { + // Keymap pass (GUI parity): offer a keystroke to the focused view's + // responder chain first, exactly like the GUI window event path. + if let Event::KeyDown { + keystroke, + is_composing, + .. + } = event + { + let window_id = self.window_id; + match app.update(|ctx| { + let responder_chain = ctx.get_responder_chain(window_id); + ctx.dispatch_keystroke(window_id, &responder_chain, keystroke, *is_composing) + }) { + Ok(true) => return true, + Ok(false) => {} + Err(error) => log::error!("error dispatching keystroke: {error}"), + } + } + + // Element-tree pass: walk the last rendered+laid-out element tree + // (cached by the presenter from the most recent draw). Access the two + // presenter fields directly so Rust can see they are disjoint borrows. + let Some(element) = self.presenter.last_element.as_mut() else { + return false; // no draw has happened yet + }; + let size = self.last_size.unwrap_or_default(); + let area = TuiRect::new(0, 0, size.width, size.height); + + let root_view_id = self.root_view.id(); + let mut event_ctx = TuiEventContext::default(); + event_ctx.set_origin_view(Some(root_view_id)); + let mut ctx = TuiLayoutContext { + rendered_views: &mut self.presenter.rendered_views, + }; + let handled = app + .read(|app_ctx| element.dispatch_event(event, area, &mut event_ctx, &mut ctx, app_ctx)); + + for update in event_ctx.take_updates() { + update(app); + } + for action in event_ctx.take_typed_actions() { + // Dispatch through the shared responder chain (the origin view's + // ancestors in the neutral view hierarchy), so an action raised + // inside an embedded child view bubbles to ancestor handlers. + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + self.window_id, + action.origin_view_id, + action.action.as_ref(), + ) + }); + } + handled + } +} + +/// The production [`TuiTerminal`]: reads from / writes to the real terminal and +/// keeps it in the alternate screen + raw mode for the runtime's lifetime. +pub struct CrosstermTerminal { + stdout: Stdout, + _mode_guard: RawModeGuard, +} + +impl CrosstermTerminal { + /// Enables raw mode and switches to the alternate screen, restoring the + /// terminal when the returned value is dropped. + pub fn enter() -> io::Result { + let mode_guard = RawModeGuard::enter(CrosstermModeControl)?; + Ok(Self { + stdout: stdout(), + _mode_guard: mode_guard, + }) + } +} + +impl TuiTerminal for CrosstermTerminal { + fn size(&self) -> io::Result { + let (width, height) = terminal::size()?; + Ok(TuiSize::new(width.max(1), height.max(1))) + } + + fn poll_event(&mut self, timeout: Duration) -> io::Result> { + if event::poll(timeout)? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } + } + + fn writer(&mut self) -> &mut dyn Write { + &mut self.stdout + } +} + +/// The alternate-screen + raw-mode operations a [`RawModeGuard`] toggles. +/// Behind a trait so the guard's enter/leave lifecycle can be exercised without +/// a real terminal. +trait TerminalModeControl { + fn enter(&mut self) -> io::Result<()>; + fn leave(&mut self); +} + +struct CrosstermModeControl; + +impl TerminalModeControl for CrosstermModeControl { + fn enter(&mut self) -> io::Result<()> { + terminal::enable_raw_mode()?; + let mut out = stdout(); + if let Err(error) = execute!(out, EnterAlternateScreen, EnableMouseCapture, Hide) { + let _ = terminal::disable_raw_mode(); + return Err(error); + } + Ok(()) + } + + fn leave(&mut self) { + let mut out = stdout(); + let _ = execute!(out, Show, DisableMouseCapture, LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + } +} + +/// Restores the host terminal on drop, so a panic or early return never strands +/// it in the alternate screen or raw mode. +struct RawModeGuard { + control: C, +} + +impl RawModeGuard { + fn enter(mut control: C) -> io::Result { + control.enter()?; + Ok(Self { control }) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + self.control.leave(); + } +} + +#[cfg(test)] +#[path = "mod_test.rs"] +mod tests; diff --git a/crates/warpui_core/src/runtime/mod_test.rs b/crates/warpui_core/src/runtime/mod_test.rs new file mode 100644 index 0000000000..5019c5d405 --- /dev/null +++ b/crates/warpui_core/src/runtime/mod_test.rs @@ -0,0 +1,271 @@ +use std::cell::RefCell; +use std::collections::VecDeque; +use std::io::{self, Write}; +use std::rc::Rc; +use std::time::Duration; + +use ratatui::crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; + +use super::*; +use crate::elements::tui::{ + TuiBuffer, TuiChildView, TuiConstraint, TuiElement, TuiEventHandler, TuiLayoutContext, + TuiStyle, TuiText, +}; +use crate::platform::WindowStyle; +use crate::{AddWindowOptions, AppContext, Entity, TypedActionView, ViewContext}; + +/// A trivial leaf element that paints a single line of text. +struct TextElement { + text: String, +} + +impl TuiElement for TextElement { + fn layout(&mut self, constraint: TuiConstraint, _ctx: &mut TuiLayoutContext) -> TuiSize { + let width = u16::try_from(self.text.chars().count()).unwrap_or(u16::MAX); + constraint.clamp(TuiSize::new(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 minimal root view that renders the text "hello". +struct TextView; + +impl Entity for TextView { + type Event = (); +} + +impl TuiView for TextView { + fn ui_name() -> &'static str { + "TextView" + } + + fn render(&self, _: &AppContext) -> Box { + Box::new(TextElement { + text: "hello".to_owned(), + }) + } +} + +impl TypedActionView for TextView { + type Action = (); +} + +/// An in-memory [`TuiTerminal`] that captures the renderer's bytes and replays a +/// fixed queue of input events. +struct TestTerminal { + size: TuiSize, + output: Vec, + events: VecDeque, +} + +impl TestTerminal { + fn new(size: TuiSize) -> Self { + Self { + size, + output: Vec::new(), + events: VecDeque::new(), + } + } + + fn output_string(&self) -> String { + String::from_utf8_lossy(&self.output).into_owned() + } +} + +impl TuiTerminal for TestTerminal { + fn size(&self) -> io::Result { + Ok(self.size) + } + + fn poll_event(&mut self, _timeout: Duration) -> io::Result> { + Ok(self.events.pop_front()) + } + + fn writer(&mut self) -> &mut dyn Write { + &mut self.output + } +} + +fn window_options() -> AddWindowOptions { + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + } +} + +#[test] +fn run_until_draws_view_text_and_exits_on_quit() { + App::test((), |mut app| async move { + let (window_id, root) = + app.update(|ctx| ctx.add_tui_window(window_options(), |_| TextView)); + let terminal = TestTerminal::new(TuiSize::new(20, 3)); + let mut runtime = TuiRuntime::with_terminal(&app, window_id, root, terminal); + + // Quit after the first iteration so a single draw pass runs and the loop + // provably terminates rather than spinning forever. + let mut iterations = 0; + runtime + .run_until(&mut app, |_| { + iterations += 1; + iterations > 1 + }) + .unwrap(); + + assert!(iterations <= 2, "run_until should exit promptly"); + assert!( + runtime.terminal().output_string().contains("hello"), + "the view's text should be drawn to the in-memory terminal" + ); + }); +} + +/// The typed action only the parent view handles in the embedded-child test. +#[derive(Debug)] +struct Bump; + +/// A leaf TUI view whose subtree raises a typed action on `b`. +struct BumpChildView; + +impl Entity for BumpChildView { + type Event = (); +} + +impl TuiView for BumpChildView { + fn ui_name() -> &'static str { + "BumpChildView" + } + + fn render(&self, _: &AppContext) -> Box { + Box::new( + TuiEventHandler::new(TuiText::new("child")) + .on_key("b", |_, ctx, _| ctx.dispatch_typed_action(Bump)), + ) + } +} + +/// The window root: embeds [`BumpChildView`] and handles [`Bump`]. +struct BumpParentView { + child: crate::ViewHandle, + bumps: usize, +} + +impl Entity for BumpParentView { + type Event = (); +} + +impl TuiView for BumpParentView { + fn ui_name() -> &'static str { + "BumpParentView" + } + + fn render(&self, _app: &AppContext) -> Box { + Box::new(TuiChildView::new(&self.child)) + } +} + +impl TypedActionView for BumpParentView { + type Action = Bump; + + fn handle_action(&mut self, _action: &Bump, _ctx: &mut ViewContext) { + self.bumps += 1; + } +} + +#[test] +fn typed_action_from_embedded_child_reaches_parent_through_runtime_dispatch() { + App::test((), |mut app| async move { + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window(window_options(), |view_ctx| { + let child = view_ctx.add_tui_view(|_| BumpChildView); + BumpParentView { child, bumps: 0 } + }) + }); + + let mut terminal = TestTerminal::new(TuiSize::new(20, 3)); + terminal.events.push_back(CrosstermEvent::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::empty(), + ))); + let root_for_runtime = root.clone(); + let mut runtime = TuiRuntime::with_terminal(&app, window_id, root_for_runtime, terminal); + + // Two iterations: the first draws (reporting the child embedding into + // the shared view hierarchy) and dispatches the queued `b` key; the + // second exits. + let mut iterations = 0; + runtime + .run_until(&mut app, |_| { + iterations += 1; + iterations > 1 + }) + .unwrap(); + + // The action was raised inside the embedded child view's subtree and + // dispatched from the child's id; the shared responder chain bubbled it + // to the parent's handler. (The legacy origin-only dispatch could not + // do this.) + assert_eq!(root.read(&app, |view, _| view.bumps), 1); + }); +} + +/// Records the mode-control enter/leave calls so the guard's lifecycle can be +/// asserted without touching a real terminal. +struct RecordingControl { + log: Rc>>, + fail_enter: bool, +} + +impl TerminalModeControl for RecordingControl { + fn enter(&mut self) -> io::Result<()> { + if self.fail_enter { + return Err(io::Error::other("enter failed")); + } + self.log.borrow_mut().push("enter"); + Ok(()) + } + + fn leave(&mut self) { + self.log.borrow_mut().push("leave"); + } +} + +#[test] +fn raw_mode_guard_restores_on_drop() { + let log = Rc::new(RefCell::new(Vec::new())); + let control = RecordingControl { + log: log.clone(), + fail_enter: false, + }; + { + let _guard = RawModeGuard::enter(control).unwrap(); + assert_eq!(*log.borrow(), vec!["enter"]); + } + assert_eq!( + *log.borrow(), + vec!["enter", "leave"], + "dropping the guard should restore the terminal" + ); +} + +#[test] +fn raw_mode_guard_does_not_leave_when_enter_fails() { + let log = Rc::new(RefCell::new(Vec::new())); + let control = RecordingControl { + log: log.clone(), + fail_enter: true, + }; + assert!(RawModeGuard::enter(control).is_err()); + assert!( + log.borrow().is_empty(), + "a failed enter must not run the leave/restore path" + ); +} diff --git a/crates/warpui_core/src/runtime/renderer.rs b/crates/warpui_core/src/runtime/renderer.rs new file mode 100644 index 0000000000..a9ff2458bd --- /dev/null +++ b/crates/warpui_core/src/runtime/renderer.rs @@ -0,0 +1,110 @@ +//! Flushes a [`TuiBuffer`] to a terminal (or any [`io::Write`] target) using +//! ratatui's cell diff and crossterm backend. +//! +//! [`TuiFrameRenderer`] keeps the previously drawn buffer and, on each draw, +//! asks ratatui's [`Buffer::diff`](TuiBuffer::diff) for the cells that changed +//! since the last frame and writes them through ratatui's [`CrosstermBackend`] +//! (which emits the minimal cursor-move + SGR + print sequence for each run). +//! +//! The first frame, and any frame whose dimensions differ from the previous one +//! (a resize), is painted in full: the screen is cleared and every non-blank +//! cell redrawn. Clearing is required for correctness because a terminal keeps +//! its old contents across a resize while the text reflows to a new width — a +//! plain diff would leave stale fragments behind. To keep that clear + repaint +//! from flickering, the whole frame is wrapped in a terminal *synchronized +//! update*, so a supporting terminal presents the cleared-and-repainted frame +//! atomically and never shows the blank intermediate state. +//! +//! Because it writes to a generic writer, it is exercised headlessly against an +//! in-memory buffer in tests rather than requiring a real tty. + +use std::io::{self, Write}; + +use ratatui::backend::{Backend, CrosstermBackend}; +use ratatui::crossterm::queue; +use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; +use ratatui::layout::Position; + +use crate::elements::tui::TuiBuffer; + +/// Renders successive [`TuiBuffer`]s to a writer, emitting only the per-frame +/// diff. Construct one per output target and reuse it across frames so it can +/// track the previously painted buffer. +pub struct TuiFrameRenderer { + previous_buffer: Option, +} + +impl TuiFrameRenderer { + pub fn new() -> Self { + Self { + previous_buffer: None, + } + } + + /// Forgets the previously drawn buffer so the next [`draw`](Self::draw) + /// repaints the whole frame (e.g. after the host terminal was cleared by + /// something outside the renderer). + pub fn reset(&mut self) { + self.previous_buffer = None; + } + + /// Draws `buffer` to `writer`, emitting either a full repaint (first frame + /// or a size change) or just the cells that differ from the previous frame, + /// then positions or hides the cursor and flushes. The whole frame is + /// wrapped in a synchronized update so it is applied atomically. + pub fn draw( + &mut self, + writer: &mut W, + buffer: &TuiBuffer, + cursor_position: Option<(u16, u16)>, + ) -> io::Result<()> { + let mut backend = CrosstermBackend::new(writer); + + // Group the whole frame into one synchronized update so the terminal + // applies it atomically — in particular, the clear + repaint on a + // resize is presented as a single frame, never as a visible blank. + queue!(backend, BeginSynchronizedUpdate)?; + + // First frame or a size change: clear, then diff against a blank buffer + // of the new size. The clear overwrites the stale contents the terminal + // keeps across a resize (the text reflows to a new width), which a plain + // diff against the previous frame could not do. + let repaint = self + .previous_buffer + .as_ref() + .is_none_or(|previous| previous.area != buffer.area); + let baseline = if repaint { + backend.clear()?; + TuiBuffer::empty(buffer.area) + } else { + self.previous_buffer + .take() + .expect("previous buffer present when not repainting") + }; + + backend.draw(baseline.diff(buffer).into_iter())?; + + match cursor_position { + Some((x, y)) => { + backend.set_cursor_position(Position::new(x, y))?; + backend.show_cursor()?; + } + None => backend.hide_cursor()?, + } + + queue!(backend, EndSynchronizedUpdate)?; + Backend::flush(&mut backend)?; + self.previous_buffer = Some(buffer.clone()); + Ok(()) + } +} + +impl Default for TuiFrameRenderer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +#[path = "renderer_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/runtime/renderer_tests.rs b/crates/warpui_core/src/runtime/renderer_tests.rs new file mode 100644 index 0000000000..d43d9a9f68 --- /dev/null +++ b/crates/warpui_core/src/runtime/renderer_tests.rs @@ -0,0 +1,136 @@ +use ratatui::style::Color; + +use super::TuiFrameRenderer; +use crate::elements::tui::{TuiBuffer, TuiRect, TuiStyle}; + +/// Builds a single-row buffer from `line`, sized to the line's column width. +fn line_buffer(line: &str) -> TuiBuffer { + let width = u16::try_from(line.chars().count()).unwrap(); + let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, width, 1)); + buffer.set_stringn(0, 0, line, usize::from(width), TuiStyle::default()); + buffer +} + +fn draw_to_string(renderer: &mut TuiFrameRenderer, buffer: &TuiBuffer) -> String { + let mut output = Vec::new(); + renderer.draw(&mut output, buffer, None).unwrap(); + String::from_utf8(output).unwrap() +} + +/// The CSI sequence crossterm emits to move the cursor to `(x, y)` (1-based). +fn move_to(x: u16, y: u16) -> String { + format!("\u{1b}[{};{}H", y + 1, x + 1) +} + +#[test] +fn first_paint_clears_and_writes_all_cells() { + let mut renderer = TuiFrameRenderer::new(); + let output = draw_to_string(&mut renderer, &line_buffer("abc")); + + // Full repaint clears the screen and prints every non-blank cell. + assert!( + output.contains("\u{1b}[2J"), + "first paint should clear screen" + ); + assert!(output.contains("abc"), "first paint should write all cells"); +} + +#[test] +fn unchanged_frame_emits_no_text() { + let mut renderer = TuiFrameRenderer::new(); + let buffer = line_buffer("abc"); + let _ = draw_to_string(&mut renderer, &buffer); + + let output = draw_to_string(&mut renderer, &buffer); + assert!( + !output.contains("abc"), + "an unchanged frame should not re-emit any cell text" + ); +} + +#[test] +fn diff_emits_only_changed_run() { + let mut renderer = TuiFrameRenderer::new(); + let _ = draw_to_string(&mut renderer, &line_buffer("abcde")); + + let output = draw_to_string(&mut renderer, &line_buffer("abXYe")); + + assert!(output.contains("XY"), "diff should emit the changed run"); + assert!( + output.contains(&move_to(2, 0)), + "diff should move the cursor to the first changed column" + ); + assert!( + !output.contains("abcde") && !output.contains("abc"), + "diff should not re-emit unchanged cells" + ); +} + +#[test] +fn size_change_triggers_full_repaint() { + let mut renderer = TuiFrameRenderer::new(); + let _ = draw_to_string(&mut renderer, &line_buffer("abc")); + + let output = draw_to_string(&mut renderer, &line_buffer("wxyz!")); + // A resize repaints authoritatively (clear + redraw) so no stale content is + // left from the previous, differently-wrapped frame. The clear is wrapped + // in a synchronized update by `draw`, so it is applied atomically. + assert!( + output.contains("\u{1b}[2J"), + "a size change should force a full repaint" + ); + assert!(output.contains("wxyz!")); +} + +#[test] +fn changed_wide_grapheme_is_emitted_whole() { + let mut renderer = TuiFrameRenderer::new(); + let _ = draw_to_string(&mut renderer, &line_buffer("ab ")); + + // Replace the two leading columns with a single wide (CJK) grapheme. + let mut next = TuiBuffer::empty(TuiRect::new(0, 0, 3, 1)); + next.set_stringn(0, 0, "界 ", 3, TuiStyle::default()); + let output = draw_to_string(&mut renderer, &next); + + assert!(output.contains('界'), "the wide grapheme should be emitted"); + assert!(output.contains(&move_to(0, 0))); +} + +#[test] +fn styled_run_changes_byte_stream() { + // A styled cell must add an SGR color escape that the same text painted with + // the default style does not, so the byte streams differ. + let styled = { + let mut buffer = TuiBuffer::empty(TuiRect::new(0, 0, 3, 1)); + buffer.set_stringn(0, 0, "ab", 2, TuiStyle::default()); + buffer.set_stringn(2, 0, "C", 1, TuiStyle::default().fg(Color::Yellow)); + draw_to_string(&mut TuiFrameRenderer::new(), &buffer) + }; + let plain = draw_to_string(&mut TuiFrameRenderer::new(), &line_buffer("abC")); + + assert!( + styled.contains('C'), + "styled run should still print its text" + ); + assert_ne!( + styled, plain, + "a foreground color should change the byte stream" + ); +} + +#[test] +fn cursor_is_shown_when_present_and_hidden_otherwise() { + let mut renderer = TuiFrameRenderer::new(); + let buffer = line_buffer("abc"); + + let mut shown = Vec::new(); + renderer.draw(&mut shown, &buffer, Some((1, 0))).unwrap(); + let shown = String::from_utf8(shown).unwrap(); + assert!(shown.contains("\u{1b}[?25h"), "cursor should be shown"); + assert!(shown.contains(&move_to(1, 0))); + + let mut hidden = Vec::new(); + renderer.draw(&mut hidden, &buffer, None).unwrap(); + let hidden = String::from_utf8(hidden).unwrap(); + assert!(hidden.contains("\u{1b}[?25l"), "cursor should be hidden"); +} diff --git a/crates/warpui_core/tests/tui_integration.rs b/crates/warpui_core/tests/tui_integration.rs new file mode 100644 index 0000000000..f0cb870b15 --- /dev/null +++ b/crates/warpui_core/tests/tui_integration.rs @@ -0,0 +1,350 @@ +//! Headless integration tests for the in-core TUI backend, ported from the +//! legacy `warpui_tui` crate's `repo_explorer_integration` tests. +//! +//! The legacy tests `#[path]`-included the repo_explorer example's view, which +//! wired up the real `repo_metadata` model. `repo_metadata` depends on +//! `warp_core`, which uses GUI-only `warpui_core` surface and therefore cannot +//! coexist with the `tui` feature in one build graph, so these tests use a +//! self-contained directory model instead — preserving the original shape: +//! a real model registered through the shared core, indexed asynchronously on +//! the background executor, observed by the root view, rendered through the +//! `TuiPresenter`, and navigated via typed actions dispatched through the +//! shared core. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use warpui_core::elements::tui::{ + Modifier, TuiBufferExt, TuiColumn, TuiElement, TuiEventHandler, TuiParentElement, TuiRect, + TuiStyle, TuiText, +}; +use warpui_core::platform::WindowStyle; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{ + AddWindowOptions, App, AppContext, Entity, ModelHandle, TuiView, TypedActionView, UpdateModel, + ViewContext, ViewHandle, WindowId, +}; + +/// The indexing lifecycle of [`DirectoryModel`]. +enum IndexState { + Indexing, + Indexed, +} + +/// A minimal model holding a directory listing, indexed off-thread. +struct DirectoryModel { + path: PathBuf, + entries: Vec<(String, bool)>, + state: IndexState, +} + +impl Entity for DirectoryModel { + type Event = (); +} + +/// The typed action the view handles, dispatched through the shared core. +#[derive(Debug, Clone, Copy)] +enum NavAction { + SelectNext, + SelectPrev, +} + +/// The root TUI view: a header + status sourced from the model, the entry +/// list with a selection marker, and key bindings that dispatch typed actions. +struct ExplorerView { + model: ModelHandle, + selected: usize, + quit: Rc>, +} + +impl ExplorerView { + fn entries(&self, ctx: &AppContext) -> Vec<(String, bool)> { + self.model.as_ref(ctx).entries.clone() + } + + fn selected(&self) -> usize { + self.selected + } +} + +impl Entity for ExplorerView { + type Event = (); +} + +impl TuiView for ExplorerView { + fn ui_name() -> &'static str { + "ExplorerView" + } + + fn render(&self, ctx: &AppContext) -> Box { + let model = self.model.as_ref(ctx); + let entries = &model.entries; + let selected = self.selected.min(entries.len().saturating_sub(1)); + + let header_style = TuiStyle::default().add_modifier(Modifier::BOLD); + let selected_style = TuiStyle::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + let hint_style = TuiStyle::default().add_modifier(Modifier::DIM); + + let mut rows: Vec> = Vec::new(); + rows.push(Box::new( + TuiText::new(format!("explorer · {}", display_name(&model.path))) + .with_style(header_style) + .truncate(), + )); + let status = match model.state { + IndexState::Indexing => "status: indexing…".to_owned(), + IndexState::Indexed => { + format!("status: indexed · {} entries", entries.len()) + } + }; + rows.push(Box::new(TuiText::new(status).truncate())); + rows.push(Box::new(TuiText::new(" "))); + + if entries.is_empty() { + rows.push(Box::new( + TuiText::new("(no indexed entries yet)").with_style(hint_style), + )); + } + for (index, (name, is_dir)) in entries.iter().enumerate() { + let marker = if index == selected { "› " } else { " " }; + let suffix = if *is_dir { "/" } else { "" }; + let style = if index == selected { + selected_style + } else { + TuiStyle::default() + }; + rows.push(Box::new( + TuiText::new(format!("{marker}{name}{suffix}")) + .with_style(style) + .truncate(), + )); + } + + rows.push(Box::new(TuiText::new(" "))); + rows.push(Box::new( + TuiText::new("j/↓ next · k/↑ prev · q quit") + .with_style(hint_style) + .truncate(), + )); + + let body = TuiColumn::new().with_children(rows); + + // Wire keyboard input: navigation keys dispatch a typed action through + // the shared core; quit keys flip the shared quit flag the runtime + // polls. + let quit_for_q = self.quit.clone(); + let quit_for_esc = self.quit.clone(); + let handler = TuiEventHandler::new(body) + .on_key("j", |_, ctx, _| { + ctx.dispatch_typed_action(NavAction::SelectNext) + }) + .on_key("down", |_, ctx, _| { + ctx.dispatch_typed_action(NavAction::SelectNext) + }) + .on_key("k", |_, ctx, _| { + ctx.dispatch_typed_action(NavAction::SelectPrev) + }) + .on_key("up", |_, ctx, _| { + ctx.dispatch_typed_action(NavAction::SelectPrev) + }) + .on_key("q", move |_, _, _| quit_for_q.set(true)) + .on_key("escape", move |_, _, _| quit_for_esc.set(true)); + + Box::new(handler) + } +} + +impl TypedActionView for ExplorerView { + type Action = NavAction; + + fn handle_action(&mut self, action: &NavAction, ctx: &mut ViewContext) { + let count = self.entries(ctx).len(); + if count == 0 { + return; + } + match action { + NavAction::SelectNext => { + self.selected = (self.selected + 1).min(count - 1); + } + NavAction::SelectPrev => { + self.selected = self.selected.saturating_sub(1); + } + } + // Mark the view dirty so the runtime repaints with the new selection. + ctx.notify(); + } +} + +/// The display name for a directory entry: its final path component, falling +/// back to the full path when there is no file name. +fn display_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) +} + +/// Scans `path` into (display name, is_dir) entries — directories first, then +/// alphabetically — so the rendered list is deterministic and navigable. +fn scan(path: &Path) -> Vec<(String, bool)> { + let mut entries: Vec<(String, bool)> = std::fs::read_dir(path) + .map(|entries| { + entries + .flatten() + .map(|entry| { + let is_dir = entry.file_type().is_ok_and(|file_type| file_type.is_dir()); + (entry.file_name().to_string_lossy().into_owned(), is_dir) + }) + .collect() + }) + .unwrap_or_default(); + entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + entries +} + +fn window_options() -> AddWindowOptions { + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + } +} + +/// Registers the model, indexes `dir` on the background executor and awaits +/// the scan on the shared runtime, then installs the root view (observing the +/// model so it redraws on change). +async fn bootstrap( + app: &mut App, + dir: PathBuf, + quit: Rc>, +) -> (WindowId, ViewHandle) { + let model = app.add_model(|_| DirectoryModel { + path: dir.clone(), + entries: Vec::new(), + state: IndexState::Indexing, + }); + + // Index on the background executor — the same `spawn` plumbing GUI views + // use — then apply the results through the shared core, notifying + // observers. + let scan_dir = dir.clone(); + let (tx, rx) = futures::channel::oneshot::channel(); + app.background_executor() + .spawn(async move { + let _ = tx.send(scan(&scan_dir)); + }) + .detach(); + let entries = rx.await.expect("the background scan completes"); + app.update(|ctx| { + ctx.update_model(&model, |model, mctx| { + model.entries = entries; + model.state = IndexState::Indexed; + mctx.notify(); + }); + }); + + let model_for_view = model.clone(); + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window(window_options(), |view_ctx| { + // Redraw whenever the model changes — the same observation + // primitive GUI views use. + view_ctx.observe(&model_for_view, |_view, _model, ctx| ctx.notify()); + ExplorerView { + model: model_for_view.clone(), + selected: 0, + quit: quit.clone(), + } + }) + }); + + (window_id, root) +} + +#[test] +fn buffer_reflects_real_model_state() { + App::test((), |mut app| async move { + let dir = std::env::current_dir().expect("cwd"); + let quit = Rc::new(Cell::new(false)); + + let (_window_id, root) = bootstrap(&mut app, dir.clone(), quit).await; + + // Render into a buffer tall enough to hold the indexed entries. + let mut presenter = TuiPresenter::new(); + let area = TuiRect::new(0, 0, 100, 120); + let frame = app.update(|ctx| presenter.present(ctx, &root, area)); + let text = frame.buffer.to_lines().join("\n"); + + assert!( + text.contains(&display_name(&dir)), + "buffer should render the header sourced from the model:\n{text}" + ); + assert!( + text.contains("status: indexed"), + "buffer should reflect the model's indexed state:\n{text}" + ); + + // The rendered list must be sourced from the model: take the model's + // own first entry and assert it appears in the painted buffer. + let (first_name, entry_count) = app.read(|ctx| { + root.read(ctx, |view, ctx| { + let entries = view.entries(ctx); + (entries.first().map(|(name, _)| name.clone()), entries.len()) + }) + }); + assert!(entry_count > 0, "the indexed directory should have entries"); + let first_name = first_name.expect("there is at least one entry"); + assert!( + text.contains(&first_name), + "buffer should render the model's first entry {first_name:?}:\n{text}" + ); + }); +} + +#[test] +fn typed_nav_action_changes_rendered_buffer() { + App::test((), |mut app| async move { + let dir = std::env::current_dir().expect("cwd"); + let quit = Rc::new(Cell::new(false)); + + let (window_id, root) = bootstrap(&mut app, dir, quit).await; + + let mut presenter = TuiPresenter::new(); + let area = TuiRect::new(0, 0, 80, 40); + + // First frame: selection starts at entry 0. + let before = app.update(|ctx| presenter.present(ctx, &root, area)); + let before_lines = before.buffer.to_lines(); + let selected_before = app.read(|ctx| root.read(ctx, |view, _| view.selected())); + assert_eq!( + selected_before, 0, + "selection should start at the first entry" + ); + + // Dispatch the typed action through the shared core, exactly as the + // runtime does when a navigation key is pressed. + app.dispatch_typed_action(window_id, &[root.id()], &NavAction::SelectNext); + + let selected_after = app.read(|ctx| root.read(ctx, |view, _| view.selected())); + assert_eq!( + selected_after, 1, + "SelectNext dispatched through the shared core should advance the selection" + ); + + // Second frame: re-present; the runtime would call invalidate() first + // but the standalone presenter falls back to a fresh render. + let after = app.update(|ctx| presenter.present(ctx, &root, area)); + let after_lines = after.buffer.to_lines(); + assert_ne!( + before_lines, after_lines, + "the typed action should change the rendered buffer" + ); + + // The selection marker '›' should sit on a different row after + // navigation. + let marker_row = |lines: &[String]| lines.iter().position(|line| line.contains('›')); + assert_ne!( + marker_row(&before_lines), + marker_row(&after_lines), + "the selection marker should move to a different row" + ); + }); +}