Skip to content

How it works

turbodesk is about 9,300 lines, half of it widgets. This is the map.

The pieces

The core, src/turbodesk/, 4,900 lines over twenty-three modules and __init__.py. view imports only geometry, style and cells, each of which imports nothing of ours; everything else imports view. The graph is four levels deep and acyclic.

module lines what it is
runtime.py 888 UI, hooks, focus, event routing, the render loop
view.py 581 the View value type, the combinators, and the handlers and tags that travel inside a view
style.py 185 Color, Style, and the arithmetic that degrades them to 256 and to 16
cells.py 77 how many columns a glyph takes; markdown and typography measure without building a view
geometry.py 45 Size, Pos, Region
markdown.py, markup.py, ansi.py, typography.py 844 text into views
theme.py 327 nineteen colour flavours, twenty-six roles
windows.py 316 overlapping windows: stacking, moving, sizing, tiling
events.py 261 escape-sequence parser: bytes in, events out
terminal.py 249 raw mode, alternate screen, the row-diffing painter
syntax.py 178 Pygments spans in character offsets, behind an optional extra
keys.py 155 naming a chord, in both directions
rich.py 150 Rich renderables into a View
fuzzy.py 138 subsequence matching and the score a picker ranks by
shell.py, commands.py, layout.py, clipboard.py, testing.py, debug.py 420 the app skeleton, one command table, layout arithmetic, OSC 52, the headless renderer, logging
widgets/ 4,447 everything in the catalogue

Measured 2026-08-28 at 0.3.2. These numbers rot; find src/turbodesk -name '*.py' | xargs wc -l is how to re-derive them.

The frame

run(app)
  └ Terminal.__enter__            raw mode, alternate screen
    └ loop:
        parse stdin  ──────────►  events
        ui.dispatch(event, view)  route to handlers, focus, clicks
        view = ui.render(app)     ← your function runs here
        terminal.present(grid)    diff against the last frame, write what changed

ui.render is the interesting part:

def render(self, app):
    self._slot_index = 0            # hooks are matched by call order
    self._event_handlers = []       # handlers are re-registered every frame
    self._focusables = {APP: [], MODAL: []}
    self._dirty = False

    view = app(self)                # your function

    self._settle_focus()            # focus fell off screen? hand it on
    if self._modals:
        view = self._with_modal(view)
    if self._notices:
        view = zcat([notice, view])
    self._release_slots(self._slot_index)   # hooks not reached this frame unmount
    self._release_scopes()
    return view

Nothing survives a frame except hook slots. Handlers, focusables and tags are rebuilt from scratch, which is why a widget that stops being drawn stops receiving input without anyone unsubscribing it.

Hook slots

ui.state, ui.reducer, ui.every and ui.task share one list per scope, indexed by a counter that resets each frame. ui.state(0) means "give me slot 3", not "give me the value named x".

That is why the call order must be stable, and why ui.scope(key) exists: a subtree that comes and goes gets its own list, keyed by key, so it cannot shift the indices of anything after it. Scopes not visited during a frame are dropped and their timers cancelled.

Two details worth knowing if you touch this:

The setter closures bind the list, not self._slots. scope rebinds that attribute, so a setter called after its scope had been left would write into whichever list was current.

_release_slots only drops trailing slots. A hook vanishing from the middle shifts every index behind it, which breaks the call-order rule. ui.every and ui.task raise there, with a message naming the fix.

Painting

Terminal.present compares each row against the previous frame and writes only the rows that differ, preceded by a cursor-position escape. Style changes within a row emit an SGR sequence; runs of identical style do not.

Colours degrade at write time: sgr(style, depth) maps a truecolor Color down to xterm-256 or the ANSI 16 palette depending on what the terminal advertises.

Passthrough regions are painted after the cell pass, and are diffed on their emitted string, because the cells beneath them are blank and compare equal every frame.

Input

events.Parser is a byte-at-a-time state machine: CSI sequences, SGR mouse reports, bracketed paste, and a 30 ms timeout to tell a bare Esc from the start of an escape sequence.

UI.dispatch routes an event:

  1. Ctrl-C exits.
  2. A Mouse event goes to _dispatch_mouse, which finds the innermost handler whose region contains the position, matching by handler key where one was given, so a row that scrolled between press and release keeps its own click.
  3. Tab moves focus, if there is more than one focusable.
  4. Otherwise: focused handlers first, then app handlers, in registration order, until one returns True.

Focus

Two scopes, APP and MODAL. A modal gets its own ring and the app's is untouched underneath, so dismissing a dialog puts the keyboard back where it was.

Within a scope, the first focusable to register takes the keyboard unless prefer_focus named a different one. _settle_focus runs after the app function: if whatever held focus is no longer on screen, it is handed on.

Design rules

Four, and they explain most of the code:

A View is a value. Immutable, enforced by __setattr__. Every combinator returns a new one. This is what makes rendering testable.

Nothing survives a frame except hook slots. If you find yourself wanting to cache something across frames, it goes in a slot or it does not exist.

Rich renders content; turbodesk composes views. Anything about measuring, wrapping or styling text can be delegated. Anything carrying handlers, tags or hit-testing cannot.

A rule enforced only by a docstring is not enforced. Learned the hard way, several times: see notes/lessons-learned.md in the repository.