Event Handling

Wijjit applications are event driven. Keyboard input, mouse activity, and widget actions are converted into strongly-typed events defined in wijjit.core.events and dispatched through wijjit.core.events.HandlerRegistry. This chapter explains how to hook those events, manage scope, and keep handlers responsive.

Event types

EventType.KEY

Low-level key presses. Wrapped by wijjit.core.events.KeyEvent (event.key, event.modifiers, event.key_obj).

EventType.ACTION

Emitted by widgets such as buttons, menus, dialogs, and inputs configured with action="…". Delivered as wijjit.core.events.ActionEvent containing action_id, source_element_id, and optional data.

EventType.CHANGE

Triggered when a bound input modifies its value. wijjit.core.events.ChangeEvent surfaces element_id, old_value, and new_value.

EventType.FOCUS / EventType.BLUR

Sent whenever focus moves between elements. Helpful for validation and inline help.

EventType.MOUSE

Raised for clicks, drags, scrolls, and hover updates. Wraps wijjit.terminal.mouse.MouseEvent with coordinates and modifiers.

Registering handlers

Wijjit exposes decorator helpers on the app instance:

@app.on_key("ctrl+s")

Decorator that registers a KEY handler with hotkey parsing (ctrl, alt, shift). The key is matched exactly (case-insensitively) against event.key; there is no "*" wildcard.

@app.on_action("save")

Decorator that subscribes to a specific action id. Handy for buttons and dialogs.

app.on(EventType.CHANGE, handler, scope=HandlerScope.VIEW)

Lowest-level API, and the only one that reaches every EventType and any HandlerScope. It works both ways: pass a callback to register immediately, or omit it to use @app.on(...) as a decorator (matching the @app.on_key / @app.on_action style):

@app.on(EventType.CHANGE, scope=HandlerScope.VIEW, view_name="signup")
def on_change(event):
    ...

Handlers receive one argument (the event object). They can be synchronous or async; Wijjit detects coroutines automatically. Example:

from wijjit.core.events import EventType, HandlerScope

async def handle_keys(event):
    if event.key == "escape":
        app.quit()

app.on(
    EventType.KEY,
    handle_keys,
    scope=HandlerScope.VIEW,
    view_name="home",
    priority=10,
)

Handler scopes & lifetimes

HandlerScope.GLOBAL

Always active. Use for cross-view shortcuts (Ctrl+C to quit). Keep global handlers minimal to avoid unexpected interactions.

HandlerScope.VIEW

Active only while the named view is visible. When navigation occurs, Wijjit automatically unregisters view-scoped handlers and re-registers those belonging to the new view. To get this scope, pass scope=HandlerScope.VIEW (and a view_name) to app.on(...). Note that @app.on_key always registers at HandlerScope.GLOBAL, and @app.on_action does not use HandlerScope at all – its handlers are stored in a separate action-handler map.

HandlerScope.ELEMENT

Tied to a specific widget id. Wijjit uses this scope to route a widget’s own focus, mouse, and change events; custom elements can register element-scoped handlers to capture events for their id.

Priorities default to 0; higher values run earlier. For example, Wijjit registers Tab/Shift+Tab navigation with priority 100 so it executes before user code can intercept the key.

Actions & change events

Any widget can emit an action:

  • Buttons – {% button action="save" %}

  • Text inputs – {% textinput action="login" %} (fires on Enter)

  • Dialogs – {% confirmdialog action_ok="confirm_delete" %}

  • Menus – menu items specify action on each entry.

Handle them using @app.on_action("save") or app.on(EventType.ACTION, handler) if you need a catch-all logger. ActionEvent.data carries widget-specific payloads (e.g., selected option).

ChangeEvent is emitted by bound inputs (text, textarea, checkbox, select). It is useful for real-time validation:

def validate(event):
    if event.element_id == "password" and len(event.new_value) < 8:
        app.state.password_error = "Too short"

app.on(EventType.CHANGE, validate, scope=HandlerScope.VIEW, view_name="signup")

Keyboard shortcuts

prompt-toolkit normalizes keys to strings ("ctrl+c", "f5", "enter"). @app.on_key registers one key per decorator; apply it multiple times (or call it directly) to bind several keys:

@app.on_key("ctrl+s")
def save_handler(event):
    save()

@app.on_key("ctrl+w")
def close_handler(event):
    close_tab()

To stop further handlers from running, call event.cancel().

