Building your own components¶
rs-rich-interact 0.0.2 makes composition the way to build. The built-in
components are made of public pieces, containers are components too, and a
keymap records every key. You can write a component from the pieces, put it
beside the built-ins, and run the result like any other component.
This page covers:
- the kit of line helpers and state types;
- the containers (
Column,Row,Stack,Split,TabsandLayers); - how focus and events travel between them;
- the keymap registry;
- components written in Python and shipped by plugins, which compose with the built-ins the same way.
A complete example is in the repository:
crates/rich-interact/examples/custom_component.rs.
It builds a checklist from kit pieces and composes it with Select,
Input and Confirm in a split, tabs and two modals. To run it:
The kit¶
rich_interact::kit holds what the built-ins are made of.
Line helpers build the lines of a View:
| Helper | What it does |
|---|---|
text(s, &style), plain(s) |
make one segment |
width(&line) |
measure a line in cells |
fit(line, n) |
crop a line to n cells |
pad(line, n) |
crop or pad a line to exactly n cells |
slice(&line, from, to) |
the cells from..to of a line |
overlay(&base, x, &top) |
draw top over base from cell x |
restyle(&line, &style) |
put a style over a line (a backdrop) |
frame(lines, inner, title, &style) |
box lines with a rounded border and a title |
place(&mut lines, x, y, &boxed, backdrop) |
draw a box over a view, dimming it first if asked |
highlight(label, positions, base, matched) |
style the characters a filter matched |
question(&theme, prompt) |
the ? prompt › line every built-in starts with |
pasted(s, newline), shown(s) |
make untrusted text safe to show |
State types remember things between events. They share the built-ins' movement and editing rules, and none of them renders anything:
| Type | Holds | Used by |
|---|---|---|
ListState |
the cursor over rows, the first row shown, and a selection by index | Select, MultiSelect |
ScrollState |
an offset into lines, with the pager keys and the wheel | Viewport, Pager, Confirm |
FilterState |
a query, and the candidates that match it, ranked by the fuzzy matcher, with the positions to highlight | Select and the pickers |
TextBuffer |
one line and a caret that moves by grapheme cluster: insert, Backspace, Delete, Ctrl+U, Ctrl+W, and an undo point | Input, Form |
Divider |
a border between two panes that the mouse drags and the keyboard nudges, with minimum sizes | Split, Select's preview |
ActionMenu |
the Ctrl+K menu of actions for one target | Select, overlay::Menu |
A checklist that filters as you type needs only three of these. The list below is abridged from the example:
use rich_interact::keymap::{keys, Keymap};
use rich_interact::kit::{self, FilterState, ListState, Theme};
use rich_interact::{Component, Context, Event, Flow, KeyCode, View};
pub struct Checklist {
items: Vec<String>,
filter: FilterState,
list: ListState,
keymap: Keymap,
}
impl Checklist {
pub fn new(items: Vec<String>) -> Checklist {
let mut list = ListState::new();
list.set_len(items.len());
Checklist {
filter: FilterState::new(items.iter().cloned()),
items,
list,
keymap: Keymap::new("checklist")
.bind("up", keys("up"), "move up")
.bind("down", keys("down"), "move down")
.bind("tick", keys("space"), "tick or untick")
.bind("done", keys("enter"), "finish"),
}
}
}
impl Component for Checklist {
type Output = Vec<String>;
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<Vec<String>> {
let Some(key) = event.key() else { return Flow::Ignored };
match self.keymap.action(key) {
Some("up") => self.list.step(-1, 8),
Some("down") => self.list.step(1, 8),
Some("tick") => {
if let Some(index) = self.filter.index(self.list.cursor()) {
self.list.toggle(index);
}
}
Some("done") => {
let ticked = self.list.selected();
return Flow::Done(ticked.into_iter().map(|i| self.items[i].clone()).collect());
}
_ => match key.code {
KeyCode::Char(c) if !key.modifiers.ctrl => {
self.filter.push(c);
self.list.set_len(self.filter.len());
self.list.reset(0, 8);
}
// Not ours: the container may want it.
_ => return Flow::Ignored,
},
}
Flow::Continue
}
fn render(&self, context: &Context<'_>) -> View {
let theme = Theme::default();
let mut lines = vec![kit::question(&theme, "Todo")];
for position in self.list.visible(8) {
let (index, matched) = &self.filter.matches()[position];
let mut line = vec![kit::plain(if self.list.is_selected(*index) { "◉ " } else { "○ " })];
line.extend(kit::highlight(&self.items[*index], matched, None, &theme.matched));
lines.push(kit::fit(line, context.width));
}
View::new(lines)
}
fn keymap(&self) -> Keymap {
self.keymap.clone()
}
}
The built-ins also expose their parts. Select has filter(), list(),
divider() and menu() for its state, event() to handle an event as it
does, and list_lines() and footer() to draw part of it. replace_items,
focus_item, set_values, set_heading, set_prefixes, set_hidden,
set_hints and set_steady are the hooks TableSelect, TreeSelect,
FilePicker and AssetPicker use to build on it. Input exposes its
buffer(), field(), resolve() and suggestion_rows(), as Form uses
them.
Containers¶
Every container is a Component, so containers nest, and you can pass a
whole composition to run, an EventLoop or headless::run. Children of
one container share its output type. A child whose output differs is adapted
with ComponentExt::map, which says what its answer means for the whole:
use rich_interact::compose::ComponentExt;
use rich_interact::{Flow, Input, Select};
// Picking finishes the whole composition with the file.
let picker = Select::new("File", ["a.rs", "b.rs"]).map(|file| Flow::Done(file.to_string()));
// Answering records the name and carries on; the input stays, showing
// its answer, and focus moves past it.
let name = Input::new("Name").map(|_name| Flow::Continue);
Cancelling a child cancels the composition, unless .on_cancel(...) says
otherwise. Label (markup) and Painted (a closure) show something without
taking focus.
| Container | Lays out |
|---|---|
Column, Row, Stack |
children along an axis, each Size::Fixed(n), Size::Flex(weight) or Size::Auto (in a column, the rows it renders), with a gap |
Split::horizontal(a, b), Split::vertical(a, b) |
two panes with a border between them. The mouse drags the border (with_mouse(true)); Alt+H and Alt+L move it side by side, Alt+K and Alt+J stacked. ratio, at, min and mins set its position and minimums |
Tabs |
one child at a time under a bar of titles. Every tab keeps its state. Alt+Right and Alt+Left (or Ctrl+PageDown and Ctrl+PageUp) switch tabs, Alt+1 to Alt+9 pick one, and a click on a title shows it |
Layers |
a base with Layer::modal and Layer::popover over it |
A modal dims the base, traps focus (Tab cycles inside it) and ignores
clicks outside it. A popover has no backdrop, and a click outside closes
it. Either layer takes the keys while it is open. Escape dismisses the top
layer when the layer does not use Escape, and a layer that cancels is
dismissed rather than cancelling the host. A layer whose child answers
closes, and the answer goes up, so a mapped Flow::Done finishes the host.
Layers open with Layers::open_on(action, keys, description, factory), or
from anywhere through a LayerHandle:
use rich_interact::compose::{ComponentExt, Layer, Layers, Split, Tabs};
use rich_interact::keymap::keys;
use rich_interact::{Confirm, Flow, Select};
#[derive(Debug)]
enum App { Picked(String), Quit }
let files = Select::new("File", ["a.rs", "b.rs"]).map(|f| Flow::Done(App::Picked(f.into())));
let more = Select::new("More", ["c.rs"]).map(|f| Flow::Done(App::Picked(f.into())));
let app = Layers::new(Tabs::new().tab("Files", files).tab("More", more))
.open_on("quit", keys("ctrl+q"), "quit", || {
let confirm = Confirm::new("Quit?")
.map(|answer| if answer == "yes" { Flow::Done(App::Quit) } else { Flow::Continue });
Layer::modal(confirm).title("Quit").size(30, 6)
});
Focus, routing and bubbling¶
Each child gets a Rect of its container's space and a Context of that
size. It renders into that size and handles events at it.
- Keys and pastes go to the focused child. A child that does not use an
event returns
Flow::Ignored, and the event bubbles. The container tries its own bindings, then passes the event to its own container. At the top, the event loop treatsIgnoredasContinue. Every built-in returnsIgnoredfor the keys it does not use. - Mouse events go to the child under the pointer, in the child's own coordinates, if the child asked for the mouse. A press holds that child until the release, so a drag stays with it. A click focuses the child it lands on.
- Resizes and ticks reach every child. A container ticks as often as its most frequent child asks.
- A hand-off's
Event::Returnedgoes back to the child that handed off. - Tab and Shift+Tab (the
focus-nextandfocus-previousbindings) move focus through every focusable child in order, into and out of nested containers. At the top, focus wraps round.
Focus keys bubble like any other key, so a child that uses Tab keeps it. A
multi-select marks with Tab, and an input with a suggestion showing
completes with it. A container can change the order by implementing
Component::focus_step and Component::focus_enter. A container can also
take a key before its children do, with shortcut(...), or after they
leave it, with on(...):
use rich_interact::compose::{Column, FOCUS_NEXT};
use rich_interact::keymap::keys;
use rich_interact::{Flow, Input};
let form = Column::new()
.child(Input::new("Name").map(|_| Flow::Continue))
.child(Input::new("Email").map(|_| Flow::Continue))
.rebind(FOCUS_NEXT, keys("f6 tab"))
.on("save", keys("ctrl+s"), "save", || Flow::Done(()));
A component that is not a container needs nothing extra. It takes focus by
default, and focusable() returns false for one that shows only.
The keymap¶
A component declares its keys in a Keymap. Each Binding has an action,
the keys that trigger it, a description and a context (select, input,
split, tabs, ...). The component then asks what a key means with
keymap.action(key), instead of matching the key itself.
Component::keymap() returns the bindings that apply now. A container's
keymap lists its focused child's bindings first, then its own. The help
overlay, the shortcut sheet, the command palette and the status bar's
hints read that list (see Overlays and chrome), and the
example's F1 dialog lists keymaps the same way:
for binding in app.keymap().bindings() {
println!("{:<20} {}", binding.keys_label(), binding.description);
}
You can rebind keys at two levels:
- one component:
Select::rebind("down", keys("ctrl+j")),Input::rebind(...), andrebindon every container, which coversfocus-nexttoo; - the whole process:
keymap::install(overrides).Overridesholdscontext.action = keyspairs, set in code withOverrides::set, or read from text (Overrides::parse) or from a configuration table (Overrides::from_pairs). This is how a configuration file, such as the CLI's, can rebind any component:
# one per line; `none` unbinds
select.down = ctrl+n, down
tabs.next = alt+l
split.grow = alt+.
select.pick = enter, "#" # quote a key that is a comma or a hash
overlays.shortcuts = none
A # at the start of a line or after a space starts a comment, so the hash
and comma keys are written in quotes ("#", ","). A line with no keys is
an error rather than an unbind, so a stray # or , cannot unbind an
action by accident.
Every built-in dispatches through its keymap, so rebinding changes what it
does: Select (and the views built on it), Input, Confirm, Pager,
TextArea, ColorPicker, Form, FilePicker, Viewport and every
container. Each has a rebind of its own, and its context is the one its
keymap() lists (confirm, pager and pager-search, textarea,
color, form with form-choice and form-toggle, file). A
Confirm's choices are actions choose-ID (a choice's key answers in
either case); rebinding a Form's next away from Enter also takes
Enter's submit on the last field, and Ctrl+S still submits. A
FilePicker's own actions (open, up to the parent directory, hidden)
rebind on the picker only; write select.up (and so on) for its list's.
A key rebound onto an action takes it from any action that only declared
it in the same context: after Pager::rebind("scroll-down", keys("n")),
n scrolls, and next-match has no key until you give it one.
Testing a composition¶
Drive a composition headless, the same way as a single component.
Script::drag and Script::click reach the mouse. The example's tests are
in crates/rich-interact/tests/compose.rs, and a PTY test runs the example
in a real terminal:
use rich_interact::headless::{self, Script};
let script = Script::new().keys("down space alt+right alt+left enter");
let (outcome, record) = headless::run(app(), script, 72, 16);
assert!(record.frames.iter().any(|frame| frame.contains("Where to?")));
Components from Python¶
The Python package (rs_rich.interact, from PyPI rs-rich 0.0.3) has the
same model. Subclass Component, and put it in the same containers as the
built-ins:
| Method | Returns | Default |
|---|---|---|
render(context) |
a renderable (a str is console markup) for context.width × context.height |
none: you write it |
handle(event) |
None to carry on, Done(value), Cancel(), or Ignored() to let the event bubble |
Ignored() |
keymap() |
a Keymap of the keys it uses, for help and hints |
None |
focusable(), mouse(), tick() |
whether Tab stops on it, whether it wants the mouse, how often it wants a "tick" (seconds) |
True, False, None |
start(context), default_value() |
a flow before the first paint; the answer without a terminal | None |
The containers are Column, Row, Stack, Split, Tabs and Layers
(with Layer.modal and Layer.popover), plus Label and Map. A
built-in's answer finishes the whole composition, as its Python value.
Map(component, done) says otherwise: done(value) returns None to carry
on, or Done(...) to finish.
from rs_rich.interact import Component, Done, Ignored, Keymap, Select, Split
class Counter(Component):
def __init__(self):
self.count = 0
self.keys = Keymap("counter").bind("up", "up k", "count up").bind("done", "enter", "answer")
def handle(self, event):
action = self.keys.action(event)
if action == "up":
self.count += 1
elif action == "done":
return Done(self.count)
else:
return Ignored() # Tab bubbles up and moves focus
def render(self, context):
return f"count [bold]{self.count}[/]"
def keymap(self):
return self.keys
app = Split(Counter(), Select("File", ["a.rs", "b.rs"]), ratio=40)
print(app.headless("up tab down enter", width=60, height=6).value) # b.rs
It runs through the same drivers as a single component: ask, run,
headless and degrade. The GIL is released between events and taken
back for each call into Python. An exception from handle, render, a
Map or binding callback, or a layer factory ends the run, and the call
that started it raises the exception. See
Interactive components in
the Python docs.
Components from plugins¶
A plugin registers a component by name, and an app mounts it beside the
built-ins. The plugin depends on rs-rich-plugin-api and core only, so it
implements that crate's small contract, rich_plugin_api::component:
PluginComponent::handle(event, context)gets aComponentEvent: a key by name ("up","ctrl+k"), a paste, a resize, a tick, the mouse or a link. It returns aComponentFlow:Continue,Done(text),CancelorIgnored;render(context)returns aComponentViewof core segments;bindings(),focusable(),tick()andmouse()have defaults.
The plugin registers a factory, which makes a fresh component for each mount:
rich_interact::plugin::PluginView mounts it from a registry. Its answer
is the plugin's text; map it into your container's output like any other
child:
use rich_interact::compose::{ComponentExt, Split};
use rich_interact::plugin::PluginView;
use rich_interact::{Flow, Select};
let registry = rich_ext::ExtensionRegistry::with_linked_plugins()?;
let counter = PluginView::mount(®istry, "counter")?; // UnknownComponent lists the names
let app = Split::horizontal(
counter.map(Flow::Done),
Select::new("File", ["a.rs", "b.rs"]).map(|file| Flow::Done(file.to_string())),
);
The plugin's bindings are listed under its registered name, so the help
overlay shows them and configuration rebinds them (counter.up = k); a
rebound key reaches the plugin as the key it declared. A plugin component
that panics does not take the app down. Its pane shows the panic, and it
stops taking focus and events.
Components come from plugins compiled into the program: added with
add_plugin, or linked with export_plugin!. A runtime plugin (a native
library or a WASM module) exchanges text through a stateless ABI, which has
no component kind in this release. See Plugins.