Skip to content

Widgets

A widget is a function. It takes ui, whatever it needs to display, and returns a View. There is nothing to instantiate and nothing to keep.

from termopy.widgets import button, listbox, table, textbox

view = button(ui, "Save", on_press=save, focus="save-button")

Every widget takes an optional focus key. With one it joins the focus ring and only responds when it holds the keyboard; without one it is decoration you can still click.

All arguments after the required ones are keyword-only, so call sites read for themselves and parameter order is not part of the API.

The catalogue

Text and input

textbox(ui, *, focus, style, cursor_style, initial, width) single-line input; returns Textbox(view, value, set)
editor(ui, *, keymap, style, width, max_height, initial, single_line, caret, highlights, selection_style, group_undo, focus) multi-line editor, with standard, vim or emacs keymaps
spinner(ui, *, kind, style, interval) an animated dot or line spinner

Controls

button(ui, label, on_press, *, focus, style, accent) Enter or Space when focused; also clickable
checkbox(ui, label, checked, on_toggle, *, focus, switch) a checkbox, or a toggle with switch=True
progress_bar(ui, fraction, *, width, label, style) determinate bar
tabs(ui, items, selected, on_select, *, width, focus) a row of tabs; left/right move

Lists and tables

listbox(ui, rows, selected, size, on_move, *, on_select, on_delete, focus, empty) single-column list of Row(label, style, value); arrows and j/k, fixed height, scrolls itself
table(ui, columns, rows, selected, *, size, on_move, on_select, on_delete, on_sort, focus, empty, show_header) the same with Columns; rows are strings or Cell(text, style); on_sort gets a column index when its heading is clicked
tree.render(node, style) Leaf / Branch / Split with box-drawing connectors
ncdu.browser(ui, root, title, *, separator, on_leaf_select, focus) a weighted-tree browser

table's columns share the row out by weight, so you say which column deserves the space and not how wide the terminal is:

COLUMNS = [
    Column("name", weight=3),
    Column("state", width=9),          # fixed
    Column("size", align="right"),
]

Framing and scrolling

border(view, *, title, subtitle, line, style, title_style, subtitle_style, padding, hide) four line styles; hide="tr" leaves sides open
scroller(ui, view, size, *, crop_width, focus, stick_to_bottom) a window with less-style keys; follows the bottom for logs
scrollbar(position, height, *, track_style, thumb_style) the bar on its own
vim_status(position, style) Top / 50% / Bot, as vim shows it

The widgets that return more than a View hand back a small NamedTuple whose first field is view: Textbox(view, value, set), Scroller(view, position, inject, scroll_to, stuck_to_bottom), Editor(view, text, set_text, cursor, mode, cursor_tag) and Pane(view, send, ...).

The editor's keymaps

standard uses what the operating system already trained everyone on: arrows and Home/End, and a modifier with an arrow to move by word. Option is that modifier on macOS, Control almost everywhere else, and the editor accepts either. Control-Home and Control-End reach the ends of the document, and the same modifier with Backspace or Delete removes a word.

emacs adds Control-A, E, F, B, N, P, D and K, plus Meta-f, Meta-b and Meta-d. Those letters are deliberately absent from standard. A letter under Control is among the most contested keys in a terminal, and an editor holding focus would silently eat its host's bindings: Control-B costs examples/frogmouth its bookmarks pane. Modifier-plus-arrow collides with nothing, which is why standard can have it.

caret is where the cursor starts. The default is the end of initial, which is right for an input field the user is about to add to and wrong for a file: examples/turbo_python passes caret=0, because Turbo Pascal opens a file at 1:1. After that the caret belongs to whoever is typing, with one exception: Editor.set_caret moves it from outside, for going to a line number, jumping to a compiler error, or landing on a search hit.

Selections and undo. Shift with any movement key extends a selection, moving without it drops one, and typing over one replaces it. Editor.selection is the selected text and replace_selection swaps it, which is the whole of Cut, Copy, Paste and Clear once the caller keeps a string of its own: the widget has no idea what a clipboard is. select(start, end) puts a selection on a search hit from outside.

Editor.undo and redo walk a stack of whole-text snapshots, with can_undo and can_redo for greying a menu. Alt-Backspace undoes from inside the widget, which is the key Borland used and one a terminal reliably delivers. group_undo=True, the default, collapses a run of edits of the same kind into one step, so undoing a typed word takes it back whole.

One thing to know about the setters: they belong to the frame that produced them. Calling select and then replace_selection in the same frame makes the second one act on the buffer as it was before the first, because that is the buffer it closed over. In an app the two arrive on separate events and it never comes up.

vim opens in normal mode with hjkl, i, a, o, dd, x and the rest of the small vocabulary in widgets/editor.py. Editor.mode reports which mode it is in, so the app can ask for a block cursor:

ui.set_cursor(box.cursor_tag, kind="block" if box.mode == "normal" else "bar-blinking")

A tag alone does nothing. Until the app hands it to ui.set_cursor, the text is editable and the terminal shows no cursor at all.

Charts

bar_chart(bars, ...), line_chart(lines, ...) and scatter_chart(points, ...), taking Bar(label, value, color), Line(points, label, color) and Point(x, y, color, marker). All three are built on the braille Canvas, which addresses four times the resolution of one cell.

Other

dialog (modals), tmux.pane (embed another TUI), less_keys (the navigation key table on its own).

Writing your own

There is no base class. A widget is a function that returns a View:

from termopy.runtime import focus_tag


def field(ui: UI, label: str, value: str, on_change, *, focus=None) -> View:
    active = focus is None or ui.focus(focus)

    def keys(event: Event) -> bool:
        if active and isinstance(event, KeyPress) and isinstance(event.key, str):
            on_change(value + event.key)
            return True
        return False

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

    view = View.hcat([
        View.text(f"{label}: ", Style(fg=ui.theme.subtext0)),
        View.text(value, Style(underline=active)),
    ])
    return view.tagged(focus_tag(focus)) if focus else view

Two conventions worth following, because the built-in widgets do:

The caller owns the state. field above takes value and on_change. The app usually already has that value and wants to save it; a widget that hides it makes that impossible.

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 callers need the value, the scroll position or the cursor tag as well.