Mouse handling

Mouse events surface through wijjit.core.events.MouseEvent, which wraps the terminal-layer mouse event in event.mouse_event and exposes convenience properties directly on the event: event.x, event.y, event.button, event.mouse_type, event.shift/event.alt/event.ctrl, and event.click_count. Scroll direction is read from event.button (there is no scroll_amount):

from wijjit.terminal.mouse import MouseButton

def on_mouse(event):
    if event.button == MouseButton.SCROLL_UP and event.ctrl:
        zoom_in()
    elif event.button == MouseButton.SCROLL_DOWN and event.ctrl:
        zoom_out()

app.on(EventType.MOUSE, on_mouse, scope=HandlerScope.VIEW, view_name="canvas")

For widget-level interactions (click a specific element), implement handle_mouse on the element or rely on built-in widgets (buttons, menus, scroll containers) which already handle mouse input.

Element event callbacks

In addition to app-level handlers, elements expose callback attributes for direct event handling:

Base Element Callbacks (all elements):

  • on_double_click(event: MouseEvent) – Called on double-click

  • on_context_menu(event: MouseEvent) -> list | None – Called on right-click, return menu items

Drag-and-Drop (all elements, set draggable=True or drop_target=True to enable):

  • on_drag_start(event) -> data | None – Start drag, return data or None to cancel

  • on_drag(event, data) – Called during drag

  • on_drag_end(event, data, dropped) – Called when drag ends

  • on_drag_over(event, data) -> bool – Check if drop allowed

  • on_drop(event, data, source_element) -> bool – Handle drop

Table Callbacks:

  • on_row_click(row_index, row_data) – Row clicked

  • on_row_double_click(row_index, row_data) – Row double-clicked

  • on_cell_click(row_index, column_key, value) – Cell clicked

  • on_header_click(column_key) – Column header clicked

  • on_sort(column_key, direction) – Sort changed

TextInput/TextArea Callbacks:

  • on_change(old_value, new_value) – Value changed

  • on_submit(value) – Enter pressed (TextInput) or Ctrl+Enter (TextArea)

  • on_paste(text) -> str | None – Modify pasted text, return None to use original

  • on_file_path_paste(paths) -> bool – File paths detected in paste, return True to prevent

Checkbox / Radio / Toggle Callbacks:

These boolean inputs share one callback vocabulary — on_change = the value changed, on_action = the user activated the control:

  • on_change(old, new) – Checked state changed (old/new are booleans). Fires whenever the value changes, whether the user clicked or you set it in code: checkbox.checked = True triggers on_change the same as a click. (CheckboxGroup passes selection lists: on_change(old_values, new_values).)

  • on_action() – The control was toggled/activated (no arguments). On Toggle this is the activation hook to use.

Example:

from wijjit.elements.input.text import TextInput
from wijjit.elements.display.table import Table

# TextInput with submit handling
search = TextInput(id="search")
search.on_submit = lambda value: print(f"Search: {value}")

# Table with row selection
table = Table(data=data, columns=columns)
table.on_row_click = lambda idx, row: print(f"Selected: {row}")

Asynchronous handlers

Mark a handler async def to perform network calls or I/O. Wijjit awaits the coroutine. If you need to run CPU-intensive synchronous handlers off the main loop, set the config keys RUN_SYNC_IN_EXECUTOR = True (and optionally EXECUTOR_MAX_WORKERS); synchronous handlers then execute on a thread pool and the event loop remains responsive.

Error handling & debugging

  • An exception inside a handler is caught and logged with a full stack trace, and the app keeps running unless the error is fatal - a bad handler will not take down your whole UI.

  • Use logger = get_logger(__name__) and log inside handlers to confirm they fire in the expected order.

Best practices

  • Keep handlers small. Delegate business logic to separate functions/services.

  • Prefer specific scopes. Global handlers are powerful but can cause conflicts as your app grows.

  • Use actions for semantic events (“save”, “submit”) and key handlers for physical keystrokes. That way you can trigger the same action from buttons, menus, or keyboard shortcuts without duplicating code.

  • Cancel events when you consume them to prevent downstream handlers from running unnecessarily.

  • Remove long-lived handlers you register manually when they’re no longer needed to avoid memory leaks — app.unregister_key(key) removes a handler bound via @app.on_key, and handler_registry.unregister covers the general case.

Next, read Layout System and Components to see how these events translate into UI building blocks.