48 lines
10 KiB
Markdown
48 lines
10 KiB
Markdown
# 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`](../../crates/warpui_core/src/elements/tui/mod.rs) defines the layout/render/present/event/cursor lifecycle; [`TuiColumn`](../../crates/warpui_core/src/elements/tui/column.rs) 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`](../../crates/warpui_core/src/elements/tui/clipped.rs), 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`](../../crates/warpui_core/src/elements/tui/text.rs) 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`](../../crates/warpui_core/src/runtime/event_conversion.rs) to map crossterm mouse input into the TUI-owned [`TuiEvent`](../../crates/warpui_core/src/elements/tui/event.rs) 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
|