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:
Nonemeans 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. ANoneinside 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 isNone.
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.
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),
),
)
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ 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
val, moved into the range from min_ to max_, inclusive, such as a dragged position kept inside a box.
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.
╭──────────────────────────────────────────────────────────────────────╮
│ 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:
╭──────────────────────────────────────────────────────────────────────╮
│ 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:
╭──────────────────────────────────────────────────────────────────────╮
│ 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
MouseEvent = (
MouseMoved
| MouseDown
| MouseUp
| MouseScrolledDown
| MouseScrolledUp
| MouseScrolledLeft
| MouseScrolledRight
)
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:
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:
╭──────────────────────────────────────────────────────────────────────╮
│ 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.