Skip to content

Input

Events

from turbodesk.events import Event, Key, KeyPress, Mouse, MouseKind, Paste

def keys(event: Event) -> bool:
    match event:
        case KeyPress(key=Key.ENTER):
            submit()
        case KeyPress(key="j"):
            move_down()
        case KeyPress(key=str() as ch) if ch.isprintable():
            type_character(ch)
        case _:
            return False
    return True

ui.on_event(keys)

Event is KeyPress | Mouse | Paste. A KeyPress carries key (a str for printable characters, or a Key enum member for named keys) and mods, a frozenset of Mod.CTRL, Mod.SHIFT, Mod.META.

Returning True consumes the event. Handlers behind it never see it. Returning False (or None) passes it on.

on_event also works as a decorator:

@ui.on_event
def fallthrough(event: Event) -> bool:
    ...

Focus

A widget that owns the keyboard joins the focus ring:

active = ui.focus("search-box")     # True if it currently holds the keyboard
if active:
    ui.on_event(handle_typing, focused=True)

focused=True handlers run before the app's own, whatever order they were registered in. A focused editor eats j wherever your global keymap was set up.

Tab cycles the ring and Shift-Tab goes back. Clicking inside a focusable takes it, provided the widget tagged itself:

from turbodesk.runtime import focus_tag

view.tagged(focus_tag("search-box"))

Focus order is registration order, not layout order

Whichever focusable is constructed first owns the keyboard by default, even when it is drawn at the bottom of the screen. A filter box built before the table it sits above will swallow every keystroke meant for the app, and the symptom is "this key does nothing", which points nowhere near focus.

Say which one should own it:

ui.prefer_focus("results-table")

Other focus controls: ui.focused (a property), ui.set_focus(key) and ui.focus_next(step), where a negative step moves backwards.

Focus groups

One ring for the whole screen is right until two parts of the screen are separately in use. With overlapping windows, Tab should walk the front window's controls and stop at its edge rather than wandering into whatever is behind it. ui.focus_group(key) says where the edge is:

with ui.scope(pane.key), ui.focus_group(pane.key):
    body = editor(ui, focus=("body", pane.key), ...)

Only Tab is affected. Clicking still reaches any focusable on the screen, which is how the keyboard gets into another group in the first place. Groups nest, and a widget belongs to the innermost one it is rendered in; anything drawn outside a group shares the one ring an ungrouped application has always had.

A group of one keeps its Tab key, the same way a lone focusable does. That is the point rather than an edge case: without groups, opening a second window would silently take Tab away from the first window's editor, because two focusables anywhere on the screen are enough to make Tab mean "move focus".

The modal stack is a group of its own underneath all of this, so a dialog's Tab was always confined to the dialog.

Mouse

view.on_click(lambda: pick(index))
view.on_click(callback, key=("row", index))    # stable identity
view.on_click_at(lambda ctx: pick_at(ctx.relative))
view.on_right_click(show_menu)
view.on_right_click_at(lambda ctx: popup(ctx.absolute))

Handlers carry their region, translated as the view is composed, so the click lands on whatever ended up at that position. The innermost handler wins.

The key matters for anything that moves. A row that scrolls between press and release would otherwise hand its click to whatever is now at those coordinates; with a key, a click is matched by identity instead.

The _at variants are handed a ClickContext with relative (inside the view) and absolute (on the screen). absolute is what you want to position a popup at; relative is what you want to know which cell was hit.

Hover

view.on_hover(lambda: describe(item), lambda: describe(None), key=("row", n))

enter fires when the pointer reaches the view and leave when it goes away — exactly once each. The runtime remembers the last hovered handler and compares by key across frames, because every handler is rebuilt each render; without a key the region is the identity, so a rebuilt element under a stationary pointer would re-enter on every frame.

leave also fires when the hovered element simply stops being drawn — a menu that closes under a stationary pointer gets no mouse event to say so, and the next frame is what notices.

