Skip to content

Events

Handling Input

Div and Text elements take an on_input handler, which Counterweight calls with key presses, pastes, and mouse events, along with a Receiver describing the element the event is delivered to. A handler matches on the event, so one case can cover a key and a mouse event alike:

def on_input(event: InputEvent, _: Receiver) -> InputResponse:
    match event:
        case KeyPressed(key=Key.Space) | MouseDown(button=1):
            set_is_open(toggle)
            return ()
        case _:
            return None

The library handlers cover the common cases, such as on_click for a press released inside the element, and scroll_on_wheel and scroll_on_keys for a scroll container.

A handler's return value says whether the event was its to handle:

  • None means it wasn't, and the event bubbles on to the element's parent.
  • Anything else means it was, and the event stops there. That is a control for Counterweight to apply (return Quit()), a tuple of controls applied in order (return Bell(), Screenshot.to_file(path)), or (), a tuple of no controls, when there's nothing else to do. A None inside a tuple is skipped, so a control can be conditional (return Bell() if is_full else None, Screenshot.to_file(path)), and a tuple still stops the event when every item in it is None.

Return () (or the controls) from every case that does something, so an ancestor that handles the same event doesn't act on it too.

Don't chain handlers with or

() is falsy, so handler_a(event, receiver) or handler_b(event, receiver) calls handler_b even when handler_a handled the event. Compose handlers with first and each instead.

A handler that only cares about keys still receives mouse events, including every movement of the pointer over its element. One that matches many keys can return early on anything else, which keeps its cases about keys alone:

def on_input(event: InputEvent, _: Receiver) -> InputResponse:
    if not isinstance(event, KeyPressed):
        return None

    match event.key:
        case Key.ControlC:
            return Quit()
        case Key.Tab:
            set_tab(lambda t: (t + 1) % len(TABS))
            return ()
        case _:
            return None

The receiver describes the element the event is delivered to. Its rectangles tell a handler where in the element a mouse event landed:

def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
    match event:
        case MouseDown(absolute=position, button=1):
            set_clicked(receiver.relative(position))
            return ()
        case _:
            return None

Its is_focused, focus_handle and subtree serve the focus handlers: whether the element is focused, the focus handle that names its component if the component is focusable, and the element and its descendants, for moving focus among them. The subtree also names the element for the scroll controls, and scroll says how far the element is scrolled if it's a scroll container, which scroll_on_keys reads to tell how far a page is and whether it's reached an end.

counterweight.events.InputEvent module-attribute

InputEvent = KeyPressed | Pasted | MouseEvent

Any event an input handler receives.

counterweight.events.InputResponse module-attribute

InputResponse = (
    AnyControl | tuple[AnyControl | None, ...] | None
)

What an input handler returns: None to pass the event on to the element's parent, or a control or a tuple of controls to stop it there and have the app apply them in order, skipping any None, with () to stop it with nothing more to do.

counterweight.events.InputHandler module-attribute

InputHandler = Callable[
    [InputEvent, Receiver], InputResponse
]

An element's on_input: what it does with an input event that reaches it, given the element as a Receiver.

counterweight.events.Receiver dataclass

Receiver(
    rects: Rects,
    is_target: bool,
    focus_handle: FocusHandle | None,
    is_focused: bool,
    subtree: Subtree,
    scroll: Scroll | None,
)

The element an input event is being delivered to.

rects instance-attribute

rects: Rects

The receiving element's rectangles, as use_rects returns them.

is_target instance-attribute

is_target: bool

Whether the receiving element is the event's target, rather than an ancestor the event bubbled to.

focus_handle instance-attribute

focus_handle: FocusHandle | None

The FocusHandle of the receiving element's component, if the element is the one its component returned and the component called use_focus, or None.

is_focused instance-attribute

is_focused: bool

Whether the receiving element is the focused one. For a key press or a paste it's the same as is_target, except when nothing is focused: the root component's element is then the target, and no receiver is focused.

subtree instance-attribute

subtree: Subtree

The receiving element and its descendants, for FocusNext and FocusPrevious to move focus within, for ScrollTo and ScrollBy to scroll it, and for ScrollIntoView to bring it into view.

scroll instance-attribute

scroll: Scroll | None

How far the receiving element is scrolled, if it's a scroll container, or None if it isn't: its scroll as of the last render, as use_scroll reports it, moved by each ScrollTo and ScrollBy returned since, and clamped to its range as of that render, so a handler later in a batch of input sees what the ones before it scrolled.

relative

relative(position: Position) -> Position

position relative to the top-left corner of the receiving element's content box.

A handler that calls another, as the autocomplete recipe calls a text field's, reads what it returned with response_controls, which turns any response into a tuple of controls, or None if the inner handler didn't handle the event.

counterweight.events.response_controls

response_controls(
    response: InputResponse,
) -> tuple[AnyControl, ...] | None

The controls a handler's response holds, skipping any None in a tuple, or None if the handler didn't handle the event.

Routing Input Events

Each input event has one target element. Counterweight calls the target's on_input handler, then each of its ancestors' in turn, up to the root component's element, until one returns something other than None. The ancestors are the element tree's, not the screen's, so an absolutely positioned child bubbles to its parent even where it's drawn outside it. Each element receives the same event, with its own receiver: a parent's rects are its own, and its is_target is False.

In this example, every element on an event's path writes down whether it handles the event or passes it on, so each line of the log is one event's trip up the tree. Field A handles a, field B handles b, the panel around them handles p, and the root handles every typed character, and every press:

Trace = Callable[[InputEvent, str], None]


def traced(name: str, handles: Callable[[InputEvent], bool], trace: Trace) -> InputHandler:
    def on_input(event: InputEvent, _: Receiver) -> InputResponse:
        if not isinstance(event, KeyPressed | MouseDown):
            return None
        is_handled = handles(event)
        trace(event, f"{name} {'handles' if is_handled else 'passes'}")
        return () if is_handled else None

    return on_input


