Skip to content

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:

path, set_path = ui.state(())          # T is `tuple[()]` — refuses anything later

Give it a declared constant instead:

NO_PATH: tuple[int, ...] = ()
...
path, set_path = ui.state(NO_PATH)     # T is tuple[int, ...]

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:

with ui.scope(current_screen):
    body = SCREENS[current_screen](ui)

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.

ui.now(tick=1.0)            # the current time, redrawing once a second

Notices

ui.notify("Saved")
ui.notify("Server unreachable", kind="error", seconds=5)

A message in the corner that goes away on its own. kind is "info", "warn" or "error".

Quitting

ui.exit()              # stop the render loop
ui.exit(chosen_file)   # `run(app)` returns this

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):

with ui.suspend():
    subprocess.run([editor, path])

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.