State and effects¶
Your app function runs again whenever something changes. Anything that must survive between runs is asked for by name, through a hook.
ui.state¶
def counter(ui: UI) -> View:
count, set_count = ui.state(0)
return View.text(str(count)).on_click(lambda: set_count(count + 1))
ui.state(initial) returns the current value and a setter. Calling the setter marks the
frame dirty, so the app runs again and count is the new value.
It is generic: ui.state(0) gives an int, ui.state(Buffer()) gives a Buffer.
Store immutable values
A new frame is scheduled only when the setter is handed something that compares unequal to what is already there. A list mutated in place and passed back is indistinguishable from no change at all, and the screen stops updating.
items, set_items = ui.state(())
set_items((*items, new)) # good: a new tuple
items.append(new); set_items(items) # BAD: nothing redraws
Prefer tuples, NamedTuples and frozen dataclasses, and build a new value each
time. The type parameter cannot express this, which is why it is
written down here.
Declaring what a slot holds¶
The type comes from the initial value, which is occasionally too narrow:
Give it a declared constant instead:
ui.reducer¶
When the next value depends on the old one through a transition function:
def apply(model: Model, action: str) -> Model: ...
model, dispatch = ui.reducer(Model(), apply)
dispatch("start")
apply must return a new state, for the same reason as above.
ui.scope: the rule that will bite you¶
Hooks are matched between frames by the order they are called in. That works only while the order is stable. A part of the app that appears and disappears (a screen, a tab, a panel behind a toggle) shifts every hook after it, and one widget gets handed another's state.
Wrap anything conditional in its own scope:
Inside the scope, hooks get their own slots keyed by current_screen. Leaving a scope
unrendered unmounts it: its state is dropped and its timers cancelled, like any widget
that stops being drawn.
Forget it and you get:
RuntimeError: ui.every() found AppCounts in hook slot 3. Hooks are matched between
frames by call order... A part of the app that appears and disappears needs its own
slots: `with ui.scope(key): ...`
This is the single sharpest edge in termopy, and any app with more than one screen hits it immediately.
Effects¶
ui.every(5.0, refresh) # run every 5 seconds, sync or async
ui.task(stream_logs) # run once, the first time this hook is reached
ui.spawn(coroutine) # fire and forget
every and task are hooks, so they follow the call-order rule too. Both are cancelled
when the widget that asked for them stops being rendered. A closed pane stops polling
without you arranging it.
Notices¶
A message in the corner that goes away on its own. kind is "info", "warn" or
"error".
Quitting¶
run returns whatever was passed to exit, so an app that picks something can hand it
back to the caller. Ctrl-C exits with None without your app doing anything.
The cursor and the theme¶
ui.set_cursor(tag, kind="bar") # place the hardware cursor on a tagged view
ui.set_cursor(None) # hide it
ui.set_theme(FLAVORS["dracula"]) # switch flavour for subsequent frames
set_cursor takes a tag, so the cursor follows whatever the tag ends up on after
layout. kind is block, bar or underline, each with a -blinking
variant. An editor uses this to put the real terminal cursor where its caret
is drawn, which is what makes it visible to screen readers and to terminal
copy-and-paste.
Suspending¶
To hand the terminal to something else (an editor, a pager, git commit):
The alternate screen is left, cooked mode restored and the cursor shown; on the way back
everything is retaken and the next paint is a full one. Outside a real terminal (under
testing.render) it does nothing, so you need not check.