@component
def field(name: str, letter: str, trace: Trace) -> Text:
    focus = use_focus(auto_focus=name == "A")

    return Text(
        content=f"{'▸' if focus.is_focused else ' '} field {name}: {letter}",
        style=border | border_lightrounded | pad_x(1) | text_bg("indigo", 700 if focus.is_focused else 900),
        on_input=traced(f"field {name}", lambda event: event == KeyPressed(key=letter), trace),
    )


def is_character_or_press(event: InputEvent) -> bool:
    return isinstance(event, MouseDown) or (isinstance(event, KeyPressed) and len(event.key) == 1)


@component
def routing() -> Div:
    lines, set_lines = use_state(tuple[str, ...]())
    last_event: Ref[InputEvent | None] = use_ref(None)

    def trace(event: InputEvent, step: str) -> None:
        # Every handler on an event's path receives the same event object, so a new one starts a new line.
        if event is last_event.current:
            set_lines(lambda lines: (*lines[:-1], f"{lines[-1]}, {step}"))
            return
        last_event.current = event
        label = repr(event.key) if isinstance(event, KeyPressed) else "press"
        set_lines(lambda lines: (*lines, f"{label}: {step}")[-LINES:])

    return Div(
        style=col | full | border | border_heavy | pad_x(1),
        on_input=traced("root", is_character_or_press, trace),
        children=(
            Text(content="root: every character, and presses"),
            Div(
                style=col | border | border_light | pad_x(1),
                on_input=traced("panel", lambda event: event == KeyPressed(key="p"), trace),
                children=(
                    Text(content="panel: p"),
                    Div(style=row | gap(2), children=(field("A", "a", trace), field("B", "b", trace))),
                ),
            ),
            *(Text(content=line) for line in lines),
        ),
    )

A log of four key presses and two presses of the mouse, each listing the elements it reached

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ root: every character, and presses                 ┃
┃ ┌────────────────────────────────────────────────┐ ┃
┃ │ panel: p                                       │ ┃
┃ │ ╭──────────────╮  ╭──────────────╮             │ ┃
┃ │ │ ▸ field A: a │  │   field B: b │             │ ┃
┃ │ ╰──────────────╯  ╰──────────────╯             │ ┃
┃ └────────────────────────────────────────────────┘ ┃
┃ 'a': field A handles                               ┃
┃ 'p': field A passes, panel handles                 ┃
┃ 'x': field A passes, panel passes, root handles    ┃
┃ 'b': field A passes, panel passes, root handles    ┃
┃ press: field B passes, panel passes, root handles  ┃
┃ press: panel passes, root handles                  ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Field A is focused, so each key press starts there. a stops at field A, p bubbles one step to the panel, and x bubbles all the way to the root. b bubbles to the root too, though field B handles it, because field B is a sibling of the focused field, not an ancestor. A press on field B starts at field B, where the pointer is, though field A is focused. A press on the panel's label starts at the label, which has no handler, so the panel's handler is the first to see it.

Every handler on an event's path receives the same event object, so trace tells the next step of one event's trip from the start of another's by identity, with is, since two presses of one key are equal events. It keeps the last event in a ref, because the handlers of one batch of input all see the state of the render before it.

Key Presses

The target of a key press is the focused element, or the root component's element when nothing is focused. A key press bubbles from the focused element through its ancestors, and never reaches its siblings, so two components that handle the same keys don't both act on one press. When nothing is focused, only the root component's handler receives key presses, and its receiver's is_target is True, though its is_focused is False.

A focusable component's key handler goes on the element the component returns, which is the one that's focused. Handlers on the elements inside it never receive key presses, and a component deep in the tree can't own a global shortcut: put the shortcut on the root component's element, which every key press bubbles to unless a handler on the way stops it.

Mouse Events

The target of a mouse event is the element drawn on top at the pointer: the last element in paint order whose border box contains it. An element's margin isn't part of it, so a press in a margin reaches whatever lies beneath. A pointer over none of the root component's elements, when the root's element is smaller than the terminal, targets the root's element, though its rectangles don't contain the pointer.

Presses

A press runs from the first mouse button going down to the last one coming up, as in the web's Pointer Events. Its first MouseDown is routed like any mouse event, and the element whose handler stops it captures the press: the press's later events, each MouseMoved with a button held and each MouseDown and MouseUp of the buttons pressed and released while it lasts, go to that element alone, as their target, wherever the pointer is, and stop there whatever its handler returns. A second button pressed during a press joins it rather than starting a new one, so a handler that acts on one button matches on button, as on_click does for button 1. So a drag keeps reaching the element it started on however fast the pointer moves, and a release outside the element still reaches it, which a handler can tell from its receiver's rectangles.

The rest of a press that nothing stopped, or whose element has since unmounted, reaches no handler, and neither does a MouseUp for a button the press doesn't hold, so an element never receives the end of a press it didn't see begin: a release over a button after a press somewhere else doesn't click it. A handler that wants a press's moves stops its MouseDown, even with nothing to do for it:

def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
    match event:
        case MouseDown(button=1):
            return ()
        case MouseMoved(absolute=position, button=1):
            set_end(receiver.relative(position))
            return ()
        case _:
            return None

A slider is one line of text that the pointer drags along. Its handler stops the press's MouseDown, so it receives every move of the drag, and sets the volume from where the pointer is across the slider, clamped to its ends:

WIDTH = 20


