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 turbodesk.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, indent, gutter, marks, history, focus).find, .replace_all, .select, .undo 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
radio_set(ui, options, selected, on_select, *, focus) one choice from several; up/down move, rows are clickable
progress_bar(ui, fraction, *, width, label, style) determinate bar
tabs(ui, items, selected, on_select, *, width, focus) a row of tabs; left/right move
rule(width, *, style, label) a horizontal line, with an optional label sitting in it

Lists and tables

listbox(ui, rows, selected, size, on_move, *, on_select, on_delete, on_toggle, focus, empty) single-column list of Row(label, style, value); arrows and j/k, fixed height, scrolls itself. on_toggle binds Space
selection_list(ui, rows, selected, size, on_move, *, checked, on_toggle, focus, empty) the same with a check box on every row; checked holds the ticked indices
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
directory_tree(ui, entries, selected, size, on_move, *, on_open, on_toggle, focus, dir_style, empty) a browsable directory; entries comes from directory.rows(root, expanded)
ncdu.browser(ui, root, title, *, separator, on_leaf_select, focus) a weighted-tree browser

directory_tree splits into a pure half and a drawn one, and the pure half is the one worth knowing. directory.rows(root, expanded, *, show_hidden=False) turns a root and the set of open directories into the lines that should be on screen; the widget draws them through listbox, so selection and scrolling come free. A directory is read only when it is expanded, so the cost is what is on screen rather than what is on the disk, and an unreadable one closes that branch rather than the view.

from turbodesk.widgets import directory, directory_tree

entries = directory.rows(root, expanded)
view = directory_tree(ui, entries, selected, size, set_selected,
                      on_open=lambda entry: load(entry.path),
                      on_toggle=lambda entry: set_expanded(
                          directory.toggled(expanded, entry.path)))

Enter opens a file and toggles a directory, Space toggles either. The caller owns root and expanded, so both can be saved between runs.

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"),
]

Desktops and menus

windows overlapping windows as pure geometry: opened, closed, raised, cycled, moved, placed, sized, cornered, zoomed, tiled, cascaded, refitted, at, find
menu.bar(menus, open_index, width, style, on_open, *, indent) a menu bar, every title clickable
menu.pulldown(items, selected, style, enabled, on_pick, *, on_hint) the open menu with its shadow; enabled decides what greys, on_hint reports the row under the pointer
menu.move / first_item / by_letter / menu_for the arithmetic for walking one from the keyboard
frame(body, *, style, title, active, number, zoomed, footer, grip, on_close, on_zoom, on_scroll, vertical, horizontal) window chrome: border, title, close and zoom boxes, a footer, a resize grip, and a scroll bar that replaces the edge when there is more to see
frame.shadowed(view, dim) a shadow that darkens what is under it rather than hiding it
frame.desktop(size, style) the hatched backdrop windows sit on
statusline(items, width, *, style, hint) the foot of the screen: Entry(key, label, action) pairs, each clickable, with a right-aligned hint
shell(*, desktop, menus, opened, set_opened, picked, set_picked, greyed, on_pick, body, status, menu_style, status_style, on_hint, hint) the three bands together: menu bar, desktop, status line
commands.dispatch(table, name, *, close) run the command name, if the table has one; True when it did

turbodesk.windows is not a widget: nothing in it draws, and a window is a numbered rectangle with a title. A stack goes in and a new stack comes out, front to back, which is the order zcat wants:

stack = windows.opened(stack, "editor", "hello.py", bounds)
stack = windows.raised(stack, key)
View.zcat([*(draw(w) for w in stack), backdrop])

Menus are data, and an item whose command is missing from the application's table draws grey:

MENUS = (Menu("~F~ile", (Item("~O~pen", "file.open", "F3"), Item("E~x~it", "file.exit"))),)

menu.pulldown(MENUS[0].items, picked, MenuStyle.of(ui.theme), commands.__contains__, run)

