intuiTUIve widgets: the missing components¶
Status: built (rs-rich-intuituive 0.0.2, rs-rich-ext 0.0.15), including moving the built-in nodes onto the trait (see Widget trait v2). Where the build differs from this note:
- Scrolling draws offscreen (option (b) below, not (a)). The scroll's child draws into a persistent offscreen screen with its own damage, and the rows in view are copied out. Only what changed inside draws, so it keeps damage exact without teaching every node about clipping. Clicks and the caret are translated through it.
scrolland pop-ups are framework nodes, not widgets, because they change how a subtree is drawn or layered.tableandtabsare built on the public trait.- The scope re-port removed about 110 lines, not 200: its own plot
(axes, legend box, clipping, cell grid) is now rs-rich-ext's
Chart. - Chart axes follow ratatui:
Labels::Nonedraws no axis line, and an emptyLabels::Valuesdraws the line with no labels.
Phases D and E: built in 0.0.2¶
The two phases after the plan below, from the list of what other frameworks (ratatui above all) leave out:
| Phase | Built | Where |
|---|---|---|
| D | tree, hsplit/vsplit with a draggable divider, calendar with Date, virtual_list |
src/tree.rs, src/split.rs, src/calendar.rs, src/widgets.rs |
| E | the command palette and help from described bindings; menu bars, drop-downs and context menus; pop-ups anchored to a Rect; toasts; animate with easing; text selection and copying with the mouse captured |
src/app.rs, src/menu.rs |
All Phase D widgets are built on the public Widget trait, which is the
test that it is enough. Building them found these gaps in it, closed in
Widget trait v2:
- Hover is per widget, not per cell.
DrawCx::hovered()covers the whole widget, and asking subscribes it to every change of the hover path. The split keeps the last pointer position itself. A "pointer in me" signal, or a leave event, would do better. - Keys reach only the focus path. With nothing focused, keys go to the root, so a container that is not focusable and has no focusable child never sees them.
layout()runs when the widget redraws. A container must read its sizing signals inlayoutordrawto be laid out again. This works, but needs documenting.- No focus, blur or resize events. Widgets compare state at draw time.
- The drawing helpers are private. Markup on one line, its width, and
combining a style over segments are crate-private; a
Canvas::markup(x, y, width, markup, style)would cover them.
Phase E chose to:
- Select text only where nothing uses the press. A press any node or widget uses never starts a selection, so drags, splits and buttons keep working. Copying goes through OSC 52.
- Build the palette and help from bindings. They reuse
rs-rich-interact's
PaletteandHelp, so no second command registry is needed; a binding without a description stays out. - Drive animations from the app clock. The loop wakes every 16 ms only while one runs, and the headless backend's virtual clock tests them.
Widget trait v2: every node a widget¶
The built-in nodes moved onto the trait. Kind, the enum of node types
the framework drew itself, is gone: a node is a Widget and a place in
the layout, and one generic routine draws every node. Text and leaves,
columns, rows, grids, panels, padding, each, switch, logs, hosted
components and scroll are each a Widget in src/builtin.rs and
src/log.rs, using only what the trait offers any widget.
What they needed, now in the trait:
| Built-in | Needed | Now |
|---|---|---|
| Panel, log, containers | Not clearing on every redraw, damage of only what changed, children left alone | retained(); DrawCx::repaint(); damage is what the Canvas wrote; children redraw only where the widget drew over them |
| Scroll | Children drawn offscreen and shown through a window | viewport() and scroll(&ScrollCx) -> (window, offset); clicks, the caret and the pointer translate through it |
| Log | Moving rows up instead of drawing them again | Canvas::scroll_up |
| Each, switch | Children that change, and pages kept while hidden | children() read after layout(); a change of children or areas lays out again; hidden_children() |
| Component host | Paste, the console, its own focus | WidgetEvent::Paste; EventCx::console(); EventCx::focused() |
| Stack, grid | Measuring children as a stack does | MeasureCx::measure, extent, stack |
| Panel | A border | Canvas::border (edges only) |
| Any node | Focus style over any node | .focus_style() works on every node, not only leaves |
The five gaps found in phase D:
- Hover per widget.
DrawCx::hovered()no longer subscribes to the whole hover path. The app marks a widget dirty only when the pointer enters or leaves it.DrawCx::pointer()gives the pointer's cell inside the widget, redrawing on each move over it.DrawCx::report_movement()turns on movement events without redrawing. The split pane uses that andHover(false)instead of asking for hover on every draw. - Keys with nothing focused go to the node under the pointer, then its ancestors.
- Layout runs each time a widget draws, before
draw, inside the same subscription; this is documented on the trait. - Focus, hover and resize events:
WidgetEvent::Focus(bool),Hover(bool)andResize { width, height }. They are queued and told after the event (or frame) that caused them, so a handler can move the focus without re-entering the app.WidgetEventisnon_exhaustive. - Helpers:
Canvas::markup,render,border,restyle,clearandscroll_up;widget::markup_widthandmarkup_line.
The benchmark gate is unchanged: at 80x24, a tick takes 14.4 µs against ratatui's 87 µs, with the same bytes sent as before the move.
Owning the loop: Driver¶
App::run owns the loop. App::driver(width, height) returns a Driver
for a loop of the caller's:
update(now)runs timers, animations, task results and watches, and drops toasts, with time given by the caller;render()returns the bytes for what changed;timeout(now)says how long the loop may wait;event(e)andresize(w, h)take input;take_copies()returns text selected with the mouse, andcopied(text)shows the toast once the loop has put it on the clipboard;finish()returns the bytes that restore the terminal;screen()andScreen::lines()give the frame as cells or styled segments.
run_on is now a loop over a Driver, so both paths share one code path.
examples/in_ratatui.rs hosts an app in half of a ratatui frame, with
ratatui owning the terminal and the loop.
rich_interact::event::from_crossterm is public for such loops. termion
and termwiz backends for run are tracked in #677 and #678.
Later: accessibility¶
Designed, not built. The retained tree already holds what a screen reader needs: each node's kind, label, focus state and the focus order. The plan:
- A semantic role per node (
button,list,listitem,tab,dialog,menu), set by the built-in nodes and by aWidget::role()method with a default. - An accessible text per node, from its label or a
.describe(text)builder, and the selected row or day for widgets that have one. - Announcements: the focus moving, a toast, a modal opening, sent to a sink. The first sink writes them to a side channel (a file or a socket) for a bridge; platform bridges (AT-SPI, UI Automation, the macOS accessibility API) come after, outside this crate.
- A text mode using rs-rich-ext's accessibility profile: no box drawing, no colour-only meaning, a linear reading of the screen.
Later: serving to a browser¶
Built in 0.0.18 as its own crate, rs-rich-web (not
rs-rich-intuituive-web), with a token in the URL and an Origin check
added to the design below; see
Serving an app to a browser. The design as
written: an app already draws through a backend that only
needs cells and events, which is what the headless backend implements, so
a web backend fits the same seam:
- A WebSocket backend sends the damaged cells of each frame (or the ANSI rs-rich already produces) to an xterm.js page and turns the page's key and mouse events back into rs-rich-interact events.
- A session per connection, each with its own
App, since the reactive runtime is per thread; a thread per session to start. - Off by default, behind a feature in its own crate
(
rs-rich-intuituive-web), so the framework gains no network code. It binds to localhost unless told otherwise and has no authentication of its own, so exposing it is the user's choice, behind a proxy that has.
The original note follows. Its status was: design note, for discussion. It
follows the intuiTUIve design spike, whose plan is done
except for Python, and the two ports that tested the framework on real
apps: the Yazi-style file manager (examples/files.rs) and the scope-tui
rebuild (examples/scope.rs).
Summary¶
- One foundation is missing: a public widget trait. Built-in nodes (stacks, grids, panels, lists, logs) are cases of a private enum. A user's control can only be one of these:
- a
leaf, which draws but cannot measure itself apart from drawing, hold children, or see the mouse; - a
component, a black box that cannot contain intuiTUIve nodes.
Nothing a user writes has the powers the built-ins have. A Widget
trait would give it them (measure, lay out children, draw, handle
events, take the focus, place the caret). Every other gap below can
then be built as an ordinary widget, by us or by users.
- Charts: yes, we need a ratatui-style chart, plus a canvas.
rs-rich-ext's LineChart decides its own axes, labels and legend. The
scope port therefore spent about 200 lines drawing its own plot (axes,
legend box, clipping, colour layers). We need:
- a Chart with configurable axes (bounds, titles, labels or none,
linear or log scale), data sets with marker types, and legend
placement;
- a Canvas with shapes and layers.
Both belong in rs-rich-ext, so the CLI and Python can use them too. - Then five widgets on the trait: a scroll container, an interactive table, a tab strip, anchored pop-ups, and mouse events beyond clicks (hover, drag, wheel). - The order matters. The trait comes first, because the others are built on it. Charts come second, and the scope example's plot re-ported onto them is the test. The five widgets can then be built in parallel.
What exists today¶
What a ratatui app reaches for, and where it is in intuiTUIve:
| ratatui | intuiTUIve today | Gap |
|---|---|---|
Block |
.panel(title), .padding(…) |
none |
Paragraph |
text, label (rich markup) |
it can't scroll; see Scrolling |
List + ListState |
list(items, selected), each(keys, build) |
none |
Table + TableState |
renderable(…) of rich's Table, or rs-rich-ext's VirtualTable |
rows can't be selected, and it has no sized columns or sticky header; see Table |
Tabs |
switch(key, build) swaps the content |
no tab strip; see Tabs |
Chart, Canvas |
renderable(…) of LineChart; DotCanvas by hand |
see Charts and canvas |
Gauge, LineGauge, Sparkline, BarChart |
rs-rich-ext's gauge, sparkline, bars, histogram, heatmap, KPI and timeline renderables, through renderable(…) |
none; they are documented as rich renderables, not as widgets |
Scrollbar |
none | see Scrolling |
Clear + a popup rect |
cx.modal(width, height, build), centred |
a pop-up can't be anchored to a node; see Pop-ups |
| Text input (tui-input, tui-textarea) | component(Input), component(TextArea) |
none |
impl Widget for MyType |
leaf, component, functions returning Node |
see The widget trait |
| Mouse events | on_click (left button), and full events inside a component |
no hover, drag or wheel on nodes; see Mouse |
Buffer cell writes |
a leaf returns styled lines | widgets can't write cells; see The widget trait |
1. The widget trait¶
Goal¶
A control written outside the crate can do everything a built-in node does:
- size itself;
- lay out children that are ordinary
Nodes; - draw cells directly;
- handle keys and mouse events in its own coordinates;
- take the focus and place the caret;
- redraw only when what it read changes.
This is the sanctioned extension seam, and it is how users "create their own controls and types".
Sketch¶
/// A node's behaviour. Built-in nodes implement it too, over time.
pub trait Widget: 'static {
/// What the inspector calls it (`"table"`, `"chart"`).
fn name(&self) -> &'static str;
/// Cells along `axis` the widget needs, given `avail` (a 0 on the other
/// axis: as much as it likes). Signals read here re-measure it when they
/// change. Default: draw at `avail` and count, as a leaf does today.
fn measure(&mut self, cx: &mut MeasureCx, axis: Axis, avail: (u16, u16)) -> u16;
/// Its children, for widgets that hold nodes.
fn children(&self) -> &[Node] { &[] }
/// Where each child goes inside `rect`, in the order of `children`.
fn layout(&mut self, cx: &mut LayoutCx, rect: Rect) -> Vec<Rect> { Vec::new() }
/// Draw itself (not its children) into `canvas`, which is clipped to the
/// widget's rectangle and uses its coordinates. Signals read here
/// subscribe the widget, as a leaf's do.
fn draw(&mut self, cx: &mut DrawCx, canvas: &mut Canvas<'_>);
/// A key, mouse or focus event, in the widget's own coordinates.
/// Unused events bubble to the parent, as keys do today.
fn event(&mut self, cx: &mut EventCx, event: &WidgetEvent) -> Used { Used::No }
/// Whether Tab can stop here.
fn focusable(&self) -> bool { false }
/// Where the text caret goes while it has the focus.
fn caret(&self) -> Option<(u16, u16)> { None }
}
/// A node from any widget; it takes every builder (`.flex`, `.panel`,
/// `.on_key`, `.name`).
pub fn widget(w: impl Widget) -> Node;
What each part gives¶
Canvasis a clipped, translated view of the screen. It has:set(x, y, char, style);print(x, y, text, style);lines(&[Vec<Segment>]), which is what a leaf does now;fill(rect, style).
Direct cell writes are what the scope plot needed. They skip building
segments and are the fast path for high-rate drawing. Damage stays the
widget's rectangle, so the painter needs no change.
- DrawCx gives the widget:
- the console and the theme;
- focused() and hovered(), which subscribe the widget like any
other read;
- redraw(), for internal state that is not a signal, such as an
animation frame.
- EventCx wraps today's Ctx (quit, push, modal, focus) and adds:
- redraw();
- capture_mouse(), which keeps drag events coming while the pointer
leaves the widget.
- Containers. A widget that returns children is a container. The
framework draws the children with today's damage logic (draw_children)
after draw, so a container draws its chrome first: a table header, a
scrollbar.
How it fits what exists¶
- The enum stays.
Kindgains aCustom(Box<dyn Widget>)case first, and every built-in keeps working. - Built-ins move onto the trait one by one: panel, pad and log first. This proves the trait expresses them, with the benchmark gate as the referee. Stack and grid stay native until the gate shows the trait costs nothing.
leafandcomponentstay. A leaf becomes a one-method widget internally.componentstays for rs-rich-interact components, which also run outside intuiTUIve. A widget is the richer choice inside an app, because it can hold nodes.
Decisions to take¶
- Object-safe trait (
Box<dyn Widget>) or generic nodes? Recommendation: object-safe, asKindalready boxes its cases. A virtual call per drawn widget does not show up next to rendering. - Should
measurehave a default? Recommendation: yes, the leaf behaviour (draw and count). Most widgets then write onlydraw.
2. Charts and a canvas¶
They live in rich_ext::chart, as renderables. They are library features
that the CLI (rich chart) and the Python bindings want too, so they don't
belong in the TUI crate (see AGENTS.md: library features go in rs-rich-ext).
Canvas (renderable)¶
- World coordinates:
x_boundsandy_bounds, as ratatui'sCanvas. - A marker per canvas or per layer:
- Braille (2×4 dots a cell, built on today's
DotCanvas); - half blocks (1×2);
- full blocks;
- a dot;
- ASCII.
- Shapes: points, line, polyline, rectangle, circle and a text label. Each is clipped to the bounds; the Liang–Barsky clipper from the scope example moves here.
- Layers in order, each with a style. A later layer's cell wins, as in the scope plot.
- Drawing with a closure:
Canvas::new().paint(|p| { p.line(…); p.layer(); … }).
Chart (renderable)¶
Axis:- bounds;
- a title;
- labels: none, automatic (n ticks), or a list given by the caller;
- scale: linear or log;
- a style.
The scope spectroscope's log frequency axis becomes Scale::Log,
instead of the app taking ln of every point.
- Dataset: a name, points, line or scatter, a marker and a style.
- Legend: top right (ratatui's default), top left, bottom, or none.
It hides itself when it would cover more than a set share of the plot.
- Layout: the same as ratatui's Chart, so ported apps look the same.
- LineChart stays as the automatic convenience, and becomes a preset
over Chart.
In intuiTUIve¶
renderable(move || Chart::new()…) already works, and redraws when the
signals it reads change. A small chart widget on the trait adds the
mouse: the pointer's position mapped to world coordinates, for a hover
read-out or zoom by dragging.
Acceptance¶
- Re-port the scope example's plot onto
Chart(and the spectroscope's grid lines ontoAxislabels). - The example loses its own plot drawing:
draw,clip, the legend box and the cell grid, about 200 lines. docs/tapes/scope.tapestill passes--check, or any change to its screenshots is explained.
3. Scrolling¶
A scroll(child) widget shows a viewport onto a child laid out at its
full height (or width):
- The offset is a signal the app can read and set.
- Arrows, PgUp/PgDn and Home/End scroll it while the focus is inside, and so does the mouse wheel over it.
- An optional scrollbar is drawn as the container's chrome.
- When the focus moves to a node outside the viewport, it scrolls that node into view.
Implementation decision. There are two options:
- (a) Lay the child out in a virtual space and translate and clip when drawing. This needs a clip rectangle in the frame state, and coordinates that can sit above the viewport.
- (b) Draw the child into an offscreen screen and copy the visible part.
Recommendation: (a). It keeps damage exact and costs nothing for rows out of view, while (b) renders everything.
Virtualisation. A child that knows its row count can draw only the
visible rows: a list, each, a table or a log. The scroll widget passes
the visible window down through MeasureCx and DrawCx. That is what
makes a 100,000-row list cost one screen.
4. Table¶
A table(rows, columns) widget, the equivalent of ratatui's Table with
TableState:
- Columns take
Sizeconstraints, solved with the layout module'ssolve, the same solver as rows and grids. - A sticky header, and a row selection signal (optionally a cell), moved by keys and clicks.
- Rows come from a closure over signals for small tables, or from
rs-rich-data's
VirtualRowsfor large ones. Large tables draw only the visible window, as rs-rich-ext'sVirtualTable(#260) does. - Hooks for sort indicators and click-to-sort, with styles from the
theme (
table.header,selected).
5. Tabs¶
A tabs(titles, selected) strip, paired with today's switch for the
content:
- ←/→ change the tab while the strip has the focus, Alt+number from anywhere.
- A click on a title selects it.
- Styles come from the theme.
- Dividers and highlight markers can be set, as in ratatui's
Tabs.
6. Anchored pop-ups¶
cx.popup(anchor, placement, size, build) opens a layer like a modal, but
next to a node rather than in the middle of the screen:
- It is placed below, above, right or left of the anchor.
- It flips when it would leave the screen.
- It closes on Esc, or on a click outside if asked to.
Dropdown selects, autocomplete lists, tooltips and context menus are built on it. The layer code that modals use already handles focus and redraw; only the placement is new.
7. Mouse¶
rs-rich-interact already reports every mouse event (Down, Up, Drag,
Moved, ScrollUp, ScrollDown). intuiTUIve only routes left clicks.
The proposal:
- Every event goes to the deepest node under the pointer, in that node's coordinates, and bubbles up like keys.
on_mouse(|cx, event| …)on any node, for apps that don't need a full widget.- Hover is state that the framework tracks and a widget reads (and is
subscribed to) in
draw, so a hover highlight redraws exactly the two nodes the pointer left and entered. - Drag capture, through
EventCx::capture_mouse. - Motion events are enabled only while something reads hover or captures the mouse. This keeps apps that don't use the mouse cheap.
8. Documenting what already works¶
The gauge, sparkline, bars, histogram, heatmap, KPI card, timeline, text
area and input are usable now, through renderable(…) and component(…).
The guide should list them as widgets, each with a line of code and a
screenshot.
Plan¶
| Phase | Work | Proves it |
|---|---|---|
| A | The Widget trait, Canvas, Kind::Custom, mouse routing and hover; panel, pad and log moved onto the trait |
The existing tests and the ratatui benchmark gate pass unchanged |
| B | Chart and Canvas in rs-rich-ext (with LineChart as a preset), and a chart widget |
The scope example re-ported onto them; its tape still checks |
| C | scroll, table, tabs, popup as widgets on the trait |
An example and tests each, and one more port of a table-heavy ratatui app (a candidate is bottom or gitui, credited as the earlier ports are) |
| D | Guide pages for every widget and for writing your own; recordings; Python bindings for widgets once the trait is stable | Docs build strict; tapes check |
Versioning:
- Phase A changes rs-rich-intuituive's public API (a new trait, and the mouse events), so it ships as rs-rich-intuituive 0.0.2 or later.
- Phase B adds to rs-rich-ext's public API, a minor bump under its own SemVer.
- Core rs-rich is not touched at any point.
Open questions¶
- Is an object-safe
Widgettrait right (recommended), or should widgets be generic? - Should built-ins move onto the trait (recommended, gradually), or stay as enum cases for good?
- Do
ChartandCanvasbelong in rs-rich-ext (recommended) or in the TUI crate? - Scrolling by translating and clipping (recommended), or offscreen?
- Should Python bindings for widgets come in phase D, or wait for the Python bindings of the framework itself?