Skip to content

Writing a component

A component in turbodesk is a function. It takes ui, takes what it should display, and returns a View. There is no base class, no registration, no lifecycle, and nothing to instantiate or keep.

This page builds one end to end, then collects the conventions and the mistakes. The widget guide is the catalogue of what already exists; this is how to add to it.

The tutorial: a radio set

A radio set is one choice out of several. Arrow keys move the choice when it has the keyboard, and each row is clickable. It is small enough to fit on a page and large enough to need every part of the API a component uses.

Step 1: draw it

Start with the picture and no behaviour at all.

from turbodesk.runtime import UI
from turbodesk.view import Style, View

MARKED, UNMARKED = "(•)", "( )"


def radio_set(ui: UI, options, selected) -> View:
    t = ui.theme
    rows = [
        View.hcat([
            View.text(
                f"{MARKED if option == selected else UNMARKED} ",
                Style(fg=t.green if option == selected else t.overlay1),
            ),
            View.text(option, Style(fg=t.text)),
        ])
        for option in options
    ]
    return View.vcat(rows)

Two things are already doing work here.

Colours come from ui.theme, never from a literal. A component with Color.hex("#00ff00") in it looks wrong in eighteen of the nineteen flavours, and does not respond to ui.set_theme. The theme's role names (text, subtext0, overlay1, surface0, green, mauve, crust) are the vocabulary.

vcat pads its rows to the widest, so the result is a rectangle and the caller can put it next to something without measuring it.

Step 2: make it clickable

from functools import partial


def radio_set(ui: UI, options, selected, on_select) -> View:
    rows = [
        row(ui, option, selected).on_click(partial(on_select, option), key=option)
        for option in options
    ]
    return View.vcat(rows)

on_click takes the callback and an optional key. Pass the key. Without one, a click is matched by the screen region the press landed in, and a row that moves between the press and the release hands its click to whatever moved into that spot. With one, the identity travels with the row. This is not hypothetical; it is why key= exists.

partial(on_select, option) and not lambda: on_select(option), because a lambda in a loop closes over the variable and every row would select the last option.

Step 3: give it the keyboard

from turbodesk.events import Event, Key, KeyPress
from turbodesk.runtime import focus_tag


def radio_set(ui: UI, options, selected, on_select, *, focus=None) -> View:
    active = focus is not None and ui.focus(focus)

    def keys(event: Event) -> bool:
        if not isinstance(event, KeyPress):
            return False
        match event.key:
            case Key.UP:
                on_select(moved(options, selected, -1))
            case Key.DOWN:
                on_select(moved(options, selected, 1))
            case _:
                return False
        return True

    if active:
        ui.on_event(keys, focused=True)

    ...
    return view.tagged(focus_tag(focus)) if focus is not None else view

Four rules live in those lines:

  • focus is optional. With a key the component joins the focus ring and responds only when it holds the keyboard; without one it is decoration you can still click. Every built-in widget takes focus this way.
  • ui.focus(key) asks whether this component holds the keyboard. It also registers the key in the ring, so call it every frame.
  • Return True for events you handled and False for everything else. True consumes the event and the handlers behind you never see it. A handler that returns True unconditionally is how a component swallows the application's own keys, and the symptom ("this key does nothing") points nowhere near the cause.
  • .tagged(focus_tag(focus)) is what makes a click inside the component take focus. The runtime hit-tests the tag on the laid-out view. Forget it and the component is focusable by Tab and not by clicking.

Step 4: separate the part that can be tested without a screen

The wrapping arithmetic has nothing to do with views, events or the terminal, so it goes to module level as a plain function:

def moved(options, selected, step):
    """The option `step` places along, wrapping."""
    if selected not in options:
        return options[0]
    return options[(options.index(selected) + step) % len(options)]

