Keyboard bindings and focus¶
KeyBinding describes an action, KeyBindings groups actions, and
FocusGroup routes input to a selected panel. Player remains the only input
poller. These helpers do not open a terminal, start threads, or own animations.
An interactive panel¶
from bruhanimate import (
Column, Fixed, FocusGroup, FocusTarget, KeyBinding, KeyBindings,
Label, Panel, Slot, StatusBar, play,
)
state = {"count": 0}
def increment() -> None:
state["count"] += 1
counter = FocusTarget("counter", KeyBindings(
KeyBinding("space", increment, description="increment"),
KeyBinding("enter", increment), # Alias omitted from the help text.
))
focus = FocusGroup(counter)
layout = Column(
Slot(Panel(Label(lambda: f"Count: {state['count']}"),
size=(20, 3), fit=True, title="Counter", focus=counter)),
Slot(StatusBar("counter", center=focus.help_text, right="q quit"), size=Fixed(1)),
)
play(effect_factory=layout.build, event_handler=focus.handle_event,
on_resize="resize", frames=180)
Attach a separate FocusTarget to each interactive Panel and list targets in the
FocusGroup’s desired navigation order. Tab selects the next available target;
Shift+Tab selects the previous one. Navigation wraps by default. A focused panel
has a bold border with ANSI palette color 226; choose
Panel(focus_border_color=117, ...) or None to retain its normal border color.
Borderless panels still receive focus; use target.focused in a label callback
to show selection. Drawing never changes focus or invokes bindings.
Discovering keys and actions¶
Typing KeyBinding(" in an editor exposes the KeyName Literal choices:
space,enter,escape,tab,shift+tab,backspaceleft,right,up,down,home,end,page_up,page_downinsert,delete,ctrl+c, andf1throughf12
Use KeyBinding.character("n", action, description="step") for a printable
single-cell character. Character bindings are case-sensitive. ASCII characters
are the portable choice; terminal encoding and key interception can limit which
events reach the application. Arbitrary modifier chords and text editing are not
provided. Wide/combining glyphs, control characters, and escape sequences are not
valid character bindings.
Actions take no arguments and return None. A matching action consumes its key;
quit=True explicitly requests exit after an optional action. Duplicate keys
within one KeyBindings are rejected, including named space and character
" ". Different targets may bind the same key to different actions.
bindings.hints() returns typed, immutable BindingHint(key, description)
records in declaration order. bindings.help_text(separator=" | ") formats
them. Empty descriptions omit aliases from help. focus.help_text is a
zero-argument callback returning the selected target’s help, suitable for
StatusBar. It excludes navigation and global bindings, which can be displayed
separately. Label/status callbacks refresh on updates, so hints refresh on the
next update after input.
Routing and explicit quit¶
FocusGroup processes each event once, in this order:
Reserved navigation keys move focus and consume the event.
The focused target’s bindings get the event.
If unhandled, application-wide bindings get the event.
By default, unhandled q/Q, Escape, and Ctrl+C request quit, including when no
target is available. A local binding can consume those keys first. Supplying
global_bindings=KeyBindings(...) replaces all those defaults; include your
own quit binding. next_key="down", previous_key="up", and wrap=False
are typed options for alternative navigation. Reserved keys take priority over
local and global bindings.
focus.focus("counter") selects an available named target and returns True;
it returns False for an unavailable target and raises KeyError for an unknown
name. focus.move("next") / focus.move("previous") expose the same
navigation with the public FocusDirection Literal type.
Use event_handler=focus.handle_event or bindings.handle_event with
Player/play. These adapters return True only to quit. For custom routing,
dispatch(event) returns EventResult.UNHANDLED, HANDLED, or QUIT;
compare explicitly. Its Boolean conversion raises to prevent a handled key
from accidentally requesting exit. Do not pass dispatch directly to Player.
Existing Boolean handlers, including Playback.handle_event, retain their contract.
Standalone bindings work without focus:
from bruhanimate import KeyBinding, KeyBindings, Label, play
state = {"count": 0}
def increment() -> None:
state["count"] += 1
bindings = KeyBindings(
KeyBinding("space", increment, description="increment"),
KeyBinding.character("q", quit=True, description="quit"),
)
play(Label(lambda: f"Count: {state['count']}"),
event_handler=bindings.handle_event, frames=180)
KeyBindings alone installs no default quit keys. Exceptions from actions propagate through Player’s normal cleanup. Handlers run synchronously: keep actions short, and queue solver work for update (as Playback.step does).
Visibility, resize, and ownership¶
A target belongs to one group and attaches to one panel. The group borrows its
targets; Panel/Scene/layout continue to own and close content. A target becomes
available after successful Panel startup. target.enabled=False removes it
from navigation without hiding or pausing content. panel.set_visible(False)
hides the panel and its nested focus targets while updates continue.
Layouts automatically mark zero-area slots unavailable, including nested panels.
Initially hidden factories remain uncalled. Already started content retains its
state and continues updating while hidden. When selection becomes unavailable,
focus moves to the next available target in declaration order, wrapping if
needed even with wrap=False; this fallback is separate from manual navigation.
If none remain, focus.focused is None. Showing or enabling another target
does not steal focus from an available selection.
Closing a panel, failing its startup, or expiring its enclosing TimedOverlay
removes its target from focus. After close, the target may attach to a replacement
panel in the same group. Never attach it to two live panels. Focus availability
does not detect arbitrary Layer clipping, offscreen motion, or overlap by another
window; use enabled or set_visible for those application decisions.
Layouts control their children’s visibility during reflow; use enabled for
a persistent exclusion from keyboard navigation.
ScrollView and LogView expose reusable bindings for arrows, Page Up/Down,
Home/End; attach them with FocusTarget("log", log.bindings). See Scrollable views and activity logs.
For a complete three-panel example, run python -m bruhanimate.demos.dashboard_demo.
See Dashboard demo for solver/signal/log controls. This API provides
keyboard routing and focus; mouse input, editable fields, and nested focus scopes
remain outside its scope.
API¶
Declarative keyboard routing; no input polling or animation ownership.
- class bruhanimate.keyboard.EventResult(value, names=<not given>, *values, module=None, qualname=None, type=None, start=1, boundary=None)[source]¶
Dispatch result; use handle_event with Player, never enum truthiness.
- UNHANDLED = 'unhandled'¶
- HANDLED = 'handled'¶
- QUIT = 'quit'¶
- class bruhanimate.keyboard.BindingHint(key: str, description: str)[source]¶
A displayable key/description pair for a help label or status bar.
- key: str¶
- description: str¶
- __init__(key: str, description: str) None¶
- class bruhanimate.keyboard.KeyBinding(key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'], action: Callable[[], None] | None = None, *, description: str = '', quit: bool = False)[source]¶
Bind a Literal-typed named key to a zero-argument action.
Use character() for a single printable character, e.g. ‘q’ or ‘+’. Actions return None; any matched binding consumes the event. quit=True requests exit after its optional action. Empty descriptions omit aliases from help text. Construction never calls actions or opens a terminal.
- __init__(key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'], action: Callable[[], None] | None = None, *, description: str = '', quit: bool = False) None[source]¶
- classmethod character(character: str, action: Callable[[], None] | None = None, *, description: str = '', quit: bool = False) KeyBinding[source]¶
Bind one printable single-cell character; matching is case-sensitive.
- property key: str¶
- property description: str¶
- class bruhanimate.keyboard.KeyBindings(*bindings: KeyBinding)[source]¶
An ordered, immutable set of bindings; duplicate normalized keys are errors.
dispatch returns EventResult for composition. handle_event adapts to Player’s bool contract (True only for quit). No default keys are installed here.
- __init__(*bindings: KeyBinding) None[source]¶
- property bindings: tuple[KeyBinding, ...]¶
- hints() tuple[BindingHint, ...][source]¶
Descriptions in declaration order, omitting undescribed aliases.
- dispatch(event: int) EventResult[source]¶
- class bruhanimate.keyboard.FocusTarget(name: str, bindings: KeyBindings, *, enabled: bool = True)[source]¶
Bindings and focus state for one Panel, registered in one FocusGroup.
A target becomes eligible when its panel starts and is visible. enabled=False excludes it without pausing its animation. The group borrows targets; neither targets nor groups start/close content. Never attach a target to two panels.
- __init__(name: str, bindings: KeyBindings, *, enabled: bool = True) None[source]¶
- property name: str¶
- property bindings: KeyBindings¶
- property focused: bool¶
Read-only selection state; reading/drawing never changes focus.
- property available: bool¶
- property enabled: bool¶
- class bruhanimate.keyboard.FocusGroup(*targets: FocusTarget, global_bindings: KeyBindings | None = None, next_key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'] = 'tab', previous_key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'] = 'shift+tab', wrap: bool = True)[source]¶
Route navigation, then focused bindings, then application bindings.
Pass handle_event to Player. Tab/Shift+Tab are reserved navigation by default; override next_key/previous_key with typed key names. Focus wraps by default. Unhandled q/Q, Escape and Ctrl+C quit unless global_bindings is supplied. Local bindings can consume those keys first. Hidden/disabled/closed panels are skipped. Showing a panel does not steal focus from an eligible selection. No terminal polling, simulation advancement, or child ownership occurs here.
- __init__(*targets: FocusTarget, global_bindings: KeyBindings | None = None, next_key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'] = 'tab', previous_key: Literal['space', 'enter', 'escape', 'tab', 'shift+tab', 'backspace', 'delete', 'insert', 'home', 'end', 'left', 'right', 'up', 'down', 'page_up', 'page_down', 'ctrl+c', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'] = 'shift+tab', wrap: bool = True) None[source]¶
- property focused: FocusTarget | None¶
- property targets: tuple[FocusTarget, ...]¶
- focus(name: str) bool[source]¶
Select a named eligible target; False if hidden/disabled, KeyError if unknown.
- move(direction: Literal['next', 'previous'] = 'next') None[source]¶
Move in declaration order, skipping unavailable targets.
- dispatch(event: int) EventResult[source]¶