Skip to content

Controls

Controls are objects that can be returned from an element's on_input handler to instruct Counterweight to take some action during the render loop, such as gracefully quitting the application or playing the terminal "bell" sound.

A handler returns one control (return Quit()), or a tuple of controls, which Counterweight applies in order (return Bell(), Screenshot.to_file(path)), skipping any None in it, as merge skips None styles. Returning (), a tuple of no controls, says the handler handled the event with nothing more to do, and returning any control or tuple stops the event, as () does (see Events). Code outside a handler, such as an effect, sends controls with use_controls.

Returning controls

This app sets the terminal window's title to what's typed in its field when Enter is pressed:

@component
def title_editor() -> Div:
    draft, set_draft = use_state("")
    title, set_title = use_state("")

    def edited(outcome: EditOutcome) -> None:
        match outcome:
            case Edited(text=text):
                set_draft(text.strip())
            case _:
                pass

    def on_input(event: InputEvent, _: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.Enter):
                if not draft:
                    return Bell()
                set_title(draft)
                return SetTitle(draft)
            case KeyPressed(key=Key.Escape):
                set_title("")
                return RestoreTitle()
            case KeyPressed(key=Key.ControlO):
                return Suspend.to_edit_text(draft, on_edit=edited)
            case _:
                return None

    return Div(
        style=col | width(60),
        on_input=on_input,
        children=(
            text_input(draft, set_draft, placeholder="Window title", auto_focus=True),
            Text(content=f"Window title: {title or "the terminal's own"}"),
            Text(
                content="Enter: set · Escape: restore · Ctrl+O: edit",
                style=text_color("slate", 400),
            ),
        ),
    )

Each key it binds returns a different control:

  • Enter returns SetTitle with the field's text, or Bell() to complain when the field is empty.
  • Escape returns RestoreTitle(), which puts back the title the window had before the app started.
  • Ctrl+O returns a Suspend that hands the terminal to the user's editor to edit the title in, and calls edited when the editor exits.

The text field handles typing, and passes Enter, Escape and the Ctrl keys it doesn't edit with on to the Div around it. A screenshot can't show the window's title, so the app shows the title it set below the field:

The field holding "Build logs", and the window title set to it

╭──────────────────────────────────────────────────────────╮  
│ Build logs                                               │  
╰──────────────────────────────────────────────────────────╯  
Window title: Build logs                                      
Enter: set · Escape: restore · Ctrl+O: edit                   

Other pages show the rest of the controls at work:

Copying to the clipboard

CopyToClipboard copies text to the system clipboard, as a browser's navigator.clipboard.writeText does, for an app's own "copy" buttons and keys. Here Up and Down move through a list of commands, and Enter or a click copies one:

COMMANDS = (
    "git status",
    "git log --oneline --graph",
    "uv run pytest -x",
    "just docs-serve",
)


@component
def command_list() -> Div:
    selected, set_selected = use_state(0)
    copied, set_copied = use_state("")

    def copy(index: int) -> CopyToClipboard:
        set_selected(index)
        set_copied(COMMANDS[index])
        return CopyToClipboard(COMMANDS[index])

    def on_input(event: InputEvent, _: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.Up):
                set_selected(lambda index: clamp(0, index - 1, len(COMMANDS) - 1))
                return ()
            case KeyPressed(key=Key.Down):
                set_selected(lambda index: clamp(0, index + 1, len(COMMANDS) - 1))
                return ()
            case KeyPressed(key=Key.Enter):
                return copy(selected)
            case _:
                return None

    def command_row(index: int) -> Text:
        return Text(
            content=f"$ {COMMANDS[index]}",
            style=pad_x(1) | (text_bg("sky", 800) if index == selected else text_color("slate", 300)),
            on_input=on_click(lambda: copy(index)),
        )

    return Div(
        style=col | width(40),
        on_input=on_input,
        children=(
            Div(
                style=col | border | border_lightrounded,
                children=tuple(command_row(index) for index in range(len(COMMANDS))),
            ),
            Text(
                content=f"Copied: {copied}" if copied else "Enter or click to copy",
                style=pad_x(1) | text_color("emerald" if copied else "slate", 400),
            ),
        ),
    )

The handler that copies also sets state, so the app can say what it copied, since nothing else shows that the copy happened:

The second command selected and copied

╭──────────────────────────────────────╮  
│ $ git status                         │  
│ $ git log --oneline --graph          │  
│ $ uv run pytest -x                   │  
│ $ just docs-serve                    │  
╰──────────────────────────────────────╯  
 Copied: git log --oneline --graph        

The app asks the terminal to set the clipboard with OSC 52, so it works only where the terminal supports that, and the app can't tell whether it did. Some terminals turn it off, or ask before letting a program set the clipboard, and tmux passes it on only with set -g set-clipboard on. A headless app copies nothing, and a recording leaves the copy out, since playing it back in a terminal would set the viewer's clipboard.

To send copies somewhere else, such as to a list a test checks or to the system clipboard through a library, give app a clipboard_handler, which takes the text of every copy, headless or not, in place of the terminal's clipboard:

copied: list[str] = []
await app(command_list(), headless=True, autopilot=[KeyPressed(key=Key.Enter), Quit()], clipboard_handler=copied.append)

A CopyToClipboard given a handler of its own sends its text there instead, whatever app was given, as a Screenshot's handler chooses where its frame goes.

API

counterweight.controls.AnyControl module-attribute

Any control: what an input handler returns, what an autopilot sends, and what a use_controls sender sends.

counterweight.controls.Quit dataclass

Quit()

Cause the application to quit.

The quit occurs at the end of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before the application exits, and the last frame shows the state they set. That render starts no effects, since the application stops before they'd run. An Inline application leaves that frame in the terminal.

counterweight.controls.Bell dataclass

Bell()

Cause the terminal to emit a bell sound.

The bell occurs at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before the sound is played.

counterweight.controls.SetTitle dataclass

SetTitle(title: str)

Set the title of the terminal window. To set it when the application starts, pass title to counterweight._app.app.

The title is set at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before it changes. Line breaks in the title show as spaces, and other control characters as symbols, such as ␛ for an escape.

The application saves the title it started with, and restores it when it quits and while it's suspended, on terminals that support xterm's title stack (those that don't keep the last title set). RestoreTitle restores it while the application runs.

title instance-attribute

title: str

counterweight.controls.RestoreTitle dataclass

RestoreTitle()

Restore the title the terminal window had when the application started, undoing any SetTitle, on terminals that support xterm's title stack.

The title is restored at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before it changes.

counterweight.controls.CopyToClipboard dataclass

CopyToClipboard(
    text: str,
    handler: Callable[[str], object] | None = None,
)

Copy text to the system clipboard, as a browser's navigator.clipboard.writeText does, for an app's own "copy" buttons and key bindings.

The text is copied at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before it's copied, and the last copy in a cycle wins. Copying "" empties the clipboard.

The app asks the terminal to set the clipboard with OSC 52, so it works only where the terminal supports it, and the app can't tell whether it did. A terminal may turn it off, or ask before letting a program set the clipboard, and tmux passes it on only with set -g set-clipboard on (its default, external, ignores applications' requests). A headless app copies nothing, and a recording doesn't record the copy, since replaying it in a terminal would set the viewer's clipboard.

A handler takes the text in place of the terminal's clipboard, headless or not, as a Screenshot's handler chooses where its frame goes, and is awaited if it returns an awaitable; without one, app's clipboard_handler takes it, if it was given one. Either way nothing is written to the terminal, and an error the handler raises is logged rather than stopping the app.

text instance-attribute

text: str

handler class-attribute instance-attribute

handler: Callable[[str], object] | None = None

counterweight.controls.Screenshot dataclass

Screenshot(
    handler: Callable[[Frame], object],
    include_scrollback: bool = False,
)

Take a "screenshot" of the rendered UI, and pass it to the given handler callback function as a Frame, which the handler can encode as an SVG image or as text.

The screenshot is taken at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before the screenshot is taken (but the screenshot will still be of the UI from before the next render occurs!). Every screenshot requested in the same cycle with the same include_scrollback receives the same frame.

handler instance-attribute

handler: Callable[[Frame], object]

include_scrollback class-attribute instance-attribute

include_scrollback: bool = False

Whether the frame is everything the app's terminal holds, as a terminal shows it, rather than only what the app draws. For an Inline app, that's the rows that scrolled off the top of the screen, then the screen, with what the app printed above its region, and the region below it, where without it the frame is the region, whose coordinates use_rects reports. The app's terminal starts empty, with the region at its top, even when the app isn't headless, since the app can't read what the terminal showed before it started. A FullScreen app's alternate screen has no scrollback, so its frame is the same either way.

to_file classmethod

to_file(
    path: Path, include_scrollback: bool = False
) -> Screenshot

A convenience method for producing a Screenshot that writes to the given path, encoded according to its suffix: .svg for an SVG image, or .txt for text with ANSI escape codes for colors and styles.

PARAMETER DESCRIPTION
path

The path to write the screenshot to. Parent directories will be created if they do not exist.

TYPE: Path

include_scrollback

Whether the frame is everything the app's terminal holds (see include_scrollback).

TYPE: bool DEFAULT: False

RAISES DESCRIPTION
ValueError

If the path's suffix is neither .svg nor .txt.

to_stream classmethod

to_stream(
    stream: TextIO | None = None,
    ansi: bool = True,
    include_scrollback: bool = False,
) -> Screenshot

A convenience method for producing a Screenshot that prints the frame as text to the given stream.

PARAMETER DESCRIPTION
stream

The stream to print to. Defaults to sys.stderr.

TYPE: TextIO | None DEFAULT: None

ansi

Whether to include ANSI escape codes for colors and styles.

TYPE: bool DEFAULT: True

include_scrollback

Whether the frame is everything the app's terminal holds (see include_scrollback).

TYPE: bool DEFAULT: False

counterweight.paint.Frame dataclass

Frame(paint: Paint)

One rendered frame of the UI, as captured by a Screenshot.

Encode it with svg or text.

paint instance-attribute

paint: Paint

svg

svg() -> str

The frame as an SVG image, indented one space per level so that it diffs line by line.

text

text(ansi: bool = True) -> str

The frame as a grid of characters, one line per terminal row, with spaces for empty cells.

PARAMETER DESCRIPTION
ansi

If True, include ANSI escape codes for colors and styles, which show when the text is printed to a terminal. If False, produce plain characters, useful for layout debugging and tests.

TYPE: bool DEFAULT: True

counterweight.paint.SCREENSHOT_FOREGROUND module-attribute

SCREENSHOT_FOREGROUND = Color.from_name('white')

counterweight.paint.SCREENSHOT_BACKGROUND module-attribute

SCREENSHOT_BACKGROUND = Color.from_name('black')

The colors an SVG screenshot (Frame.svg) draws the terminal's default foreground and background in, where no terminal decides them: white text on black. Give them to an asciicast Theme to play a recording in the colors its screenshots show, or to rgb_of to find the RGB a cell's color is drawn in.

counterweight.controls.Print dataclass

Print(
    content: Component | AnyElement,
    stream: PrintStream = "stdout",
)

Print content, a component or an element, once, to one of the app's streams, as print does, where nothing changes it again: finished output, such as a log line, a test that passed, or a chat message that's done streaming.

content is rendered once, as React's renderToStaticMarkup does: its components run with each hook's initial state, their effects never start, and use_rects and use_scroll report nothing. It's laid out as wide as the stream it's printed to and as tall as it is, and each of its rows ends at its last cell that isn't blank, so a narrower terminal doesn't rewrap padding.

stream picks the stream, app's stdout or stderr, as print(file=sys.stderr) does. A stream that is the app's terminal, as both are when a program runs at a prompt with nothing redirected, shows it above an Inline app's region, in the terminal's scrollback. A FullScreen app's main screen isn't showing, so it holds what it prints there until it hands the screen back, when it stops or is suspended, and lays it out at the width the terminal has then. Any other stream is written to as it is: with its styles if it's a terminal, and as plain text if it isn't, such as a pipe or a file, laid out as wide as its content, since a pipe has no width to wrap at.

Every Print that arrives in a render cycle is printed in that cycle's frame, in order, beside the render the same events caused, so a handler that prints an item and drops it from the state the region shows does both in one frame.

content instance-attribute

content: Component | AnyElement

stream class-attribute instance-attribute

stream: PrintStream = 'stdout'

counterweight.controls.PrintStream

PrintStream = Literal['stdout', 'stderr']

Which of the app's two streams, app's stdout or stderr, a Print writes to.

counterweight.controls.Suspend dataclass

Suspend(handler: Callable[[TerminalStreams], object])

Suspend the application while the handler function is running, handing the terminal back to whatever started the application, and taking it again when the handler returns. The handler receives the terminal, as TerminalStreams.

The application will be suspended (and then resumed) at the beginning of the next render cycle, so all other events that are due to be processed in the current cycle will be processed before the application is suspended.

handler instance-attribute

to_shell classmethod

to_shell() -> Suspend

A Suspend that stops the application as a shell job, as Ctrl+Z stops a program in a terminal, so the shell takes the terminal back, until it resumes the application with fg. It's what Ctrl+Z does by default (see unclaimed_keys on counterweight._app.app).

It stops the application's whole process group, as the terminal's Ctrl+Z does, so whatever started the application and waits on it, such as uv run, stops too, which is what tells the shell to take the terminal back.

to_program classmethod

to_program(
    argv: Sequence[str],
    on_exit: Callable[
        [ProgramOutcome], object
    ] = lambda outcome: None,
    env: Mapping[str, str] = frozendict(),
    clear_env: bool = False,
    cwd: Path | None = None,
) -> Suspend

A Suspend that runs argv on the application's terminal until it exits, such as a pager or an editor, then calls on_exit with how it exited, or why it couldn't start, before the application takes the terminal back. See run_program for env, clear_env and cwd, and for what Ctrl+C and Ctrl+Z do while the program runs.

to_edit_file classmethod

to_edit_file(
    path: Path,
    on_exit: Callable[
        [ProgramOutcome], object
    ] = lambda outcome: None,
    editor: Sequence[str] | None = None,
) -> Suspend

A Suspend that edits the file at path in the user's editor on the application's terminal, then calls on_exit with how the editor exited, or why it couldn't start, before the application takes the terminal back. See edit_file for editor.

to_edit_text classmethod

to_edit_text(
    text: str,
    on_edit: Callable[[EditOutcome], object],
    file_name: str = "edit.txt",
    editor: Sequence[str] | None = None,
) -> Suspend

A Suspend that edits text in the user's editor on the application's terminal, then calls on_edit with the edited text, or how the edit failed, before the application takes the terminal back. See edit_text for file_name and editor.

counterweight.controls.ToggleBorderHealing dataclass

ToggleBorderHealing()

Toggle whether border healing occurs.

counterweight.controls.AdvanceClock dataclass

AdvanceClock(by: timedelta)

Move the application's clock forward by by, as if that much more time had passed, and render the components whose use_clock that makes due.

This is primarily useful in an autopilot, with a FrozenClock, to show how an app changes over time without waiting for it.

by instance-attribute

counterweight.controls.Marker dataclass

Marker(label: str = '')

Mark this point in the app's Recording with label, which asciinema-player shows as a chapter to jump to, such as each step of an autopilot's demo, or each view a user opens. It does nothing if the app isn't recording.

label class-attribute instance-attribute

label: str = ''

Focus

These controls move focus, which decides the element that key presses go to. The focus handlers return them for Tab and for presses.

counterweight.controls.FocusNext dataclass

FocusNext(within: ElementHandle | Subtree | None = None)

Move focus to the next visible component in the focus order, in tree order, wrapping from the last to the first. From nothing focused, or from focus outside within, it focuses the first. From a focused component that's out of the focus order (see in_focus_order on use_focus), it moves from that component's place in tree order. It does nothing if within has unmounted or holds no visible component in the focus order.

within class-attribute instance-attribute

within: ElementHandle | Subtree | None = None

The element whose focusable descendants, and itself if it's focusable, focus moves among, usually the subtree of the handler's receiver, or an element's handle, from code with no receiver, such as an effect, or None for the whole tree.

counterweight.controls.FocusPrevious dataclass

FocusPrevious(
    within: ElementHandle | Subtree | None = None,
)

Move focus to the previous visible component in the focus order, in tree order, wrapping from the first to the last. From nothing focused, or from focus outside within, it focuses the last. Otherwise it behaves like FocusNext.

within class-attribute instance-attribute

within: ElementHandle | Subtree | None = None

The element whose focusable descendants, and itself if it's focusable, focus moves among, by its subtree or its handle, or None for the whole tree.

counterweight.controls.FocusOn dataclass

FocusOn(handle: FocusHandle)

Focus the component whose use_focus has handle, whether or not it's in the focus order.

If no component can take focus with handle yet, such as one that mounts in the render a state change beside this control causes, it focuses that component in the next render, before the frame is drawn, as showModal focuses a dialog's field as the dialog opens, unless another control moves focus first. Key presses that arrive before that render still go to the component focused when they arrive. It does nothing if its batch of input causes no render, or no component can take focus with handle after that render either, because it hasn't mounted, has unmounted, is hidden, or is unfocusable.

handle instance-attribute

handle: FocusHandle

counterweight.controls.ClearFocus dataclass

ClearFocus()

Clear focus, so key presses go to the root component.

Scrolling

These controls scroll a scroll container. scroll_on_wheel returns ScrollBy for the wheel, and scroll_on_keys returns ScrollBy for the keys that scroll by a step or a page, and ScrollTo for Home and End. ScrollIntoView brings an element into view in every container above it: moving focus applies it to the newly focused element, use_text_area's handler returns it for the cursor's row, and a picker returns it for the highlighted option.

counterweight.controls.ScrollTo dataclass

ScrollTo(
    target: ElementHandle | Subtree,
    x: int | None = None,
    y: int | None = None,
)

Scroll the scroll container target names so its offset is x and y, as the web's scrollTo does, leaving an axis given as None where it is. The offset is clamped to the range the container scrolls through, from 0 to its max_offset, so a large y scrolls to the end.

It applies to the layout of the next render, before that render paints, after anchoring and sticking to the end, and in order with the other scroll controls returned since the last render, so a handler that returns it beside a state change scrolls the content that change lays out. It does nothing if target names no mounted, visible scroll container then.

target instance-attribute

The scroll container, by the ElementHandle passed to its handle, or by the subtree of a handler's receiver.

x class-attribute instance-attribute

x: int | None = None

y class-attribute instance-attribute

y: int | None = None

destination

destination(offset: Position) -> Position

Where this moves a container scrolled to offset, before clamping.

counterweight.controls.ScrollBy dataclass

ScrollBy(
    target: ElementHandle | Subtree, x: int = 0, y: int = 0
)

Scroll the scroll container target names by x columns and y rows, as the web's scrollBy does: positive values move toward the end of the content, and negative ones toward its start. The offset is clamped, and the control applied, as ScrollTo's is.

target instance-attribute

The scroll container, named as ScrollTo names it.

x class-attribute instance-attribute

x: int = 0

y class-attribute instance-attribute

y: int = 0

destination

destination(offset: Position) -> Position

Where this moves a container scrolled to offset, before clamping.

counterweight.controls.ScrollIntoView dataclass

ScrollIntoView(
    target: ElementHandle | Subtree,
    block: ScrollAlignment = "start",
    inline: ScrollAlignment = "nearest",
)

Scroll each scroll container above the element target names so the element's border box is in view, from the innermost container out, as the web's scrollIntoView does, aligning it as block says vertically and as inline says horizontally, with the web's defaults. On each axis, "start" puts the element's start edge at the start edge of the container's view, "center" its center at the view's center, and "end" its end edge at the view's end edge. "nearest" does whichever of "start" and "end" scrolls least, and doesn't scroll at all if the element is already wholly in view, or covers the whole view. Each offset is clamped to the range its container scrolls through.

It applies as ScrollTo does, to the layout of the next render, so a handler that returns it beside a state change, such as moving a highlight through a long list, shows the scroll in the same frame as the change. The element target names is the one with that element handle in the next render, so an element handle moved onto a newly highlighted element names the new one. It does nothing if target names no mounted, visible element then, and it stops at an element with clip_to_screen, since no container above that element clips it.

target instance-attribute

The element, named as ScrollTo names a scroll container.

block class-attribute instance-attribute

block: ScrollAlignment = 'start'

Where to put the element vertically.

inline class-attribute instance-attribute

inline: ScrollAlignment = 'nearest'

Where to put the element horizontally.

counterweight.controls.ScrollAlignment module-attribute

ScrollAlignment = Literal[
    "start", "center", "end", "nearest"
]

Where ScrollIntoView puts an element in a scroll container's view.