That selected not in options branch is the reason to pull it out. Deciding what a radio set does when handed a selection that is not one of its options is a real question, it has an answer, and the answer deserves a test that does not have to render anything. column_widths in widgets/table.py and less_keys.action_for are the same idea at larger scale.

The whole thing

from collections.abc import Callable, Sequence
from functools import partial

from turbodesk.events import Event, Key, KeyPress
from turbodesk.runtime import UI, focus_tag
from turbodesk.view import Style, View

MARKED, UNMARKED = "(•)", "( )"


def moved(options: Sequence[str], selected: str, step: int) -> str:
    """The option `step` places along, wrapping. Pure: no `ui`, no `View`."""
    if selected not in options:
        return options[0]
    return options[(options.index(selected) + step) % len(options)]


def radio_set(
    ui: UI,
    options: Sequence[str],
    selected: str,
    on_select: Callable[[str], None],
    *,
    focus: object = None,
) -> View:
    """One choice from several. Up and down move when focused; each row is clickable."""
    t = ui.theme
    active = focus is not None and ui.focus(focus)

    def keys(event: Event) -> bool:
        if not isinstance(event, KeyPress):
            return False
        match event.key:
            case Key.UP:
                on_select(moved(options, selected, -1))
            case Key.DOWN:
                on_select(moved(options, selected, 1))
            case _:
                return False
        return True

    if active:
        ui.on_event(keys, focused=True)

    rows = [
        View.hcat([
            View.text(
                f"{MARKED if option == selected else UNMARKED} ",
                Style(fg=t.green if option == selected else t.overlay1),
            ),
            View.text(option, Style(fg=t.text, bold=active and option == selected)),
        ]).on_click(partial(on_select, option), key=option)
        for option in options
    ]
    view = View.vcat(rows)
    return view.tagged(focus_tag(focus)) if focus is not None else view

Forty lines, and the caller's side is one:

def app(ui: UI) -> View:
    keymap, set_keymap = ui.state("vim")
    return radio_set(ui, ("standard", "vim", "emacs"), keymap, set_keymap, focus="keymap")

Testing it

The pure helper is a table, in tests/a_unit/:

@pytest.mark.parametrize(
    ("selected", "step", "expected"),
    [("vim", 1, "emacs"), ("emacs", 1, "standard"), ("standard", -1, "emacs")],
)
def test_moving_wraps_at_both_ends(selected, step, expected):
    assert moved(KEYMAPS, selected, step) == expected


def test_a_selection_that_is_not_an_option_falls_back_to_the_first():
    assert moved(KEYMAPS, "dvorak", 1) == "standard"

The rendering and the behaviour go through a UI, in tests/b_integration/, using the screen and render fixtures from tests/conftest.py:

def test_a_radio_set_marks_the_selected_option(screen):
    def app(ui: UI) -> View:
        return radio_set(ui, KEYMAPS, "vim", lambda _: None)

    assert "(•) vim" in screen(app)
    assert "( ) standard" in screen(app)


def test_a_focused_radio_set_moves_on_the_arrow_keys(render):
    picked = []

    def app(ui: UI) -> View:
        return radio_set(ui, KEYMAPS, "vim", picked.append, focus="keymap")

    render(app, [KeyPress(Key.DOWN)])

    assert picked == ["emacs"]


def test_clicking_a_row_selects_it(render):
    picked = []

    def app(ui: UI) -> View:
        return radio_set(ui, KEYMAPS, "vim", picked.append, focus="keymap")

    render(app, [Mouse(MouseKind.LEFT, Pos(2, 0)), Mouse(MouseKind.RELEASE, Pos(2, 0))])

    assert picked == ["standard"]

A click is a press and a release at the same element. Sending only the press does nothing, which is correct and which surprises everyone once.

Assert on rendered text wherever you can. to_text(view) flattens a view to its characters, and an assertion on what the screen says survives a refactor that an assertion on the view tree does not.

The conventions

