Skip to content

How it works

termopy is about 5,500 lines. This is the map.

The pieces

module lines what it is
view.py ~620 the View value type, cells, styles, colours, combinators
runtime.py ~650 UI, hooks, focus, event routing, the render loop
terminal.py ~230 raw mode, alternate screen, the row-diffing painter
events.py ~240 escape-sequence parser: bytes in, events out
markdown.py, typography.py, markup.py, ansi.py ~900 text rendering
theme.py ~310 eighteen colour flavours, twenty-six roles
rich.py ~150 Rich renderables into a View
widgets/ ~2,300 everything in the catalogue

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; termopy 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.