@component
def slider() -> Div:
    volume, set_volume = use_state(5)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case MouseDown(absolute=position, button=1) | MouseMoved(absolute=position, button=1):
                set_volume(clamp(0, receiver.relative(position).x, WIDTH - 1))
                return ()
            case _:
                return None

    return Div(
        style=col | full | pad_x(1),
        children=(
            Text(content=f"Volume: {volume}"),
            Text(
                content="━" * volume + "●" + "─" * (WIDTH - 1 - volume),
                style=width(WIDTH) | text_color("indigo", 300),
                on_input=on_input,
            ),
        ),
    )

counterweight.geometry.clamp

clamp(min_: C, val: C, max_: C) -> C

val, moved into the range from min_ to max_, inclusive, such as a dragged position kept inside a box.

The slider at its highest volume, after a drag that ended below and to the right of it

 Volume: 19                             
 ━━━━━━━━━━━━━━━━━━━●                   

The drag above ended three rows below the slider and past its right end, outside it, and the moves kept reaching the slider all the way, so it's at its highest volume. If the press weren't captured, the moves past the slider's edge would go to whatever is under the pointer instead.

A MouseMoved with no button held ends a press whose release the terminal never reported, such as when focus moves to another window mid-drag, and so does a MouseDown for a button the press already holds. Both are routed by the pointer's position, and the MouseDown starts a new press. Scroll wheel events aren't part of a press, so they always target the element under the pointer, even mid-drag.

Since the press goes to whichever element stops its MouseDown, the innermost element with a handler for presses owns the whole press: a press on a button inside a draggable panel clicks the button and doesn't drag the panel. on_click builds a handler for the commonest case.

Handling Keyboard Events

Each time a key is pressed, Counterweight emits a KeyPressed event that holds which key was pressed, and routes it to the focused element. This inspector writes down every event its element receives, much as counterweight check-input prints them. Nothing in it is focusable, so its root element receives every key press and paste:

LINES = 8


def concise(event: InputEvent) -> str:
    """The event's repr, without the fields that hold their defaults, such as modifier keys that weren't held."""
    shown = ", ".join(
        f"{field.name}={value!r}" for field in fields(event) if (value := getattr(event, field.name)) != field.default
    )
    return f"{type(event).__name__}({shown})"


@component
def inspector() -> Div:
    lines, set_lines = use_state(tuple[str, ...]())

    def on_input(event: InputEvent, _: Receiver) -> InputResponse:
        set_lines(lambda lines: (*lines, concise(event))[-LINES:])
        match event:
            case MouseDown():
                return ()
            case _:
                return None

    return Div(
        style=col | full | border | border_lightrounded | pad_x(1),
        on_input=on_input,
        children=(
            Text(content="Press keys, paste, or use the mouse. Ctrl+C quits.", style=text_color("slate", 400)),
            *(Text(content=line) for line in lines),
        ),
    )

Its handler returns None for everything but a MouseDown, so the keys with defaults keep them, and Ctrl+C still quits. It stops each MouseDown so that it receives the rest of the press.

The inspector after a, é, Enter, Ctrl+J, Up, F1 and Ctrl+S

╭──────────────────────────────────────────────────────────────────────╮
│ Press keys, paste, or use the mouse. Ctrl+C quits.                   │
│ KeyPressed(key='a')                                                  │
│ KeyPressed(key='é')                                                  │
│ KeyPressed(key=Key.Enter)                                            │
│ KeyPressed(key=Key.ControlJ)                                         │
│ KeyPressed(key=Key.Up)                                               │
│ KeyPressed(key=Key.F1)                                               │
│ KeyPressed(key=Key.ControlS)                                         │
│                                                                      │
╰──────────────────────────────────────────────────────────────────────╯

A key that types text, such as a or é, is that text, and any other key is a Key from counterweight.keys, such as Key.Enter, Key.Up or Key.ControlS. A handler matches either kind with a KeyPressed(key=...) pattern, such as KeyPressed(key="a") or KeyPressed(key=Key.Up). Enter and Ctrl+J are different keys, Key.Enter and Key.ControlJ, so a text area can insert a line break on Ctrl+J while Enter submits a form.

counterweight.events.KeyPressed dataclass

KeyPressed(key: str)

A key press, which goes to the focused element, or the root component's element when nothing is focused.

key instance-attribute

key: str

The Key for a control or special key (Key.Enter, Key.Up, Key.F1), or for one pressed with Alt (Key.AltEnter, Key.AltUp, Key.ControlAltA), the text typed: one grapheme cluster, such as "a", "é" or "👩‍💻", or alt+ and the text, for text typed with Alt, such as "alt+b" or "alt+B" (Alt with Shift and B). Text pasted in a terminal without bracketed paste arrives as a key press per grapheme cluster; in one with it, the text arrives as one Pasted.

Keys pressed with Alt

