intuiTUIve from Python¶
rs_rich.tui is intuiTUIve from Python, in the rs-rich wheel on PyPI
(0.0.6 and later). It has the same API as the Rust crate, name for name, so
the rest of this guide reads the same in either language. Where the Rust
code has a closure, the Python code has a function or a lambda.
from rs_rich.tui import App, column, label, signal, text
def build():
count = signal(0)
return (
column([
text(lambda: f"[b]Count:[/] {count.get()}").panel("Counter"),
label("[dim]+ adds one · q quits"),
])
.on_key("+", lambda cx: count.update(lambda c: c + 1))
.on_key("q", lambda cx: cx.quit())
)
App(build).run()
As in Rust, build runs once and returns the root node. text reads
count, so pressing + redraws that one line.
From Rust to Python¶
| Rust | Python |
|---|---|
App::new(\|\| …) |
App(build): build() runs once, when the app is made |
text!("{count} items") |
text(lambda: f"{count.get()} items") |
count.update(\|c\| *c += 1) |
count.update(lambda c: c + 1) |
.on_key("q", \|cx\| cx.quit()) |
.on_key("q", lambda cx: cx.quit()) |
.class("warn") |
.class_("warn") (class is a Python keyword) |
Size::Fixed(3), Size::Auto |
Size.Fixed(3), Size.Auto, or 3 and "auto" ("40%" and "2fr" too) |
Easing::EaseOut, Placement::Below, Role::MenuBar |
Easing.EaseOut, Placement.Below, Role.MenuBar, or "ease_out", "below", "menubar" |
.align_items(Align::Center), .justify(Justify::SpaceBetween), .align_content(AlignContent::Stretch) |
.align_items("center"), .justify("space-between"), .align_content("stretch"): the stylesheet's CSS names |
Rect::new(x, y, w, h) |
Rect(x, y, w, h), or a tuple (x, y, w, h) |
Size::Ratio(1, 3), .ratio(1, 3) |
Size.Ratio(1, 3) or "1/3", .ratio(1, 3) |
.border(Border::Thick), .shadow(Shadow::Dim), .text_align(TextAlign::Center), .overflow(Overflow::Ellipsis) |
.border("thick"), .shadow("dim"), .text_align("center"), .overflow("ellipsis"), or Border.Thick and so on (Border.None_, since None is a keyword) |
.borders(Sides::TOP \| Sides::BOTTOM) |
.borders(Sides.TOP \| Sides.BOTTOM), or .borders("top bottom") |
.add_title(Edge::Bottom, Align::End, "v1") |
.add_title("bottom", "right", "v1"); a title's alignment is "left", "center" or "right" |
.theme_styles([("warning", "bold red")]) |
.theme_styles({"warning": "bold red"}), or a list of pairs |
layout::split(rect, Axis::Row, &tracks, flow) |
layout_split(rect, "row", tracks, gap=1, justify="center"): split is the split pane |
Date::new(2026, 10, 7) |
datetime.date(2026, 10, 7) |
Duration::from_millis(200) |
0.2 (seconds), or a timedelta |
text_area(text).language("rust").line_numbers() |
text_area(text, language="rust", line_numbers=True): the options are keywords |
spawn_future(future, done) |
spawn_async(coroutine, done) |
app.render_with(&["+", "q"], 30, 2) |
app.render_with(["+", "q"], 30, 2) |
App::run_on(&mut Headless::new(script, w, h)) |
run(app, script, width=w, height=h) |
list(..).direction(ListDirection::BottomToTop).highlight_spacing(HighlightSpacing::Always) |
.direction("bottom_to_top").highlight_spacing("always"): the variants' names in snake case |
Column::new("Qty", Size::Fixed(7)).align(Align::End).min(4) |
Column("Qty", 7, align="flex-end", min=4), or .align("end").min(4), which return a new column |
Scrollbar::new().side(Side::Left).symbols(ScrollbarSymbols::VERTICAL) |
Scrollbar().side("left").symbols("vertical"): each builder returns a new bar |
number_input(age, 0..=150) |
number_input(age, 0, 150): both ends included |
cx.modal_with(w, h, ModalOptions::new().shadow("on grey11"), build) |
cx.modal_with(w, h, ModalOptions(shadow="on grey11"), build) |
cx.quit_with(value) and App::run_for::<T>() |
cx.quit_with(value) (any object) and app.run_for(); a headless Run's exit |
cx.fail(error) |
cx.fail(error_or_message): run() raises it as an OSError |
Import the names you use (from rs_rich.tui import App, column, signal), or
the module (from rs_rich import tui): a from rs_rich.tui import * brings
in list and text, as Rust's prelude does, over Python's own list.
Signals hold Python objects¶
A signal holds any Python object. get() reads it (a node drawing, or a
memo computing, subscribes to it), and set(value) writes it, doing nothing
when the new value is equal (==) to the old.
update(f) sets the value to f(value). When f returns None, the
value is kept, and its readers are told all the same, which is how to
change a list or a dict in place:
items = signal([])
items.update(lambda xs: xs + ["new"]) # a new list
items.update(lambda xs: xs.append("x")) # the same list, changed
Setting a mutated object back with set changes nothing, since it is equal
to itself: use update.
The widgets that need a signal of a particular type take one of yours: a
list's or table's selection (an int), a split's ratio (a float), a
calendar's day (a datetime.date), a time picker's time (a naive
datetime.time), a date-and-time picker's moment (a naive
datetime.datetime; one with a time zone is a TypeError, and a picker's
min after its max a ValueError), a text area's text (a str), a
tree's path (a list of indices) and its expanded paths (a set of tuples), a
table's sort (None or (column, Order)). The first time a signal goes to such a widget it becomes one of
that type and keeps its value, and anything that read it before (a memo
made earlier) follows it. From then on, setting it to a value of another
type raises TypeError.
Custom widgets¶
Subclass Widget and make a node of it with widget(w), as a Rust app
implements the Widget trait:
from rs_rich.tui import App, Widget, widget
class Counter(Widget):
"""A counter that a click or `+` counts up."""
def __init__(self):
self.n = 0
def name(self):
return "counter"
def draw(self, cx, canvas):
canvas.print(0, 0, f"count {self.n}", cx.style("accent", "bold"))
def event(self, cx, event):
pressed = event.kind == "mouse" and event.mouse.is_press()
if event.kind == "key" and event.key == "+" or pressed:
self.n += 1
cx.redraw()
return True
return False
def focusable(self):
return True
App(lambda: widget(Counter()).on_key("q", lambda cx: cx.quit())).run()
draw(cx, canvas)is the one method to write. TheCanvashas the trait's methods:print,set,fill,clear,markup,render(any renderable),lines(rows ofSegments),restyle,borderandscroll_up, withcell(x, y)(aCellView, orNoneoff the canvas),set_cellandfill_withfor single cells.draw_over(cx, canvas)draws after the widget's children, andoverlapping()returningTruelets children share an area, later ones on top, aslayersdoes.event(cx, event)returns whether it used the event.event.kindis"key","key_up","preview","mouse","paste","focus","hover"or"resize".cx.app()is theCtx, to quit, open a screen or move the focus.measure,layout,scroll,caret,role,cursor,access_stateanddescribework as in Rust. A container returns its nodes fromchildren().layoutandMeasureCx.measurename a child by its index there.name(),children(),focusable(),retained(),viewport()andpreviews_keys()are read once, whenwidget(w)makes the node.- Methods you do not override keep the trait's defaults, and are not called in Python.
cx, canvas and every Ctx a handler gets are lent for that one call. A
widget may keep one, but using it after the call raises RuntimeError.
Effects¶
effect(child, f) calls f(canvas, cx) after the child draws, with the
same canvas and context a widget's draw gets:
from rs_rich.tui import effect, label
def shout(canvas, cx):
for x in range(canvas.width):
cell = canvas.cell(x, 0)
if cell is not None:
canvas.set_cell(x, 0, cell.symbol().upper(), "bold")
effect(label("quiet"), shout)
Threads and asyncio¶
- The app runs on the Python thread that called
run()(or aDrivermethod). While it waits for the terminal it releases the GIL, so other Python threads run. Each callback takes the GIL back while it runs. - Signals, memos, logs and resources belong to the app's thread. Using
one from another thread raises
RuntimeError. Nodes and apps cannot cross threads at all. spawn(work, done)runswork()on a thread of its own, thendone(result, cx)back on the app's thread, where it may write signals.workholds the GIL only while it runs Python code, so atime.sleepor a socket read in it does not stop the app drawing.spawn_async(coroutine, done)awaits a coroutine on an asyncio event loop, from a thread of its own: a new loop for the coroutine (asyncio.run), or withloop=a loop of yours that runs on another thread (asyncio.run_coroutine_threadsafe).Proxy, fromcx.proxy()orapp.proxy(), is the way in from a thread of your own:proxy.run(lambda: status.set("done")).
async def fetch():
await asyncio.sleep(1)
return "fetched"
def build():
status = signal("waiting")
spawn_async(fetch(), lambda body, cx: status.set(body))
return text(lambda: status.get())
Exceptions¶
A Python exception raised by any callback stops the app. That covers a handler, a node's function, a memo, a watch, a timer, a widget's method, a task's work or a proxy's function. When it raises:
- no more of that app's Python code runs, and the app quits at its next turn;
run(),render_withorrs_rich.tui.runraises the exception once the terminal is restored;- with a
Driver, the call that ran the callback (key,update,render, ...) raises it.
Two cases differ. A resource's fetch raising is the resource failing
(Load.error holds the message), not the app, as a Rust fetch's Err
is. A served app has no Python call to raise from, so its exception goes to
sys.unraisablehook and that tab's session ends.
Inside a terminal app, Ctrl+C is a key: it quits the app, unless a binding
takes it. It does not raise KeyboardInterrupt.
Testing¶
run(app, script) runs an app headless on a virtual clock and returns what
it painted. A script is a string of key names, or an rs_rich.interact
Script for text, pastes, clicks, drags, the wheel, resizes and waits:
from rs_rich.tui import App, Script, run, signal, text
def counter():
count = signal(0)
return (
text(lambda: f"Count: {count.get()}")
.on_key("+", lambda cx: count.update(lambda c: c + 1))
.on_key("q", lambda cx: cx.quit())
)
ran = run(App(counter), Script().keys("+ +").wait(1.0).keys("q"), width=30, height=1)
assert ran.finished # the app quit before the script ran out
assert ran.screen[0].startswith("Count: 2")
App.wait_for_tasks() waits for background tasks before each scripted
event, so a test sees a task's result however fast the machine is. For more
control, App.driver(width, height) returns the loop of your
own. It has update(now), render(),
timeout(now), key, text, paste, mouse, click, resize,
screen(), lines(), accessibility() (the accessibility tree, as
AccessNodes), take_announcements() and finish(). In linear mode,
render() returns the lines a screen reader would read. With
App.inspector() on, inspector_trace() returns what the
inspector's trace holds, as TraceEntrys (frame,
at in seconds, kind, text), inspector_timings() each node's
NodeTiming (name, draws, and total and last in seconds), and
inspector_selected() the id of the node it has selected. Signals made
and written from Python are named in the trace by where the bindings make
and write them, not by the Python line.
For a test, as in Rust, the driver also has
press("tab enter") and type_text("Ada") (each draws until there is
nothing left to draw, as settle() does), assert_focused(name), which
raises AssertionError saying where the focus is, focused() and
focused_name(). Snapshots take a path, since Python has no crate
directory:
from pathlib import Path
from rs_rich.tui import App, assert_screen, assert_screen_styled
SNAPSHOTS = Path(__file__).parent / "snapshots"
def test_the_counter_counts():
driver = App(counter).driver(20, 1)
driver.press("+ +")
assert_screen(driver, SNAPSHOTS / "counter_two.txt")
UPDATE_SNAPSHOTS=1 pytest writes the files instead of comparing. A
difference raises AssertionError with a unified diff.
assert_screen_styled(driver, path) also keeps <name>.styled.txt, the
rows as console markup, and assert_snapshot(path, text) compares any
text; driver.plain() and driver.styled() are the texts they store.
Serving to a browser¶
serve prints the URL to open, with its token, and serves one app per
browser tab until Ctrl+C. Each tab runs in a
process of its own: the server runs your
script again (sys.executable with the arguments it was started with, the
script by its absolute path), and
when that process reaches the same serve call it runs the tab's app
there. So the module-level code before serve runs once per tab, a crash
or a hang ends that tab alone, and an if __name__ == "__main__": guard
around serve is fine. In a REPL (Python's, python -m asyncio, IPython)
or a Jupyter kernel, with no script to run again, sessions run on threads.
Server(address, app) takes the options (max_sessions, token,
allow_origin, title, isolation("threads") to run every session on a
thread of this process, as tests do, and on Unix session_memory(bytes) (not macOS)
and session_cpu(seconds)), and spawn() serves in the background and
returns a handle to stop() (or a context manager). The
security model is the Rust crate's: a token, an origin check and a session
cap, and nothing beyond this computer unless a proxy with TLS and
authentication is in front.
Terminal panes and web views¶
terminal(command) runs a program in a pane, and web_view(url) shows a
page in a terminal browser, as in rs-rich-embed:
pane = terminal(["htop", "-d", "10"]).on_exit(lambda status, cx: cx.quit())
column([pane.node().panel("htop"), web_view("https://example.com").node()])
ReplayHost(output=…, exit=…) with terminal_with(host), and
ProgramEngine.with_hosts(make), stand in for a program in tests.
Examples¶
crates/rich-py/examples/tui has the counter, the tutorial's
to-do app, the planner (a menu bar, a tree, split panes, a calendar and a
virtual list of a hundred thousand rows), and the counter served to a
browser. Each runs with python crates/rich-py/examples/tui/<name>.py.
What is not bound¶
Everything the Rust guide shows is bound. What is left out has no Python counterpart, or a different one:
- Generic types.
on_droptakes every value dragged from Python (Rust matches by type), so check the value's type in the handler.eachandswitchkeys are any hashable (or==-comparable) objects. - The console. Rust's
DrawCx::console(),EventCx::console()andMeasureCx::console()hand out the app'srich::Console, which Python cannot hold. Usecanvas.render,canvas.markupandcx.styleinstead.leaf(draw)callsdraw(width, height)without it, andApp::consoleis not bound. - Backends.
run()uses crossterm. The termion and termwiz backends are off in the wheel, andApp::run_onwith a backend of your own is not bound:run(headless) andDrivercover the same ground. - Traits implemented in Python other than
Widget: aPtyHostorWebEngineof your own, and anAnnouncerother than a function (App.announcer(f)). The Chrome and Browsh engines are off in the wheel. - The calendar's 0.0.19 options:
CalendarOptions::event,events,surrounding,first_weekdayand the header and day styles, andmonth.calendar_withtakestoday,minandmax. - Smaller pieces:
Widget::hidden_children,Widget::claims_keyandMeasureCx::stack_flow(0.0.19),App::session_options(Python starts no session of its own),ScrollbarSymbolsas a type (its sets are named by strings), the cell-levelScreen(Driver.screen()gives the rows,Driver.lines()the styled segments),widget::markup_widthandmarkup_line, andEasing::at.