examples/windows.py is both of them together, with ui.on_drag for the mouse.

One table, read three ways

commands.dispatch is the piece that keeps a menu honest. The application holds one mapping from command id to function; an id that is absent draws grey, and an id that is present runs. The menu, the keyboard and the status line all read the same mapping, so a shortcut can never do something its own menu item says is unavailable:

table = {"file.open": open_file}          # `file.exit` is missing, so it greys
if not commands.dispatch(table, name, close=lambda: set_opened(None)):
    ...                                    # nothing ran; the key is still up for grabs

close runs first, because picking a command always dismisses the thing it was picked from. The bool it returns is for a key handler that has to say whether it consumed the event; a click has nowhere to report it and ignores it.

The whole screen

shell() composes the three bands a Turbo Vision–style application is made of, and owns no state: which menu is open and which row is highlighted live in your hooks and are passed in.

return shell(
    desktop=bounds,
    menus=MENUS,
    opened=opened, set_opened=set_opened,
    picked=picked, set_picked=set_picked,
    greyed=table.__contains__,             # asked about every command id
    on_pick=run,
    body=lambda: desktop_view,
    status=[Entry("F10", "Menu", open_menu)],
    menu_style=MenuStyle.of(ui.theme),
    status_style=StatusStyle.of(ui.theme),
)

It hangs the open pull-down under its own title, clamped so it cannot run off the right edge, and puts a transparent sheet over the desktop beneath it so a click anywhere else closes the menu. That sheet is also what stops a press reaching a draggable region underneath.

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

scroller and scrollbar take a ScrollPosition(offset, max_offset); border's line is one of LINE_TYPES.

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), Pane(view, send, ...) and Editor, which carries enough for an application to build a whole Edit menu out of:

view, text, cursor, mode, cursor_tag what it drew, what it holds, where the caret is, and the tag ui.set_cursor wants
set_text, set_caret replace the text; jump to an offset (a compiler error, a search hit)
selection, select(start, end), replace_selection(text) Cut, Copy, Paste and Clear are these plus a string you keep
find(needle, *, backwards, ignore_case, whole_words, regex), replace_all(needle, fresh, *, ignore_case, whole_words, regex) see Finding and replacing
undo, redo, can_undo, can_redo the last two are for greying the menu items

Finding and replacing

box.find("needle")                              # selects the hit; False if there is none
box.find("needle", backwards=True, ignore_case=True)
count = box.replace_all("old", "new")

find selects the match rather than only putting the caret on it, so the hit is visible instead of somewhere on the line. It searches on from the caret and wraps, which is what makes calling it again walk the file, and the caret leads in the direction of travel — a backwards search that landed the caret at the match end would find the same match for ever.

replace_all returns how many it changed and records one undo step; finding nothing changes nothing and costs no undo.

Both go through re with the needle escaped, so a needle full of metacharacters is literal and a replacement containing \1 is two characters. That is also why case-insensitive search is exact: lower-casing both sides would return offsets pointing at the wrong characters for any letter whose lower case is a different length.

box.find("count", whole_words=True)             # not "account", not "counter"
box.find(r"def \w+\(", regex=True)              # re.error if it will not compile

whole_words needs a word boundary at both ends. regex reads the needle as a pattern — and only the needle: the replacement stays literal either way, so \1 is still two characters. A pattern that does not compile raises re.error rather than returning no match, which is what lets a Find dialog say "that is not a pattern" instead of "not found".

For search without a widget, search(text, needle, start, *, backwards, wrap, ignore_case, whole_words, regex) and replaced(text, needle, fresh, *, ignore_case, whole_words, regex) are the pure functions underneath, and pattern_for(needle, ...) is what both compile.

Syntax colouring

from turbodesk.syntax import SyntaxStyle, highlighter

editor(ui, highlights=highlighter("python", SyntaxStyle.of(ui.theme)), ...)

