Skip to content

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.

pip install rs-rich
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. The Canvas has the trait's methods: print, set, fill, clear, markup, render (any renderable), lines (rows of Segments), restyle, border and scroll_up, with cell(x, y) (a CellView, or None off the canvas), set_cell and fill_with for single cells.
  • draw_over(cx, canvas) draws after the widget's children, and overlapping() returning True lets children share an area, later ones on top, as layers does.
  • event(cx, event) returns whether it used the event. event.kind is "key", "key_up", "preview", "mouse", "paste", "focus", "hover" or "resize". cx.app() is the Ctx, to quit, open a screen or move the focus.
  • measure, layout, scroll, caret, role, cursor, access_state and describe work as in Rust. A container returns its nodes from children(). layout and MeasureCx.measure name a child by its index there.
  • name(), children(), focusable(), retained(), viewport() and previews_keys() are read once, when widget(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 a Driver method). 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) runs work() on a thread of its own, then done(result, cx) back on the app's thread, where it may write signals. work holds the GIL only while it runs Python code, so a time.sleep or 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 with loop= a loop of yours that runs on another thread (asyncio.run_coroutine_threadsafe).
  • Proxy, from cx.proxy() or app.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_with or rs_rich.tui.run raises 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

from rs_rich.tui import serve

serve("127.0.0.1:8080", lambda: App(build))

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_drop takes every value dragged from Python (Rust matches by type), so check the value's type in the handler. each and switch keys are any hashable (or ==-comparable) objects.
  • The console. Rust's DrawCx::console(), EventCx::console() and MeasureCx::console() hand out the app's rich::Console, which Python cannot hold. Use canvas.render, canvas.markup and cx.style instead. leaf(draw) calls draw(width, height) without it, and App::console is not bound.
  • Backends. run() uses crossterm. The termion and termwiz backends are off in the wheel, and App::run_on with a backend of your own is not bound: run (headless) and Driver cover the same ground.
  • Traits implemented in Python other than Widget: a PtyHost or WebEngine of your own, and an Announcer other 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_weekday and the header and day styles, and month. calendar_with takes today, min and max.
  • Smaller pieces: Widget::hidden_children, Widget::claims_key and MeasureCx::stack_flow (0.0.19), App::session_options (Python starts no session of its own), ScrollbarSymbols as a type (its sets are named by strings), the cell-level Screen (Driver.screen() gives the rows, Driver.lines() the styled segments), widget::markup_width and markup_line, and Easing::at.