Terminals send a key pressed with Alt as ESC followed by what the key sends on its own (xterm's metaSendsEscape, which most terminals do by default), and Counterweight reads the two as one key press: Key.AltEnter for Alt+Enter, Key.AltBackspace for Alt+Backspace, Key.ControlAltA for Ctrl+Alt+A, and "alt+b" for Alt+B, which is alt+ and the text the key types, so Alt+Shift+B is "alt+B". Arrow, navigation and function keys pressed with Alt are Key.AltUp, Key.AltPageDown, Key.AltF1 and so on, whether the terminal sends Alt in their escape sequence, as xterm does, or as an ESC before it, as rxvt does. The inspector shows them:

The inspector after Alt+Enter, Alt+B, Alt+Shift+B, Ctrl+Alt+A and Alt+Up

╭──────────────────────────────────────────────────────────────────────╮
│ Press keys, paste, or use the mouse. Ctrl+C quits.                   │
│ KeyPressed(key=Key.AltEnter)                                         │
│ KeyPressed(key='alt+b')                                              │
│ KeyPressed(key='alt+B')                                              │
│ KeyPressed(key=Key.ControlAltA)                                      │
│ KeyPressed(key=Key.AltUp)                                            │
│                                                                      │
│                                                                      │
│                                                                      │
╰──────────────────────────────────────────────────────────────────────╯

A handler matches a key pressed with Alt as it matches any other key:

def on_input(event: InputEvent, _: Receiver) -> InputResponse:
    match event:
        case KeyPressed(key=Key.Enter):
            send()
            return ()
        case KeyPressed(key="alt+n"):
            start_new_chat()
            return ()
        case _:
            return None

The DOM's KeyboardEvent reports Alt in altKey, beside a key that doesn't change, as Counterweight's mouse events report it in is_alt_held. A key press names Alt in its key instead, as it names Control and Shift (Key.ControlLeft, Key.ShiftUp), so a handler that matches Key.Enter doesn't also take Alt+Enter, and a text field never types the b of Alt+B, since text is always one grapheme cluster, and "alt+b" isn't.

An Escape pressed on its own and then a key sends the same bytes as Alt with the key, so Counterweight tells them apart by time, as terminal programs do: an ESC at the end of the input that has arrived waits up to 50 milliseconds for more, as it waits for the rest of an escape sequence, and is Alt with the key that arrives in that time, or the Escape key once it passes. Two ESCs are two presses of Escape rather than Alt+Escape, so pressing Escape twice quickly is never lost. ESC [ and ESC O also begin escape sequences, so Alt+[ and Alt+Shift+O arrive as "alt+[" and "alt+O" only when no key follows them within that time.

A terminal that doesn't send Alt as an escape never sends these keys. One that sets the high bit of the byte for Alt instead (xterm with metaSendsEscape off and eightBitInput on) sends another character, as does macOS's Option key, which types characters such as ∫ for Option+B unless the terminal is set to send it as Meta (Terminal.app's "Use Option as Meta key", or iTerm2's "Esc+" for the Option key). Run counterweight check-input to see which events a terminal's keys arrive as.

Keys the terminal would act on

A terminal acts on a few Ctrl keys itself unless the program reading it asks otherwise: Ctrl+C and Ctrl+\ send signals that interrupt and kill the program, Ctrl+Z one that stops it, and Ctrl+S and Ctrl+Q pause and resume its output. Counterweight asks otherwise, as tmux does: it reads the terminal raw, so every one of those keys arrives as a key press (Key.ControlC, Key.ControlBackslash, Key.ControlZ, Key.ControlS and Key.ControlQ), and an editor can save on Ctrl+S, or an app can pass Ctrl+C on to a program it runs.

A key press that no handler on its path claims then takes its default from app's unclaimed_keys, as a browser takes a key's default action unless a listener prevents it. DEFAULT_UNCLAIMED_KEYS gives Ctrl+C and Ctrl+Z what they'd do in a terminal: Ctrl+C quits, and Ctrl+Z suspends the app as a shell job, with Suspend.to_shell(), until the shell resumes it with fg. A handler that claims one of them keeps it from taking its default, and unclaimed_keys changes the defaults for the whole app:

# No defaults: Ctrl+C and Ctrl+Z do nothing unless a handler handles them.
asyncio.run(app(root(), unclaimed_keys={}))

# Ctrl+Q quits too, as well as Ctrl+C.
asyncio.run(app(root(), unclaimed_keys=DEFAULT_UNCLAIMED_KEYS | {Key.ControlQ: Quit()}))

A key the terminal would turn into a signal only does something once the app reads it, so an app whose render loop is stuck, such as in a handler that never returns, can't be quit with Ctrl+C. Stop it from outside with kill: the first interrupt quits it as Ctrl+C would, and a second one goes to whatever handled interrupts before the app started. A focused component that claims every key, such as one that passes keys on to a program, takes Ctrl+C and Ctrl+Z too, so an app that has one needs another way to quit, such as a key that it doesn't claim.

counterweight.DEFAULT_UNCLAIMED_KEYS module-attribute

DEFAULT_UNCLAIMED_KEYS: Mapping[str, AnyControl] = (
    frozendict(
        {
            Key.ControlC: Quit(),
            Key.ControlZ: Suspend.to_shell(),
        }
    )
)

What app does with a key press no handler claims, by default: Ctrl+C quits, and Ctrl+Z suspends the application as a shell job, as they do to a program in a terminal.

Handling Pastes

Counterweight turns on the terminal's bracketed paste mode, so text pasted into the terminal arrives as one Pasted event holding all of it, rather than as a key press per character, and it's routed to the focused element, as a key press is. A handler that only matches key presses never sees it, so a paste can't trigger a binding by holding the binding's key. use_text_input and use_text_area insert it in one edit. Terminals without bracketed paste send pasted text as typed keys, with its line breaks as Enter, or as Ctrl+J from a terminal that sends them as line feeds.

In the inspector, typing h and i makes two key presses, while pasting two lines makes one event, with the line break inside its text:

The inspector after typing h and i, then pasting two lines

╭──────────────────────────────────────────────────────────────────────╮
│ Press keys, paste, or use the mouse. Ctrl+C quits.                   │
│ KeyPressed(key='h')                                                  │
│ KeyPressed(key='i')                                                  │
│ Pasted(text='first line\nsecond line')                               │
│                                                                      │
│                                                                      │
│                                                                      │
│                                                                      │
│                                                                      │
╰──────────────────────────────────────────────────────────────────────╯

counterweight.events.Pasted dataclass

Pasted(text: str)

Text pasted into the terminal, which goes where a key press would.

text instance-attribute

text: str

The text pasted into the terminal, whole, with each line break as "\n" (terminals send a pasted line break as a carriage return). It can hold anything a key press can't, such as line breaks and tabs, which arrive as text rather than as keys.

Handling Mouse Events

Each time the state of the mouse changes, Counterweight emits a single mouse event, one of MouseMoved, MouseDown, MouseUp, MouseScrolledDown, MouseScrolledUp, MouseScrolledLeft, or MouseScrolledRight.

counterweight.events.MouseEvent module-attribute

Any mouse event, which goes to the element drawn on top at the pointer, unless a press has captured the mouse.

counterweight.events.MouseMoved dataclass

MouseMoved(
    absolute: Position,
    button: int | None,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

The mouse moved, with or without a button held.

absolute instance-attribute

absolute: Position

The absolute position on the screen that the mouse moved to.

button instance-attribute

button: int | None

The button that was held during the motion, or None if no button was pressed.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseDown dataclass

MouseDown(
    absolute: Position,
    button: int,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

A mouse button went down.

absolute instance-attribute

absolute: Position

The absolute position on the screen where the button went down.

button instance-attribute

button: int

The mouse button that went down: 1 is the primary button, 2 the middle, and 3 the secondary.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseUp dataclass

MouseUp(
    absolute: Position,
    button: int,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

A mouse button came up.

absolute instance-attribute

absolute: Position

The absolute position on the screen where the button came up.

button instance-attribute

button: int

The mouse button that came up: 1 is the primary button, 2 the middle, and 3 the secondary.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseScrolledDown dataclass

MouseScrolledDown(
    absolute: Position,
    direction: int = 1,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

The mouse wheel turned one step down.

absolute instance-attribute

absolute: Position

The absolute position on the screen of the pointer as the wheel turned.

direction class-attribute instance-attribute

direction: int = 1

The direction that the mouse was scrolled as an integer offset; -1 for up, +1 for down.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseScrolledUp dataclass

MouseScrolledUp(
    absolute: Position,
    direction: int = -1,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

The mouse wheel turned one step up.

absolute instance-attribute

absolute: Position

The absolute position on the screen of the pointer as the wheel turned.

direction class-attribute instance-attribute

direction: int = -1

The direction that the mouse was scrolled as an integer offset; -1 for up, +1 for down.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseScrolledLeft dataclass

MouseScrolledLeft(
    absolute: Position,
    direction: int = -1,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

The mouse wheel turned one step left, as a trackpad's sideways swipe does.

absolute instance-attribute

absolute: Position

The absolute position on the screen of the pointer as the wheel turned.

direction class-attribute instance-attribute

direction: int = -1

The direction that the mouse was scrolled as an integer offset; -1 for left, +1 for right.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

counterweight.events.MouseScrolledRight dataclass

MouseScrolledRight(
    absolute: Position,
    direction: int = 1,
    is_shift_held: bool = False,
    is_alt_held: bool = False,
    is_control_held: bool = False,
)

The mouse wheel turned one step right, as a trackpad's sideways swipe does.

absolute instance-attribute

absolute: Position

The absolute position on the screen of the pointer as the wheel turned.

direction class-attribute instance-attribute

direction: int = 1

The direction that the mouse was scrolled as an integer offset; -1 for left, +1 for right.

is_shift_held class-attribute instance-attribute

is_shift_held: bool = False

Whether Shift was held, as the DOM's MouseEvent.shiftKey reports it.

is_alt_held class-attribute instance-attribute

is_alt_held: bool = False

Whether Alt (Option on macOS) was held, as the DOM's MouseEvent.altKey reports it.

is_control_held class-attribute instance-attribute

is_control_held: bool = False

Whether Control was held, as the DOM's MouseEvent.ctrlKey reports it.

Each mouse event says which modifier keys were held, as the DOM's MouseEvent does, in is_shift_held, is_alt_held and is_control_held, so a handler can match a modified press or turn of the wheel. In this list, a press selects one item, and a press with Control held adds an item to the selection, or takes it out:

ITEMS = ("apples", "bread", "cheese", "dates")


@component
def shopping_list() -> Div:
    selected, set_selected = use_state(frozenset[str]())

    def row(item: str) -> Text:
        def on_input(event: InputEvent, _: Receiver) -> InputResponse:
            match event:
                case MouseDown(button=1, is_control_held=True):
                    set_selected(lambda selected: selected ^ {item})
                    return ()
                case MouseDown(button=1):
                    set_selected(frozenset({item}))
                    return ()
                case _:
                    return None

        is_selected = item in selected
        return Text(
            content=f"{'●' if is_selected else '○'} {item}",
            style=pad_x(1) | (text_bg("indigo", 700) if is_selected else default),
            on_input=on_input,
        )

    return Div(style=col | full, children=tuple(row(item) for item in ITEMS))

The case with is_control_held=True comes first, since a MouseDown(button=1) pattern matches a press whatever modifiers were held. After a press on bread and a press with Control on dates, both are selected:

A list with bread and dates selected

 ○ apples           
 ● bread            
 ○ cheese           
 ● dates            

Terminals keep some combinations for themselves and never report them to the app: most select text for Shift with a press, even while the app tracks the mouse, and some scroll their own scrollback for Shift with the wheel or open links for Control with a press. So a modifier works best as a shortcut for something the app offers another way too. counterweight check-input --mouse shows the events a terminal reports.

For example, consider the following series of mouse actions and corresponding events:

Action Event
Mouse starts at position (0, 0) No event emitted
Mouse moves to position (1, 0) MouseMoved(absolute=Position(x=1, y=0), button=None)
Mouse button 1 is pressed MouseDown(absolute=Position(x=1, y=0), button=1)
Mouse moves to position (1, 1) MouseMoved(absolute=Position(x=1, y=1), button=1)
Mouse button 1 is released MouseUp(absolute=Position(x=1, y=1), button=1)
Mouse button 3 is pressed MouseDown(absolute=Position(x=1, y=1), button=3)
Mouse moves to position (2, 2) and mouse button 3 is released simultaneously MouseUp(absolute=Position(x=2, y=2), button=3)

The inspector receives that series as the table lists it, followed by a turn of the wheel with Control held. It receives the presses' moves and releases because it stops each MouseDown:

The inspector after the mouse events in the table, and a turn of the wheel with Control held

╭──────────────────────────────────────────────────────────────────────╮
│ Press keys, paste, or use the mouse. Ctrl+C quits.                   │
│ MouseMoved(absolute=Position(x=1, y=0), button=None)                 │
│ MouseDown(absolute=Position(x=1, y=0), button=1)                     │
│ MouseMoved(absolute=Position(x=1, y=1), button=1)                    │
│ MouseUp(absolute=Position(x=1, y=1), button=1)                       │
│ MouseDown(absolute=Position(x=1, y=1), button=3)                     │
│ MouseUp(absolute=Position(x=2, y=2), button=3)                       │
│ MouseScrolledDown(absolute=Position(x=2, y=2), is_control_held=True) │
│                                                                      │
╰──────────────────────────────────────────────────────────────────────╯

Mouse Button Identifiers

Mouse buttons are identified by numbers instead of names (e.g., "left", "middle", "right") because the button numbers are the same regardless of whether the mouse is configured for left-handed or right-handed use.

Number Button for Right-Handed Mouse Button for Left-Handed Mouse
1 Left Right
2 Middle Middle
3 Right Left
8 Back Back
9 Forward Forward

You should always use the numbers instead of names to refer to mouse buttons to ensure that your application works as expected for both left-handed and right-handed users.

use_mouse vs. on_input

use_mouse and an element's on_input handler both see the mouse, but use_mouse is a hook and on_input is an event handler. use_mouse is more efficient when a component depends only on the current state of the mouse (e.g., the current position, or whether a button is currently pressed), while on_input is more convenient when a component needs to respond to changes in the mouse state, (e.g., a button release MouseUp). use_mouse sees every change, while a handler sees only the events routed to its element.

Keys

Key names every key that doesn't type text, alone and with the modifiers a terminal reports for it.

counterweight.keys.Key

The named keys, each equal to the text that names it, such as Key.ControlA == "ctrl+a".

Derived from Textual's keys.

Escape class-attribute instance-attribute

Escape = 'escape'

ShiftEscape class-attribute instance-attribute

ShiftEscape = 'shift+escape'

ControlA class-attribute instance-attribute

ControlA = 'ctrl+a'

ControlB class-attribute instance-attribute

ControlB = 'ctrl+b'

ControlC class-attribute instance-attribute

ControlC = 'ctrl+c'

ControlD class-attribute instance-attribute

ControlD = 'ctrl+d'

ControlE class-attribute instance-attribute

ControlE = 'ctrl+e'

ControlF class-attribute instance-attribute

ControlF = 'ctrl+f'

ControlG class-attribute instance-attribute

ControlG = 'ctrl+g'

ControlH class-attribute instance-attribute

ControlH = 'ctrl+h'

ControlI class-attribute instance-attribute

ControlI = 'ctrl+i'

ControlJ class-attribute instance-attribute

ControlJ = 'ctrl+j'

ControlK class-attribute instance-attribute

ControlK = 'ctrl+k'

ControlL class-attribute instance-attribute

ControlL = 'ctrl+l'

ControlM class-attribute instance-attribute

ControlM = 'ctrl+m'

ControlN class-attribute instance-attribute

ControlN = 'ctrl+n'

ControlO class-attribute instance-attribute

ControlO = 'ctrl+o'

ControlP class-attribute instance-attribute

ControlP = 'ctrl+p'

ControlQ class-attribute instance-attribute

ControlQ = 'ctrl+q'

ControlR class-attribute instance-attribute

ControlR = 'ctrl+r'

ControlS class-attribute instance-attribute

ControlS = 'ctrl+s'

ControlT class-attribute instance-attribute

ControlT = 'ctrl+t'

ControlU class-attribute instance-attribute

ControlU = 'ctrl+u'

ControlV class-attribute instance-attribute

ControlV = 'ctrl+v'

ControlW class-attribute instance-attribute

ControlW = 'ctrl+w'

ControlX class-attribute instance-attribute

ControlX = 'ctrl+x'

ControlY class-attribute instance-attribute

ControlY = 'ctrl+y'

ControlZ class-attribute instance-attribute

ControlZ = 'ctrl+z'

ControlBackslash class-attribute instance-attribute

ControlBackslash = 'ctrl+\\'

ControlRightBracket class-attribute instance-attribute

ControlRightBracket = 'ctrl+]'

ControlCaret class-attribute instance-attribute

ControlCaret = 'ctrl+^'

ControlUnderscore class-attribute instance-attribute

ControlUnderscore = 'ctrl+_'

ControlAltA class-attribute instance-attribute

ControlAltA = 'ctrl+alt+a'

ControlAltB class-attribute instance-attribute

ControlAltB = 'ctrl+alt+b'

ControlAltC class-attribute instance-attribute

ControlAltC = 'ctrl+alt+c'

ControlAltD class-attribute instance-attribute

ControlAltD = 'ctrl+alt+d'

ControlAltE class-attribute instance-attribute

ControlAltE = 'ctrl+alt+e'

ControlAltF class-attribute instance-attribute

ControlAltF = 'ctrl+alt+f'

ControlAltG class-attribute instance-attribute

ControlAltG = 'ctrl+alt+g'

ControlAltJ class-attribute instance-attribute

ControlAltJ = 'ctrl+alt+j'

ControlAltK class-attribute instance-attribute

ControlAltK = 'ctrl+alt+k'

ControlAltL class-attribute instance-attribute

ControlAltL = 'ctrl+alt+l'

ControlAltN class-attribute instance-attribute

ControlAltN = 'ctrl+alt+n'

ControlAltO class-attribute instance-attribute

ControlAltO = 'ctrl+alt+o'

ControlAltP class-attribute instance-attribute

ControlAltP = 'ctrl+alt+p'

ControlAltQ class-attribute instance-attribute

ControlAltQ = 'ctrl+alt+q'

ControlAltR class-attribute instance-attribute

ControlAltR = 'ctrl+alt+r'

ControlAltS class-attribute instance-attribute

ControlAltS = 'ctrl+alt+s'

ControlAltT class-attribute instance-attribute

ControlAltT = 'ctrl+alt+t'

ControlAltU class-attribute instance-attribute

ControlAltU = 'ctrl+alt+u'

ControlAltV class-attribute instance-attribute

ControlAltV = 'ctrl+alt+v'

ControlAltW class-attribute instance-attribute

ControlAltW = 'ctrl+alt+w'

ControlAltX class-attribute instance-attribute

ControlAltX = 'ctrl+alt+x'

ControlAltY class-attribute instance-attribute

ControlAltY = 'ctrl+alt+y'

ControlAltZ class-attribute instance-attribute

ControlAltZ = 'ctrl+alt+z'

ControlAltBackslash class-attribute instance-attribute

ControlAltBackslash = 'ctrl+alt+\\'

ControlAltRightBracket class-attribute instance-attribute

ControlAltRightBracket = 'ctrl+alt+]'

ControlAltCaret class-attribute instance-attribute

ControlAltCaret = 'ctrl+alt+^'

ControlAltUnderscore class-attribute instance-attribute

ControlAltUnderscore = 'ctrl+alt+_'

Left class-attribute instance-attribute

Left = 'left'

Right class-attribute instance-attribute

Right = 'right'

Up class-attribute instance-attribute

Up = 'up'

Down class-attribute instance-attribute

Down = 'down'

Home class-attribute instance-attribute

Home = 'home'

End class-attribute instance-attribute

End = 'end'

Insert class-attribute instance-attribute

Insert = 'insert'

Delete class-attribute instance-attribute

Delete = 'delete'

PageUp class-attribute instance-attribute

PageUp = 'pageup'

PageDown class-attribute instance-attribute

PageDown = 'pagedown'

ControlLeft class-attribute instance-attribute

ControlLeft = 'ctrl+left'

ControlRight class-attribute instance-attribute

ControlRight = 'ctrl+right'

ControlUp class-attribute instance-attribute

ControlUp = 'ctrl+up'

ControlDown class-attribute instance-attribute

ControlDown = 'ctrl+down'

ControlHome class-attribute instance-attribute

ControlHome = 'ctrl+home'

ControlEnd class-attribute instance-attribute

ControlEnd = 'ctrl+end'

ControlInsert class-attribute instance-attribute

ControlInsert = 'ctrl+insert'

ControlDelete class-attribute instance-attribute

ControlDelete = 'ctrl+delete'

ControlPageUp class-attribute instance-attribute

ControlPageUp = 'ctrl+pageup'

ControlPageDown class-attribute instance-attribute

ControlPageDown = 'ctrl+pagedown'

ShiftLeft class-attribute instance-attribute

ShiftLeft = 'shift+left'

ShiftRight class-attribute instance-attribute

ShiftRight = 'shift+right'

ShiftUp class-attribute instance-attribute

ShiftUp = 'shift+up'

ShiftDown class-attribute instance-attribute

ShiftDown = 'shift+down'

ShiftHome class-attribute instance-attribute

ShiftHome = 'shift+home'

ShiftEnd class-attribute instance-attribute

ShiftEnd = 'shift+end'

ShiftInsert class-attribute instance-attribute

ShiftInsert = 'shift+insert'

ShiftDelete class-attribute instance-attribute

ShiftDelete = 'shift+delete'

ShiftPageUp class-attribute instance-attribute

ShiftPageUp = 'shift+pageup'

ShiftPageDown class-attribute instance-attribute

ShiftPageDown = 'shift+pagedown'

ControlShiftLeft class-attribute instance-attribute

ControlShiftLeft = 'ctrl+shift+left'

ControlShiftRight class-attribute instance-attribute

ControlShiftRight = 'ctrl+shift+right'

ControlShiftUp class-attribute instance-attribute

ControlShiftUp = 'ctrl+shift+up'

ControlShiftDown class-attribute instance-attribute

ControlShiftDown = 'ctrl+shift+down'

ControlShiftHome class-attribute instance-attribute

ControlShiftHome = 'ctrl+shift+home'

ControlShiftEnd class-attribute instance-attribute

ControlShiftEnd = 'ctrl+shift+end'

ControlShiftInsert class-attribute instance-attribute

ControlShiftInsert = 'ctrl+shift+insert'

ControlShiftDelete class-attribute instance-attribute

ControlShiftDelete = 'ctrl+shift+delete'

ControlShiftPageUp class-attribute instance-attribute

ControlShiftPageUp = 'ctrl+shift+pageup'

ControlShiftPageDown class-attribute instance-attribute

ControlShiftPageDown = 'ctrl+shift+pagedown'

AltLeft class-attribute instance-attribute

AltLeft = 'alt+left'

AltRight class-attribute instance-attribute

AltRight = 'alt+right'

AltUp class-attribute instance-attribute

AltUp = 'alt+up'

AltDown class-attribute instance-attribute

AltDown = 'alt+down'

AltHome class-attribute instance-attribute

AltHome = 'alt+home'

AltEnd class-attribute instance-attribute

AltEnd = 'alt+end'

AltInsert class-attribute instance-attribute

AltInsert = 'alt+insert'

AltDelete class-attribute instance-attribute

AltDelete = 'alt+delete'

AltPageUp class-attribute instance-attribute

AltPageUp = 'alt+pageup'

AltPageDown class-attribute instance-attribute

AltPageDown = 'alt+pagedown'

BackTab class-attribute instance-attribute

BackTab = 'shift+tab'

F1 class-attribute instance-attribute

F1 = 'f1'

F2 class-attribute instance-attribute

F2 = 'f2'

F3 class-attribute instance-attribute

F3 = 'f3'

F4 class-attribute instance-attribute

F4 = 'f4'

F5 class-attribute instance-attribute

F5 = 'f5'

F6 class-attribute instance-attribute

F6 = 'f6'

F7 class-attribute instance-attribute

F7 = 'f7'

F8 class-attribute instance-attribute

F8 = 'f8'

F9 class-attribute instance-attribute

F9 = 'f9'

F10 class-attribute instance-attribute

F10 = 'f10'

F11 class-attribute instance-attribute

F11 = 'f11'

F12 class-attribute instance-attribute

F12 = 'f12'

F13 class-attribute instance-attribute

F13 = 'f13'

F14 class-attribute instance-attribute

F14 = 'f14'

F15 class-attribute instance-attribute

F15 = 'f15'

F16 class-attribute instance-attribute

F16 = 'f16'

F17 class-attribute instance-attribute

F17 = 'f17'

F18 class-attribute instance-attribute

F18 = 'f18'

F19 class-attribute instance-attribute

F19 = 'f19'

F20 class-attribute instance-attribute

F20 = 'f20'

F21 class-attribute instance-attribute

F21 = 'f21'

F22 class-attribute instance-attribute

F22 = 'f22'

F23 class-attribute instance-attribute

F23 = 'f23'

F24 class-attribute instance-attribute

F24 = 'f24'

ControlF1 class-attribute instance-attribute

ControlF1 = 'ctrl+f1'

ControlF2 class-attribute instance-attribute

ControlF2 = 'ctrl+f2'

ControlF3 class-attribute instance-attribute

ControlF3 = 'ctrl+f3'

ControlF4 class-attribute instance-attribute

ControlF4 = 'ctrl+f4'

ControlF5 class-attribute instance-attribute

ControlF5 = 'ctrl+f5'

ControlF6 class-attribute instance-attribute

ControlF6 = 'ctrl+f6'

ControlF7 class-attribute instance-attribute

ControlF7 = 'ctrl+f7'

ControlF8 class-attribute instance-attribute

ControlF8 = 'ctrl+f8'

ControlF9 class-attribute instance-attribute

ControlF9 = 'ctrl+f9'

ControlF10 class-attribute instance-attribute

ControlF10 = 'ctrl+f10'

ControlF11 class-attribute instance-attribute

ControlF11 = 'ctrl+f11'

ControlF12 class-attribute instance-attribute

ControlF12 = 'ctrl+f12'

ControlF13 class-attribute instance-attribute

ControlF13 = 'ctrl+f13'

ControlF14 class-attribute instance-attribute

ControlF14 = 'ctrl+f14'

ControlF15 class-attribute instance-attribute

ControlF15 = 'ctrl+f15'

ControlF16 class-attribute instance-attribute

ControlF16 = 'ctrl+f16'

ControlF17 class-attribute instance-attribute

ControlF17 = 'ctrl+f17'

ControlF18 class-attribute instance-attribute

ControlF18 = 'ctrl+f18'

ControlF19 class-attribute instance-attribute

ControlF19 = 'ctrl+f19'

ControlF20 class-attribute instance-attribute

ControlF20 = 'ctrl+f20'

ControlF21 class-attribute instance-attribute

ControlF21 = 'ctrl+f21'

ControlF22 class-attribute instance-attribute

ControlF22 = 'ctrl+f22'

ControlF23 class-attribute instance-attribute

ControlF23 = 'ctrl+f23'

ControlF24 class-attribute instance-attribute

ControlF24 = 'ctrl+f24'

AltF1 class-attribute instance-attribute

AltF1 = 'alt+f1'

AltF2 class-attribute instance-attribute

AltF2 = 'alt+f2'

AltF3 class-attribute instance-attribute

AltF3 = 'alt+f3'

AltF4 class-attribute instance-attribute

AltF4 = 'alt+f4'

AltF5 class-attribute instance-attribute

AltF5 = 'alt+f5'

AltF6 class-attribute instance-attribute

AltF6 = 'alt+f6'

AltF7 class-attribute instance-attribute

AltF7 = 'alt+f7'

AltF8 class-attribute instance-attribute

AltF8 = 'alt+f8'

AltF9 class-attribute instance-attribute

AltF9 = 'alt+f9'

AltF10 class-attribute instance-attribute

AltF10 = 'alt+f10'

AltF11 class-attribute instance-attribute

AltF11 = 'alt+f11'

AltF12 class-attribute instance-attribute

AltF12 = 'alt+f12'

ControlSpace class-attribute instance-attribute

ControlSpace = 'ctrl+space'

Tab class-attribute instance-attribute

Tab = 'tab'

Space class-attribute instance-attribute

Space = ' '

Enter class-attribute instance-attribute

Enter = 'enter'

Backspace class-attribute instance-attribute

Backspace = 'backspace'

ControlAltSpace class-attribute instance-attribute

ControlAltSpace = 'ctrl+alt+space'

AltSpace class-attribute instance-attribute

AltSpace = 'alt+space'

AltTab class-attribute instance-attribute

AltTab = 'alt+tab'

AltEnter class-attribute instance-attribute

AltEnter = 'alt+enter'

AltBackspace class-attribute instance-attribute

AltBackspace = 'alt+backspace'

__repr__

__repr__() -> str