highlights= is any function from the text to a list of Highlight(start, end, style) in character offsets — matching brackets, search hits, a diff. highlighter returns exactly that function, for a language. language is a Pygments lexer name — python, rust, sql — and an unknown one raises at the first call rather than quietly drawing everything plain.

Four kinds, because that is what a terminal palette tells apart at a glance: comments, literals, keywords, and the names a language defines for you.

Two things make a per-frame hook affordable, and they are why this is a library module rather than a copy in every application. The lex result is cached on the text, as kinds rather than styles, so scrolling costs nothing and changing theme repaints without lexing again. Above MAX_HIGHLIGHT characters the colouring switches off, because 20 ms a keystroke is a sticky keyboard and plain text is not. Pass limit= to move that line.

Pygments is not a turbodesk dependency: pip install turbodesk[syntax] adds it, and the module says so if it is missing.

The editor's gutter

gutter=True numbers the lines. marks puts one character beside a line, keyed by document line from 0:

editor(ui, gutter=True, marks={12: Mark("●", Style(fg=theme.red))}, ...)

Both come out of width, so the widget stays the size you asked for and the text wraps a few columns sooner. Passing marks at all reserves its column, empty or not — a column that appeared with the first breakpoint would reflow the file under the person setting it, so marks={} is a meaningful argument.

A wrapped line is numbered once, on the row it starts. A press in the gutter puts the caret at the start of that line rather than a few characters into it.

The editor's indentation

indent is one level, as a string, so indent="\t" is a file indented with tabs and nothing downstream has to know which it is:

  • Enter carries the current line's leading whitespace onto the new line. Only the whitespace before the caret, since what follows it moves down on its own.
  • Tab inserts a level at the caret; Shift-Tab takes one off the front of the line.
  • With a selection, both shift every line the selection touches and leave the block selected, so the key works pressed twice. Blank lines keep out of it, and a selection ending on a line break stops at the line before — dragging down three lines lands the caret at the start of the fourth, and shifting a line nobody highlighted is a surprise.

A tab character counts as one level however wide indent is, so outdenting a tab-indented file takes one tab rather than four spaces' worth of it.

Tab only reaches the editor when it is alone in its focus group: the runtime moves focus with it first. That is what an input field on a form wants, and what a file being typed into does not — so give a window's editor a group of its own.

The editor's mouse

Pressing puts the caret where you pressed; dragging selects what you cross. Past the end of a short line is that line's end, and a drag that wanders off the widget goes on selecting to the nearest end rather than stopping. Nothing to wire up: editor() registers its own ui.on_drag target.

That target covers the whole widget, so the registration-order warning applies. An editor nested inside something else draggable loses the mouse to whichever target was registered first, and an untagged overlay drawn over the editor does not stop a press from reaching it.

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.

Fuzzy matching

turbodesk.fuzzy is the matcher behind dialog.pick, and it is usable on its own:

fuzzy.match("fb", "src/foo/bar.py")          # -> Match(text, score, positions) | None
fuzzy.ranked("fb", names, limit=200)         # -> best first

The query's characters have to appear in order; where they appear decides the score. A character starting a word is worth most, a run of adjacent characters next, and depth in a path costs a little. Matching is leftmost-greedy rather than optimal — scoring the best alignment is a dynamic program, and nothing has needed one yet.

History lists

An input can remember what has been typed into it before:

editor(ui, single_line=True, history=("first", "second"), ...)

walks back through the list, walks forward, and past the newest gives back whatever was being typed before the walk started. Typing over a recalled line ends the walk: an entry you have edited is a new line, not an old one. The list is oldest first, the way you accumulate one by appending, and you own it — appending on submit is the application's business, the same as the text. dialog.prompt(history=) and dialog.Field(history=) pass it through.

It is read only with single_line, where those two keys otherwise do nothing. In a multi-line editor they move the caret and go on doing so.

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: 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 turbodesk.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.

Writing a component is the long version: a radio set built from nothing, its tests, the patterns for popups and cursors, and the mistakes worth knowing about first.