Events#
Widgets announce what they do as named events — a button reports a click,
a field reports a change when its value is committed. You respond by binding
a handler. Each widget exposes on_*() shorthands for the events it supports,
and every handler can be detached again through the subscription it returns.
What a handler receives#
There are two kinds of event, and the handler argument differs accordingly:
Data-carrying events (
change,input,select, …) hand the handler a typed payload object directly — the argument is the payload. Read its attributes straight off:e.value,e.text,e.record.Native events (
click,hover,focus,blur,resize, key and scroll events) carry no payload, so the handler receives a curatedEventdescribing where it happened and which modifier keys were held.
Every payload type is cataloged in bootstack.events, so editors can
autocomplete the attributes available on each event.
Note
Two widgets hand their handlers a plain object instead of a payload
dataclass, by design. ListView item events
(on_item_click, on_item_insert, …) and Form’s
on_data_change deliver the record dict directly (e["field"]).
Tree node events (on_activate, on_expand,
on_collapse) deliver the TreeNode handle. Everywhere else, a
data-carrying event is one of the payloads below.
Listening for an event#
Call the matching on_*() shorthand with a handler. It binds immediately and
returns a Subscription:
field = bs.TextField()
field.on_change(lambda e: print("committed:", e.value))
field.on_input(lambda e: print("typed:", e.text))
For a simple button action, the on_click= constructor argument takes a
no-argument callback:
bs.Button("Save", on_click=lambda: save())
Every shorthand is a thin wrapper over the generic on() method, which takes
the event name as a string. Reach for it for an event without a dedicated
shorthand, or when the event name is computed:
widget.on("right_click", show_context_menu)
The shorthand and the generic form are the same call: for an event named
change, widget.on_change(handler) is shorthand for
widget.on("change", handler). The tables below name each event, so you can
use either form.
Events by widget#
Each widget exposes only the events that make sense for it. This is the map from a widget to the events you can bind and the payload each one delivers — reach for it when you know the widget but not the event name. (The API reference is the complementary index, organized by payload type.)
Actions
Widget |
Event |
Handler receives |
|---|---|---|
|
|
|
|
||
|
Text, number, and date fields (TextField, NumberField, PathField,
PasswordField, DateField, TimeField, SpinnerField) share one event set —
on_input fires on every keystroke, on_change when the value is committed
(blur or Enter), and on_submit on Enter.
Widget |
Event |
Handler receives |
|---|---|---|
Any field |
|
|
(same) |
|
|
(same) |
|
|
(same) |
|
|
(same) |
|
Boolean and selection controls
Widget |
Event |
Handler receives |
|---|---|---|
|
||
(same) |
|
|
|
Sliders and meters
Widget |
Event |
Handler receives |
|---|---|---|
|
||
|
||
|
||
|
Lists, tables, and trees
Widget |
Event |
Handler receives |
|---|---|---|
|
the record |
|
|
||
|
||
|
||
|
||
|
||
|
the |
Navigation and containers
Widget |
Event |
Handler receives |
|---|---|---|
|
||
|
||
|
||
|
||
|
||
|
Each widget’s own guide lists its complete event set; the tables above cover the events you reach for most.
Reading a payload#
For a data-carrying event the handler argument is the payload itself. Read the attributes straight off it — editors autocomplete them because every payload is a frozen dataclass.
A change event is a ChangeEvent carrying the
committed value, the prev_value, and the raw text:
def on_change(e):
print(e.value, "was", e.prev_value)
field.on_change(on_change)
A slider distinguishes the value moving from the value settling. Use
on_change for live feedback as the handle drags, and on_commit for the
expensive work you only want once the drag ends:
preview = bs.Label("100%")
slider = bs.Slider(value=100, min_value=0, max_value=200)
def show_zoom(e):
preview.text = f"{e.value:.0f}%"
slider.on_change(show_zoom)
slider.on_commit(lambda e: apply_zoom(e.value))
A table reports both the row a user acts on and the current selection. A
RowEvent carries the record and its id; a
SelectionEvent carries every selected record and
id:
def show_count(e):
status.text = f"{len(e.records)} selected"
table.on_row_double_click(lambda e: open_detail(e.id))
table.on_select(show_count)
A tab strip tells you not just which tab is active but why it changed — a
TabChangeEvent carries the current and
previous tab (each a TabRef), plus the reason
and via tags. Use them to ignore programmatic changes, for example:
def on_tab(e):
if e.reason == "user":
track_view(e.current.key)
tabs.on_change(on_tab)
A page change carries navigation context — a
PageChangeEvent exposes the page now active, the
prev_page, and can_back / can_forward flags you can bind a toolbar
to:
def on_page(e):
back_button.disabled = not e.can_back
shell.on_page_change(on_page)
The curated Event#
Native events hand the handler a frozen Event. It exposes the originating widget, the pointer
position (x, y, x_root, y_root), the widget width and
height, scroll delta, modifier-key booleans (ctrl, shift,
alt, meta), and — for keyboard events — a clean key and char.
button.on_click(lambda e: print("clicked at", e.x, e.y, "ctrl:", e.ctrl))
These pointer, focus, and geometry events can be bound on any widget by
name. Several have no on_*() shorthand, so on() is the only way to reach
them:
Event |
Fires when |
|---|---|
|
A mouse button is pressed over the widget. |
|
The pointer enters or leaves the widget. |
|
The widget gains or loses keyboard focus. |
|
The widget’s size changes. |
Each hands the handler a curated Event. Pass
any of these names to on() (click, focus, and blur also have
shorthands on the widgets that emit them).
Widget lifecycle#
Every widget also reports its own teardown. on_destroy fires once, as the
widget is being destroyed — when you call widget.destroy(), when a container
it lives in is torn down (each child fires its own), or when the window closes.
It is the place to release anything the widget holds that is not cleaned up for
you: a file handle, an external subscription, a timer you started outside the
widget’s own schedule (jobs on that are canceled automatically).
feed = open_price_feed()
ticker = bs.Label("—")
ticker.on_destroy(lambda e: feed.close())
The handler receives the same curated Event as
the native events above. Unlike them, on_destroy fires exactly once and
cannot be canceled. For a window’s close button — which a handler can veto —
use on_close instead (see App Structures).
Detaching and reattaching#
Destroying a widget is permanent. To pull a widget out of the layout without
tearing it down — and put it back later in the same spot — use detach and
attach. A detached widget stops taking up space but keeps its state,
children, and bindings.
panel = bs.VStack()
...
panel.detach() # hide it — frees its space
panel.attach() # bring it back exactly where it was
attach accepts the same layout options as the constructor, so you can move a
widget as you bring it back. For stacked widgets, index= sets the position
among the currently attached siblings (or pass an explicit before= /
after= sibling):
row.detach()
row.attach(index=0) # back at the top of its stack
The is_attached property reports the current state, and a plain detach()
on an already-detached widget (or attach() after a no-argument detach)
round-trips cleanly.
To build a widget that starts hidden, pass attached=False to its
constructor. It is created and parented in place — so a later attach()
drops it into the slot it was declared in — but takes up no space until shown,
with no startup flicker:
with bs.VStack():
bs.Label("Account")
banner = bs.Label("Saved!", accent="success", attached=False)
bs.TextField()
...
banner.attach() # reveal it between the label and the field
Two events bracket this. on_attach fires whenever the widget enters the
layout — on its initial placement and on every attach — and on_detach
fires when it leaves, including when an ancestor hides it. They are the place to
start and stop work that should only run while the widget is on screen:
chart = bs.Card()
chart.on_attach(lambda e: feed.subscribe(chart.refresh))
chart.on_detach(lambda e: feed.unsubscribe(chart.refresh))
Each hands the handler a curated Event.
Filtering native events#
The modifier state arrives as plain booleans, so branching on it is ordinary Python. A Ctrl-click that means something different from a plain click:
def on_click(e):
if e.ctrl:
add_to_selection(e.widget)
else:
replace_selection(e.widget)
row.on_click(on_click)
To filter before the handler runs — so the handler only sees the events you
care about — bind a stream instead and chain
filter:
# Only Ctrl-clicks reach the handler.
row.on_click().filter(lambda e: e.ctrl).listen(add_to_selection)
Subscriptions and stream handles#
Binding a handler directly returns a Subscription; building a stream
returns a Handle. The two are interchangeable where it counts — both detach
the handler when you call cancel(), and both work as context managers. Hold
on to whichever you get when the handler outlives the thing it watches:
sub = item.on_click(handle_select)
sub.cancel() # stop listening
A subscription (or handle) is also a context manager, which binds the handler only for the duration of a block. Reach for this when a binding’s life should match a bounded interaction that runs its own event loop — most often a modal dialog. While the dialog below is open, the table’s selection is mirrored into it; the handler is removed the moment the dialog closes, even on an error:
with table.on_select(lambda e: dialog.set_count(len(e.records))):
dialog.show() # modal — runs until the user closes it
Around ordinary synchronous code the event loop never pumps, so the handler
would never fire before the block exits — use with only when something
inside it processes events.
Emitting your own events#
Use emit() to fire an event yourself, optionally with a payload — this is how
a composite widget surfaces its own high-level activity to listeners. Any name
that isn’t a built-in event is treated as a custom event, and its handlers
receive whatever you pass as data. A plain dict is the natural choice:
# A handler bound by name...
widget.on("row_imported", lambda e: print(e["row"], e["source"]))
# ...fires when you emit that event with a payload.
widget.emit("row_imported", data={"row": 42, "source": "clipboard"})
For a built-in event, pass its matching payload from bootstack.events
instead — the same object an on_<event>() handler would receive:
widget.emit("change", data=bs.events.ChangeEvent(value=new_value))
See also#
API reference#
The complete catalog — the curated Event, the
Subscription handle, and every typed
payload — lives in Events. The most common payloads at a
glance:
The object a handler receives for native and context events. |
|
Handle returned by |
|
Fires when a field's value is committed (on blur or Enter). |
|
Fires on every keystroke, before the value is committed. |
|
Fires after validation runs — |
|
Fires when the set of selected rows changes. |
|
Fires for a multi-row action (insert, update, delete, move). |
|
Broadcast by a data source when its data or view changes. |