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:
- Ctrl-C exits.
- A
Mouseevent goes to_dispatch_mouse, which finds the innermost handler whose region contains the position, matching by handlerkeywhere one was given, so a row that scrolled between press and release keeps its own click. - Tab moves focus, if there is more than one focusable.
- 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.