The caller owns the state. radio_set takes selected and on_select. The application usually already has that value, wants to save it, and wants to set it from somewhere else. A component that hides its state in a hook makes all three impossible. Reach for ui.state inside a component only for state the caller provably does not want (a scroll position, a cursor blink phase), and even then consider handing it back.

Return a View, unless the caller needs more than pixels. textbox, scroller, editor and tmux.pane return a small NamedTuple whose first field is view, because the caller also needs the value, the scroll position, or the cursor tag. Anything else returns a View.

Keyword-only after the required arguments. ruff's PLR0917 is deliberately not disabled, and it is what keeps this true. Call sites read for themselves and argument order stays out of the API.

Pure helpers at module level. Anything that is arithmetic, a key table, or formatting.

Docstring says what it responds to. "Up and down move when focused; each row is clickable" is what a caller needs, and it is not derivable from the signature.

Patterns you will need

State the caller genuinely should not see

top, set_top = ui.state(0)

Hook slots are matched by call order. A component that calls ui.state conditionally, or that appears and disappears from the tree, shifts every hook registered after it and hands one component's state to another. If the component can come and go, the caller wraps it:

with ui.scope("preview"):
    view = preview(ui, path)

ui.every and ui.task raise an error naming this rule when they detect it. ui.state cannot, so this is the one place to be careful.

Popups, and the click catcher

A pull-down or an autocomplete has to close when you click outside it, and a click inside has to reach it first. zcat puts the first view on top and View.hit returns the first matching handler, so:

View.zcat([
    popup,                                                    # on top, gets clicks
    View.blank(size.width, size.height).on_click(dismiss),    # catches everything else
    body,
])

View.blank is transparent, so the catcher is invisible and still clickable. Without it, dismissing on the press means the release lands where the popup no longer is, and the click never completes.

The cursor

ui.set_cursor(box.cursor_tag, kind="bar-blinking")

The terminal has one cursor. A component that wants it exposes a tag and lets the caller decide, which is what editor does. Two components both calling set_cursor is a bug the caller has to resolve, and it can only resolve it if they are asking rather than taking.

Finding where something landed

view.tagged(key) marks a subtree and view.find(key) gives back its Region after layout. This is how a component asks where it ended up on screen without the caller telling it.

Mistakes that cost an hour

Each of these is in notes/lessons-learned.md with the story attached.

Focus order is registration order. A filter box constructed before the table it is drawn above registers first and owns the keyboard, so every keystroke meant for the table gets typed into the filter. ui.prefer_focus(key) declares the answer. Reordering the code works and is invisible to the next person who moves two lines.

A closure that reads self._x breaks when self._x is rebound. Bind the object, not the attribute path.

A Literal is not a runtime check. An unchecked caller can still pass "dvorak". Whether a guard is needed depends on what happens next: if the bad value falls through to a default and behaves plausibly, a typo becomes a silent bug and the guard earns its place.

Cells are terminal columns, not characters. A double-width character occupies two cells. Use typography.string_width, never len, for anything that becomes a width.

Contributing one to turbodesk

Before opening a pull request:

  • Signature matches the conventions above: ui first, required arguments next, everything else keyword-only, optional focus.
  • No literal colours. Every style comes from ui.theme.
  • Pure helpers at module level, with unit tests that need no UI.
  • Integration tests for rendering, for keys, and for clicks.
  • Exported from turbodesk/widgets/__init__.py and added to __all__.
  • A row in the catalogue table in docs/src/guide/widgets.md.
  • make lint clean under all five checkers, with no # noqa and no # type: ignore.
  • Works at 40 columns and at 200. Fixed-height components should say so.

Where a component belongs. turbodesk/widgets/ is for anything an application composes into its own layout. turbodesk/ proper (view.py, runtime.py, events.py) is for things a component cannot do from outside, and the bar there is higher: check first whether an application can do it, and read notes/turbodesk-roadmap.md, which lists what is already known to be missing and why.