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, backspace

  • left, right, up, down, home, end, page_up, page_down

  • insert, delete, ctrl+c, and f1 through f12

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:

  1. Reserved navigation keys move focus and consume the event.

  2. The focused target’s bindings get the event.

  3. 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.

help_text(separator: str = ' | ') → str[source]

Format declarative hints; never invoke actions.

dispatch(event: int) → EventResult[source]
handle_event(event: int) → bool[source]

Player-compatible handler; handled actions never accidentally quit.

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.

help_text() → str[source]

Current target’s hints for an update-time StatusBar callback.

dispatch(event: int) → EventResult[source]
handle_event(event: int) → bool[source]

Return True only for quit, matching Player’s event_handler contract.

bruhanimate.keyboard._set_visible(animation: object, visible: bool) → None[source]

Propagate layout visibility without input polling or lifecycle changes.