A status line describing the menu item under the pointer is the first caller: menu.pulldown takes an on_hint, shell threads it through, and statusline draws it right-aligned in whatever space the key pairs leave.

Dragging

A drag belongs to the runtime, because it outlives the frame it started in. Mark the draggable region with a tag and say what to do with it:

ui.on_drag("titlebar", follow)
return frame.draggable("titlebar")

The callback is handed a Drag with phase ("start", "move", "end"), grab, start, pos and delta:

def follow(drag: Drag) -> None:
    set_position(Pos(drag.pos.x - drag.grab.x, drag.pos.y - drag.grab.y))

grab is where inside the element the press landed, and start is where on screen. Both are latched when the button goes down and never move, which is the whole point: the element is usually following the pointer, so anything measured against where it is compounds its own corrections, one report at a time. Every useful sum is a subtraction from a fixed point. To move something, pos - grab; to drag a corner, pos is the corner.

draggable marks the region with a handler, so a press finds its target the same way a click does: innermost and topmost wins, whatever order the targets were registered in. An editor inside a window you drag by its title bar gets its own presses, and the window gets the ones on its frame.

Anything drawn over a draggable region takes the press instead. "Over" means a handler that comes first without fitting inside the target: a close box on a window's own title bar is part of the window and still raises it, while a menu's click-away sheet covers the whole screen, so a press anywhere under an open menu belongs to the menu. That sheet is the overlay idiom already — a transparent full-screen catcher under the pull-down, so a click outside closes it — which is what makes one rule enough.

One edge is worth knowing: a region fits inside an identical one, so an overlay exactly the size of the draggable it covers does not block. Every overlay here is a full-screen catcher over a smaller target, so it does not arise; if it ever does, mark the overlay draggable under a tag nobody registered and the press stops there with no geometry involved.

A press that never moves still produces a start and an end with a zero delta, and on_click on the same region fires as usual: dragging and clicking the same element is the common case, so neither suppresses the other.

The callback is looked up by tag on every report, so it is always the current frame's. A callback captured at press time would close over state that is stale by the second mouse report.

Chords as text

turbodesk.keys names a chord in both directions, so a menu label and a binding table cannot drift apart:

from turbodesk import keys

keys.chord("ctrl+alt+f9")            # KeyPress(Key.F9, {CTRL, META})
keys.describe(KeyPress("s", CTRL))   # "Ctrl+S"
keys.describe(event, join="-")       # "Ctrl-S"

chord raises ValueError on an unknown modifier or key name, which turns a settings file's typo into a message at load time instead of a binding that silently never fires.

Alt on macOS

Terminal.app and iTerm send no Alt modifier by default. The Option key composes characters there, which is what it is for: Option-E then e is é, and Option-2 is on a US layout and something else on a German one.

There is no way to recover the chord from the character, and no application should try. The character depends on the keyboard layout, so any table is right for one layout and wrong for the rest; and where it is right, reading as Alt-X means the user can no longer type . Those keys belong to whoever is typing.

The fix is the terminal's, and it is one setting:

  • Terminal.app: Settings, Profiles, Keyboard, "Use Option as Meta key".
  • iTerm2: Settings, Profiles, Keys, Left Option key: Esc+.
  • Alacritty, kitty, WezTerm, Ghostty: already the default.

turbodesk parses the resulting Esc-prefixed sequences into Mod.META. What an app can do is offer a second key for everything it puts on Alt, so it works on a terminal nobody has configured. Turbo Python, built on turbodesk, ships two whole schemes and picks the Alt-free one on macOS.

Bindings

There is no BINDINGS list. A key table is a dict:

ACTIONS = {"q": quit, "r": refresh, Key.ESCAPE: back}

def keys(event: Event) -> bool:
    if not isinstance(event, KeyPress):
        return False
    action = ACTIONS.get(event.key)
    if action is None:
        return False
    action()
    return True

A focused text input takes every printable key

If your app has a text box that always holds focus (a console, a search field), d is a character. Give the user a way out that is not a letter: Esc, or a modifier.