From eeb2fd63f5912fcf0d7f75941f4c2e4abc22995f Mon Sep 17 00:00:00 2001 From: Yunfan Yang Date: Wed, 17 Jun 2026 15:30:09 -0400 Subject: [PATCH 1/2] warpui_core(tui): add ratatui-backed TUI presenter and runtime Build the immediate-mode presenter and the draw + event runtime on top of the ratatui element layer, completing the in-core TUI backend behind the `tui` feature: - presenter::tui::TuiPresenter: measure -> arrange -> present -> paint into a ratatui Buffer, reporting child-view embeddings into the shared neutral view hierarchy. - runtime::TuiRuntime drives a TuiView window: raw mode + alt screen via a RAII guard, invalidate -> redraw, crossterm input -> shared keymap then element-tree dispatch, draining deferred app updates / typed actions. - TuiFrameRenderer flushes via ratatui's Buffer::diff + CrosstermBackend instead of a hand-rolled diff; crossterm comes from ratatui::crossterm. - event_conversion maps crossterm 0.29 input to the shared Event vocabulary. - Re-export Color/Modifier from elements::tui so consumers can style text without depending on ratatui directly. - tests/tui_integration.rs: end-to-end model -> view -> presenter -> typed action flow against the shared core. --- crates/warpui_core/Cargo.toml | 7 + crates/warpui_core/src/elements/tui/buffer.rs | 2 +- crates/warpui_core/src/elements/tui/event.rs | 7 +- crates/warpui_core/src/elements/tui/mod.rs | 4 +- crates/warpui_core/src/lib.rs | 2 + crates/warpui_core/src/presenter.rs | 3 + crates/warpui_core/src/presenter/tui.rs | 160 +++++++ crates/warpui_core/src/presenter/tui_tests.rs | 416 ++++++++++++++++++ .../src/runtime/event_conversion.rs | 174 ++++++++ .../src/runtime/event_conversion_tests.rs | 128 ++++++ crates/warpui_core/src/runtime/mod.rs | 335 ++++++++++++++ crates/warpui_core/src/runtime/mod_test.rs | 274 ++++++++++++ crates/warpui_core/src/runtime/renderer.rs | 92 ++++ .../warpui_core/src/runtime/renderer_tests.rs | 133 ++++++ crates/warpui_core/tests/tui_integration.rs | 349 +++++++++++++++ 15 files changed, 2078 insertions(+), 8 deletions(-) create mode 100644 crates/warpui_core/src/presenter/tui.rs create mode 100644 crates/warpui_core/src/presenter/tui_tests.rs create mode 100644 crates/warpui_core/src/runtime/event_conversion.rs create mode 100644 crates/warpui_core/src/runtime/event_conversion_tests.rs create mode 100644 crates/warpui_core/src/runtime/mod.rs create mode 100644 crates/warpui_core/src/runtime/mod_test.rs create mode 100644 crates/warpui_core/src/runtime/renderer.rs create mode 100644 crates/warpui_core/src/runtime/renderer_tests.rs create mode 100644 crates/warpui_core/tests/tui_integration.rs diff --git a/crates/warpui_core/Cargo.toml b/crates/warpui_core/Cargo.toml index 91ebeea3db..4d25663af4 100644 --- a/crates/warpui_core/Cargo.toml +++ b/crates/warpui_core/Cargo.toml @@ -112,3 +112,10 @@ 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"] 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/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/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 16b6241829..9510d625d2 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; @@ -130,8 +130,6 @@ pub struct TuiPresentationContext<'a> { } 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, parent_by_child: &'a mut HashMap, 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..39828ffe37 --- /dev/null +++ b/crates/warpui_core/src/presenter/tui.rs @@ -0,0 +1,160 @@ +//! 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, TuiPresentationContext, TuiRect}; +use crate::{AppContext, TuiView, ViewHandle}; + +/// 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`]. +/// +/// The view ancestry discovered while presenting is reported into the core's +/// neutral `view_parents` hierarchy (the single source of truth shared with the +/// GUI presenter), so the presenter itself retains no parent map. +#[derive(Default)] +pub struct TuiPresenter; + +impl TuiPresenter { + pub fn new() -> Self { + Self + } + + /// Renders the root view through the app, then lays it out and paints it + /// into `area`, returning the composited [`TuiFrame`]. + /// + /// The root is resolved via [`AppContext::render_tui_view`] as a typed + /// `Box`; a view that is not a TUI view yields a blank + /// frame. Child-view embeddings discovered during the present pass are + /// reported via [`AppContext::report_view_embeddings`] — as a batch, + /// because the present pass borrows the rendered tree mutably (the same + /// constraint the GUI presenter's `build_scene` has). + 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(); + let Ok(mut element) = ctx.render_tui_view(window_id, root_view_id) else { + return TuiFrame::blank(area); + }; + + let arranged = arrange(element.as_mut(), area); + + let mut embeddings = HashMap::new(); + { + let mut present_ctx = TuiPresentationContext::new(root_view_id, &mut embeddings); + element.present(&mut present_ctx); + } + ctx.report_view_embeddings(window_id, embeddings); + + paint(element.as_ref(), arranged, area) + } + + /// Lays out and paints an already-rendered element tree into `area`. + /// + /// This is the backend-agnostic core of [`present`](Self::present), exposed + /// so the runtime (and tests) can drive layout/paint for an element tree + /// that was produced outside the app's view registry. No view-ancestry is + /// recorded. + pub fn present_element(&mut self, mut root: Box, area: TuiRect) -> TuiFrame { + let arranged = arrange(root.as_mut(), area); + paint(root.as_ref(), arranged, area) + } +} + +/// 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) -> TuiRect { + let measured = root.layout(TuiConstraint::loose(area.as_size())); + 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. +fn paint(root: &dyn TuiElement, arranged: TuiRect, area: TuiRect) -> TuiFrame { + let mut buffer = TuiBuffer::empty(buffer_rect_for(area)); + root.render(arranged, &mut buffer); + + let cursor = root + .cursor_position(arranged) + .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..bfb13502fd --- /dev/null +++ b/crates/warpui_core/src/presenter/tui_tests.rs @@ -0,0 +1,416 @@ +//! 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, 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) -> TuiSize { + constraint.clamp(TuiSize::new(self.width(), 1)) + } + + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { + buffer.set_stringn( + area.x, + area.y, + &self.text, + usize::from(area.width), + TuiStyle::default(), + ); + } + + fn desired_height(&self, _width: u16) -> u16 { + 1 + } +} + +/// 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) -> 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)); + 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) { + 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); + remaining = rest; + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.children + .iter() + .map(|child| child.desired_height(width)) + .sum() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + for child in &mut self.children { + child.present(ctx); + } + } + + fn cursor_position(&self, area: TuiRect) -> 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) { + 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) -> 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)); + 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) { + 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); + } + + fn desired_height(&self, width: u16) -> u16 { + let inset = self.padding.saturating_mul(2); + self.child + .desired_height(width.saturating_sub(inset)) + .saturating_add(inset) + } + + 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) -> TuiSize { + constraint.clamp(TuiSize::new(5, 1)) + } + + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer) { + buffer.set_stringn( + area.x, + area.y, + "INPUT", + usize::from(area.width), + TuiStyle::default(), + ); + } + + fn desired_height(&self, _width: u16) -> u16 { + 1 + } + + fn cursor_position(&self, _area: TuiRect) -> 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, app); + 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| 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| 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..db796bfd6a --- /dev/null +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -0,0 +1,174 @@ +//! 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, MouseButton, + MouseEvent, MouseEventKind, +}; + +use crate::event::{KeyEventDetails, ModifiersState}; +use crate::geometry::vector::{vec2f, Vector2F}; +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), + CrosstermEvent::Mouse(mouse_event) => mouse_event_to_warp_event(mouse_event), + 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, + } +} + +fn mouse_event_to_warp_event(event: MouseEvent) -> Option { + let position = vec2f(f32::from(event.column), f32::from(event.row)); + let modifiers = modifiers_state(event.modifiers); + match event.kind { + MouseEventKind::Down(MouseButton::Left) => Some(Event::LeftMouseDown { + position, + modifiers, + click_count: 1, + is_first_mouse: false, + }), + MouseEventKind::Up(MouseButton::Left) => Some(Event::LeftMouseUp { + position, + modifiers, + }), + MouseEventKind::Drag(MouseButton::Left) => Some(Event::LeftMouseDragged { + position, + modifiers, + }), + MouseEventKind::Down(MouseButton::Middle) => Some(Event::MiddleMouseDown { + position, + cmd: modifiers.cmd, + shift: modifiers.shift, + click_count: 1, + }), + MouseEventKind::Down(MouseButton::Right) => Some(Event::RightMouseDown { + position, + cmd: modifiers.cmd, + shift: modifiers.shift, + click_count: 1, + }), + MouseEventKind::Moved => Some(Event::MouseMoved { + position, + cmd: modifiers.cmd, + shift: modifiers.shift, + is_synthetic: false, + }), + MouseEventKind::ScrollUp => Some(scroll_wheel_event(position, modifiers, vec2f(0.0, 1.0))), + MouseEventKind::ScrollDown => { + Some(scroll_wheel_event(position, modifiers, vec2f(0.0, -1.0))) + } + MouseEventKind::ScrollLeft => { + Some(scroll_wheel_event(position, modifiers, vec2f(-1.0, 0.0))) + } + MouseEventKind::ScrollRight => { + Some(scroll_wheel_event(position, modifiers, vec2f(1.0, 0.0))) + } + MouseEventKind::Up(MouseButton::Middle | MouseButton::Right) + | MouseEventKind::Drag(MouseButton::Middle | MouseButton::Right) => None, + } +} + +fn scroll_wheel_event(position: Vector2F, modifiers: ModifiersState, delta: Vector2F) -> Event { + Event::ScrollWheel { + position, + delta, + precise: false, + modifiers, + } +} + +fn modifiers_state(modifiers: KeyModifiers) -> ModifiersState { + ModifiersState { + alt: modifiers.contains(KeyModifiers::ALT), + cmd: modifiers.contains(KeyModifiers::SUPER), + shift: modifiers.contains(KeyModifiers::SHIFT), + ctrl: modifiers.contains(KeyModifiers::CONTROL), + func: false, + } +} + +#[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..c2b1fa2f5e --- /dev/null +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -0,0 +1,128 @@ +use ratatui::crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, + MouseEvent, MouseEventKind, +}; + +use super::crossterm_event_to_warp_event; +use crate::geometry::vector::vec2f; +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 left_mouse_down_maps_to_left_mouse_down_at_position() { + let event = CrosstermEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 3, + row: 4, + modifiers: KeyModifiers::empty(), + }); + let Some(Event::LeftMouseDown { position, .. }) = crossterm_event_to_warp_event(event) else { + panic!("expected LeftMouseDown"); + }; + assert_eq!(position, vec2f(3.0, 4.0)); +} + +#[test] +fn scroll_up_and_down_map_to_vertical_scroll_wheel() { + let up = CrosstermEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + }); + let Some(Event::ScrollWheel { delta, .. }) = crossterm_event_to_warp_event(up) else { + panic!("expected ScrollWheel"); + }; + assert_eq!(delta, vec2f(0.0, 1.0)); + + let down = CrosstermEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + }); + let Some(Event::ScrollWheel { delta, .. }) = crossterm_event_to_warp_event(down) else { + panic!("expected ScrollWheel"); + }; + assert_eq!(delta, vec2f(0.0, -1.0)); +} + +#[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..c18f1e1efa --- /dev/null +++ b/crates/warpui_core/src/runtime/mod.rs @@ -0,0 +1,335 @@ +//! 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::{TuiConstraint, TuiEventContext, 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)?; + 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| { + // Drain this window's invalidations each draw. The runtime repaints + // the full frame, but the manual + autotracking invalidation sets + // must still be consumed so they don't accumulate forever (and so + // per-view caching can use them later). + let _invalidation = ctx.take_all_invalidations_for_window(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) { + if self.dispatch_event(app, &warp_event) { + self.dirty.set(true); + } + } + } + } + Ok(()) + } + + fn dispatch_event(&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 rendered tree, offering the event to + // each element (child-view elements re-scope the action origin while + // descending into their subtree). + let size = self + .last_size + .or_else(|| self.terminal.size().ok()) + .unwrap_or_default(); + let area = TuiRect::new(0, 0, size.width, size.height); + + let root_view_id = self.root_view.id(); + let mut element = match app.read(|ctx| ctx.render_tui_view(self.window_id, root_view_id)) { + Ok(element) => element, + Err(error) => { + log::error!("failed to render the TUI root view for event dispatch: {error}"); + return false; + } + }; + element.layout(TuiConstraint::tight(size)); + + let mut event_ctx = TuiEventContext::default(); + event_ctx.set_origin_view(Some(root_view_id)); + let handled = app.read(|ctx| element.dispatch_event(event, area, &mut event_ctx, 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..644d7218e9 --- /dev/null +++ b/crates/warpui_core/src/runtime/mod_test.rs @@ -0,0 +1,274 @@ +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, TuiElement, TuiEventHandler, 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) -> 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) { + buffer.set_stringn( + area.x, + area.y, + &self.text, + usize::from(area.width), + TuiStyle::default(), + ); + } + + fn desired_height(&self, _width: u16) -> u16 { + 1 + } +} + +/// 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, app)) + } +} + +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..c6dc7df53f --- /dev/null +++ b/crates/warpui_core/src/runtime/renderer.rs @@ -0,0 +1,92 @@ +//! 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 — clears the screen and repaints in full. 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::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. + pub fn draw( + &mut self, + writer: &mut W, + buffer: &TuiBuffer, + cursor_position: Option<(u16, u16)>, + ) -> io::Result<()> { + let mut backend = CrosstermBackend::new(writer); + + // First frame or a size change: clear, then repaint every cell by + // diffing against a blank buffer of the new size. Otherwise diff + // against the previously drawn frame so only changed cells are emitted. + 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()?, + } + + 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..d231190047 --- /dev/null +++ b/crates/warpui_core/src/runtime/renderer_tests.rs @@ -0,0 +1,133 @@ +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!")); + 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..0fce1c8814 --- /dev/null +++ b/crates/warpui_core/tests/tui_integration.rs @@ -0,0 +1,349 @@ +//! 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, 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::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: the rendered buffer must change (the selection marker + // moved). + 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" + ); + }); +} From ae870bf11f0d51da659c98e4a7b9c27701c330e9 Mon Sep 17 00:00:00 2001 From: Yunfan Yang Date: Wed, 17 Jun 2026 18:26:27 -0400 Subject: [PATCH 2/2] fix flickering + example app --- crates/warpui_core/Cargo.toml | 5 + crates/warpui_core/examples/tui_demo.rs | 173 ++++++++++++++++++ crates/warpui_core/src/runtime/renderer.rs | 34 +++- .../warpui_core/src/runtime/renderer_tests.rs | 3 + 4 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 crates/warpui_core/examples/tui_demo.rs diff --git a/crates/warpui_core/Cargo.toml b/crates/warpui_core/Cargo.toml index 4d25663af4..72842081a6 100644 --- a/crates/warpui_core/Cargo.toml +++ b/crates/warpui_core/Cargo.toml @@ -119,3 +119,8 @@ cfg_aliases.workspace = true [[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..3d8d7c9a8d --- /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, 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::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/runtime/renderer.rs b/crates/warpui_core/src/runtime/renderer.rs index c6dc7df53f..a9ff2458bd 100644 --- a/crates/warpui_core/src/runtime/renderer.rs +++ b/crates/warpui_core/src/runtime/renderer.rs @@ -5,14 +5,24 @@ //! 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 — clears the screen and repaints in full. Because it writes to a generic -//! writer, it is exercised headlessly against an in-memory buffer in tests -//! rather than requiring a real tty. +//! +//! 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; @@ -40,7 +50,8 @@ impl TuiFrameRenderer { /// 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. + /// 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, @@ -49,9 +60,15 @@ impl TuiFrameRenderer { ) -> io::Result<()> { let mut backend = CrosstermBackend::new(writer); - // First frame or a size change: clear, then repaint every cell by - // diffing against a blank buffer of the new size. Otherwise diff - // against the previously drawn frame so only changed cells are emitted. + // 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() @@ -75,6 +92,7 @@ impl TuiFrameRenderer { None => backend.hide_cursor()?, } + queue!(backend, EndSynchronizedUpdate)?; Backend::flush(&mut backend)?; self.previous_buffer = Some(buffer.clone()); Ok(()) diff --git a/crates/warpui_core/src/runtime/renderer_tests.rs b/crates/warpui_core/src/runtime/renderer_tests.rs index d231190047..d43d9a9f68 100644 --- a/crates/warpui_core/src/runtime/renderer_tests.rs +++ b/crates/warpui_core/src/runtime/renderer_tests.rs @@ -72,6 +72,9 @@ fn size_change_triggers_full_repaint() { 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"