Files

10 KiB

Generalized TUI viewport element — TECH

Context

This branch adds a generalized, content-agnostic virtualized viewport to WarpUI's TUI element stack, plus an interactive demo harness that exercises it over a trivial in-memory source of text blocks. It is the reusable scrolling/clipping foundation; it knows viewport geometry, scroll clamping, and visible element trees, but it knows nothing about TerminalModel, terminal blocks, or agent exchanges. The consumer will land in a PR above this one: a terminal-backed transcript (specs/tui-transcript-view/TECH.md) plugs a TerminalModel::BlockList-backed source and real agent/terminal block renderers into this viewport. That work is out of scope here. WarpUI already has a TUI element/view/presenter stack. TuiElement defines the layout/render/present/event/cursor lifecycle; TuiColumn lays children out top-to-bottom, giving each child a loose constraint of the remaining height. The viewport returns normal visible TuiElement trees so this lifecycle stays intact; it never paints raw buffer rows outside the element lifecycle.

Proposed changes

Generalized viewport element

Add crates/warpui_core/src/elements/tui/viewported_list.rs: a TuiViewportedList that delegates content slicing to a caller-provided TuiViewportedElement. The list owns viewport geometry, scroll position clamping, visible child layout, painting, presentation, cursor lookup, and event dispatch. The content source owns item storage, item identity, ordered traversal, height caches, and any width-dependent measurement reconciliation. The content source API is absolute-row based:

  • TuiViewportWindow { scroll_top, viewport_height } describes the requested visible row window, with scroll_top in content-space rows and viewport_height in terminal rows.
  • TuiViewportedElement::visible_items(window, available_width, app) returns TuiViewportContent; available_width is layout context for width-dependent height reconciliation, not horizontal viewport state.
  • TuiViewportContent { content_height, items } reports the full content height and the child elements visible in or near the requested window.
  • TuiVisibleViewportItem { origin_y, element } gives each returned child element's top row in content coordinates. This keeps the generic viewport API clean: consumers answer “which elements are visible for this row window?” instead of adapting their model to a seekable cursor interface. A terminal-backed source may still use stable block IDs, a sum tree, and scoped model borrows internally, but those implementation details do not leak into the reusable TUI viewport element. The source returns full child elements, not row-sliced elements. TuiViewportedList lays each returned child out, intersects the child's content-space range with the viewport window, and renders only the visible rows. That keeps partial visibility and event/cursor translation in the viewport layer instead of requiring every consumer to call TuiClipped or slice text by hand. TuiViewportedList performs a single layout pass and does not reconcile heights after layout: the source must return correct heights up front. This keeps the generic viewport simple and assumes the source owns a cheap, exact height predictor. A source with width-dependent item heights refreshes its own height cache inside visible_items (for both visible and near-off-screen items) before returning content, so windowing and content_height use current heights without a second pass.

Viewport position and scrolling

Use a simple absolute-row viewport position model:

  • TuiViewportPosition::End follows the bottom of the content.
  • TuiViewportPosition::RowsFromTop(usize) stores an absolute content-space row offset. TuiViewportedListState is caller-owned shared storage for this requested position, mirroring the GUI virtual list pattern where durable scroll state lives outside the ephemeral element tree. TuiViewportedList::new(state, content) reads the current position during layout and updates the state directly during scroll. During layout, TuiViewportedList resolves End using the last known content height, calls visible_items, clamps against the returned content_height.saturating_sub(usize::from(viewport_height)), and re-requests content once if clamping changes the effective scroll_top. This handles the first End layout and content-height changes without exposing item anchors. Wheel scrolling remains in TuiScrollable. TuiViewportedList::scroll_by_rows converts the effective current position to an absolute row, clamps it against the last known content height and viewport height, stores RowsFromTop(new_top) when scrolled away from the bottom, and restores End when the viewport reaches the bottom clamp. This preserves the current “scroll back to bottom follows new content” behavior while intentionally avoiding item-anchor semantics in the generic API. Absolute row offsets are not semantic anchors across arbitrary mutations above the viewport. If the terminal transcript needs stronger preservation later, add a content-level adjustment hook above this primitive rather than folding item identity back into TuiViewportedList.

Reusable wheel-scroll wrapper

Add crates/warpui_core/src/elements/tui/scrollable.rs: TuiScrollable wraps a TuiScrollableElement (implemented by TuiViewportedList), capturing wheel events over the child's area and translating them into scroll_by_rows calls. Layout, render, cursor, and inner event dispatch are transparent; only the wheel is intercepted, and only when the child did not already handle the event. This mirrors the GUI's NewScrollable / NewScrollableElement split: the wrapper owns wheel handling, the list owns scroll position and clamping. The TUI wrapper consumes in-bounds wheel events by default, like the GUI scrollable default, and exposes opt-in propagation for nested scrollables at edges. That preserves the future nested-scroll handoff path without importing the GUI's clipped-scrollable mode into this primitive.

Generic clipped element

Add TuiClipped, a generic single-child wrapper whose with_viewport_origin_y(origin_y) API sets the child row rendered at viewport y=0. The child still lays out and renders from logical row 0; TuiClipped copies the visible window out of that rendered child buffer and translates cursor/event coordinates through the same origin. The viewport uses equivalent clipping/translation internally for boxed visible children, while TuiClipped remains the public reusable seam for other TUI containers that need row-offset child rendering. Leaf elements such as TuiText remain unaware of scrolling. TuiClipped is the seam for a future full TUI clipped-scrollable implementation: richer layout, event translation, and scrollbar/scroll-state integration should grow there instead of in text.

Mouse event conversion

Extend crossterm_event_to_tui_event to map crossterm mouse input into the TUI-owned TuiEvent vocabulary. TuiEvent intentionally uses terminal-native cell coordinates (TuiPoint) and row/column scroll deltas (TuiScrollDelta) instead of the GUI Event type's float/pixel coordinates. The converter emits the event shapes needed by this viewport primitive: wheel events, left/middle/right mouse-down, left mouse-up, left drag, and mouse-move. Right/middle mouse-up and drag remain ignored until a concrete TUI consumer needs them. Key events still participate in the shared keymap path because TuiRuntime extracts keymap data from TuiEvent::KeyDown before falling through to element-tree dispatch. TuiEventContext queues notifications and typed actions raised during element-tree dispatch. notify() records the current origin view in a notified set so the runtime notifies each origin once after dispatch. dispatch_typed_action() stores actions with the origin view whose subtree raised them; after notifications are drained, the runtime dispatches queued typed actions in insertion order through the shared responder chain rooted at each origin view. The alternate-screen guard already enables mouse capture, so the existing TuiRuntime delivers converted TUI events to the element tree without further terminal-mode changes.

Demo harness

Add crates/warpui_core/examples/tui_viewport_demo.rs (mirrors crates/warpui_core/examples/tui_demo.rs): an in-memory TuiViewportedElement of simple multi-row text blocks rendered through TuiScrollable::new(TuiViewportedList::new(...)), run via TuiRuntime::enter + run_until. It deliberately drives every shipped component so all of it is manually testable: end position, absolute row offsets while scrolling, return-to-end, front-block removal while scrolled, wheel scrolling, viewport-owned top clipping, and width-dependent height re-measurement on resize.

Testing and validation

Unit tests alongside the new modules use fake viewport sources and injected elements (viewported_list_tests.rs, clipped_tests.rs, text_tests.rs, geometry_tests.rs, event_conversion_tests.rs, runtime/mod_tests.rs) and verify: viewport windows include scroll_top and viewport_height; available_width is passed separately for measurement; end-position rendering; absolute row offset rendering; clamping offsets past content back to end; wheel scrolling up/down and return-to-end behavior; all-content-fits no-op scrolling; scroll notifications; edge propagation; viewport-owned clipping for render, cursor, and event dispatch; TUI rect point containment uses half-open bounds; crossterm mouse input maps to the supported TuiEvent variants; typed actions raised by embedded children reach parent handlers through runtime dispatch. Run:

  • ./script/format
  • cargo nextest run -p warpui_core --features tui elements::tui --no-fail-fast
  • cargo build -p warpui_core --example tui_viewport_demo --features tui and a manual run (overflow the viewport, wheel-scroll, confirm clipping + return-to-end; Esc/Ctrl-C restores the terminal)
  • cargo clippy -p warpui_core --all-targets -- -D warnings

Outside this branch

  • the terminal-history source over TerminalModel::BlockList, the terminal-backed transcript view, and the real agent/terminal block renderers (see specs/tui-transcript-view/TECH.md)
  • the production TUI root, invalidation-driven driver, and interactive input hookup