Reactivity
This guide explains how to connect widgets and application state using signals, callbacks, and events.
Three Mechanisms
bootstack provides three ways to respond to changes:
| Mechanism | Purpose | Use For |
|---|---|---|
| Signals | Reactive state | Values that multiple widgets share |
| Callbacks | Direct actions | Button clicks, menu selections |
| Events | Low-level input | Keyboard, mouse, focus |
Each has its place. Understanding when to use each makes applications cleaner.
Signals
Signals represent state that can be observed.
import bootstack as bs
app = bs.App()
# Create a signal
name = bs.Signal("")
# Connect an entry to the signal
entry = bs.TextEntry(app, textsignal=name)
entry.pack(padx=20, pady=10)
# React to signal changes
def on_name_changed(value):
print(f"Name changed to: {value}")
name.subscribe(on_name_changed)
app.mainloop()
Key characteristics:
- Signals hold a value that changes over time
- Multiple widgets can share the same signal
- Subscribers are notified when the value changes
- Widgets don't know about each other—they only know the signal
Creating Signals
# String signal
name = bs.Signal("")
# Boolean signal
enabled = bs.Signal(False)
# Numeric signal
count = bs.Signal(0)
# Any type
selection = bs.Signal(None)
Reading and Writing
# Get current value
current = name.get()
# Set value (notifies subscribers)
name.set("Alice")
# Alternative property access
current = name.value
name.value = "Alice"
Subscribing
def on_changed(value):
print(f"New value: {value}")
# Subscribe
name.subscribe(on_changed)
# Unsubscribe
name.unsubscribe(on_changed)
Subscribers receive the new value whenever it changes.
Connecting Widgets
Many widgets accept a signal parameter:
name = bs.Signal("")
# Entry updates the signal
entry = bs.TextEntry(app, textsignal=name)
# Label displays the signal
label = bs.Label(app, textsignal=name)
When the entry changes, the label updates automatically.
Mapping and Transforming
Create derived signals by mapping a source signal through a transform function:
import bootstack as bs
app = bs.App()
# Source signal
celsius = bs.Signal(0)
# Derived signal: transforms celsius to fahrenheit
fahrenheit = celsius.map(lambda c: c * 9/5 + 32)
# UI
bs.Label(app, text="Celsius:").pack()
bs.Scale(app, from_=0, to=100, signal=celsius).pack(fill="x", padx=20)
bs.Label(app, text="Fahrenheit:").pack(pady=(20, 0))
bs.Label(app, textsignal=fahrenheit).pack()
app.mainloop()
When celsius changes, fahrenheit updates automatically with the transformed value.
Common Mapping Patterns
Formatting for display:
price = bs.Signal(29.99)
# Format as currency
display_price = price.map(lambda p: f"${p:.2f}")
bs.Label(app, textsignal=display_price) # Shows "$29.99"
Boolean to text:
is_enabled = bs.Signal(True)
# Map boolean to descriptive text
status_text = is_enabled.map(lambda v: "Enabled" if v else "Disabled")
bs.Label(app, textsignal=status_text)
Validation state:
username = bs.Signal("")
# Derive validity
is_valid = username.map(lambda u: len(u) >= 3)
# Derive message
validation_msg = username.map(
lambda u: "" if len(u) >= 3 else "Username must be at least 3 characters"
)
Chaining transforms:
raw_input = bs.Signal(" hello world ")
# Chain multiple transforms
cleaned = raw_input.map(str.strip).map(str.title)
# cleaned.get() returns "Hello World"
Combining Multiple Signals
For transformations that depend on multiple signals, use subscribe with manual updates:
width = bs.Signal(10)
height = bs.Signal(20)
area = bs.Signal(0)
def update_area(*_):
area.set(width.get() * height.get())
width.subscribe(update_area)
height.subscribe(update_area)
update_area() # Initialize
Or create a helper for common cases:
def combine(signals, transform):
"""Create a signal that combines multiple source signals."""
result = bs.Signal(transform(*[s.get() for s in signals]))
def update(*_):
result.set(transform(*[s.get() for s in signals]))
for s in signals:
s.subscribe(update)
return result
# Usage
width = bs.Signal(10)
height = bs.Signal(20)
area = combine([width, height], lambda w, h: w * h)
Signal API
See bootstack.Signal for all methods.
Callbacks
Callbacks handle discrete actions.
import bootstack as bs
app = bs.App()
def on_submit():
print("Form submitted!")
bs.Button(app, text="Submit", command=on_submit).pack(pady=20)
app.mainloop()
Callbacks are:
- fired once per action
- tied to a specific widget
- good for commands, not ongoing state
Common Callback Patterns
Button Click
def save_file():
# ... save logic ...
pass
bs.Button(app, text="Save", command=save_file)
With Arguments
def delete_item(item_id):
print(f"Deleting {item_id}")
# Use lambda to pass arguments
bs.Button(app, text="Delete", command=lambda: delete_item(42))
Reading State in Callbacks
name = bs.Signal("")
def on_submit():
current_name = name.get()
print(f"Submitting: {current_name}")
entry = bs.TextEntry(app, textsignal=name)
button = bs.Button(app, text="Submit", command=on_submit)
The callback reads the signal's current value when invoked.
Events
Events handle low-level input like keyboard and mouse.
import bootstack as bs
app = bs.App()
entry = bs.Entry(app)
entry.pack(padx=20, pady=20)
def on_key(event):
print(f"Key pressed: {event.keysym}")
entry.bind("<Key>", on_key)
app.mainloop()
Events are:
- tied to Tk's event system
- useful for keyboard shortcuts, mouse gestures
- more complex than signals or callbacks
Common Events
# Keyboard
widget.bind("<Return>", on_enter)
widget.bind("<Escape>", on_escape)
widget.bind("<Control-s>", on_save)
# Mouse
widget.bind("<Button-1>", on_left_click)
widget.bind("<Double-Button-1>", on_double_click)
widget.bind("<Enter>", on_mouse_enter) # Hover
widget.bind("<Leave>", on_mouse_leave)
# Focus
widget.bind("<FocusIn>", on_focus)
widget.bind("<FocusOut>", on_blur)
Virtual Events
Some widgets emit virtual events for semantic actions:
def on_selection_changed(event):
print("Selection changed")
listview.bind("<<SelectionChange>>", on_selection_changed)
Convenience Methods
Many bootstack widgets provide on_* and off_* methods that abstract common event bindings. Prefer these over manual binding when available:
# Preferred: use convenience methods
def handle_change(event):
print("Value changed:", event.data)
# on_* returns a bind_id for later removal
bind_id = entry.on_changed(handle_change)
# Later, to remove the binding, pass the bind_id
entry.off_changed(bind_id)
This is cleaner than manual binding:
# Manual binding (works, but prefer on_* methods)
bind_id = entry.bind("<<Changed>>", handle_change)
entry.unbind("<<Changed>>", bind_id)
Generating Virtual Events
Use event_generate to programmatically emit a virtual event:
# Emit an event on a widget
widget.event_generate("<<MyCustomEvent>>")
Event scope: Virtual events propagate up the widget hierarchy. A binding on a parent widget will receive events generated by its children:
# Parent binds to event
app.bind("<<FormSubmitted>>", on_form_submitted)
# Child generates event — parent receives it
submit_button.event_generate("<<FormSubmitted>>")
This enables loose coupling: children emit events, parents handle them.
Enhanced Virtual Events with Data
bootstack extends Tk's virtual events to support passing data through the event object:
# Generate event with data
widget.event_generate("<<ItemSelected>>", data={"id": 42, "name": "Alice"})
# Handler receives data in event.data
def on_item_selected(event):
print(f"Selected: {event.data['name']} (ID: {event.data['id']})")
widget.bind("<<ItemSelected>>", on_item_selected)
This is particularly useful for:
- Passing selected items from lists or tables
- Communicating form values on submission
- Custom widget-to-parent communication
# Real-world example: custom list widget
class ItemList(bs.Frame):
def select_item(self, item):
self._selected = item
# Emit event with the selected item as data
self.event_generate("<<ItemSelected>>", data=item)
# Parent handles the event
def on_selected(event):
item = event.data
print(f"User selected: {item}")
item_list = ItemList(app)
item_list.bind("<<ItemSelected>>", on_selected)
Events & Bindings
See Platform → Events & Bindings for the full event system.
Choosing the Right Mechanism
Use Signals When
- Multiple widgets need the same value
- State should persist across interactions
- You want reactive updates
# Good: shared username across form
username = bs.Signal("")
entry = bs.TextEntry(app, textsignal=username)
preview = bs.Label(app, textsignal=username)
Use Callbacks When
- A button or action triggers something
- The action happens once, not continuously
- There's a clear "do this" moment
# Good: button submits form
bs.Button(app, text="Submit", command=submit_form)
Use Events When
- You need keyboard shortcuts
- You need mouse interaction details
- You're handling focus or hover
# Good: keyboard shortcut
app.bind("<Control-s>", lambda e: save_file())
Combined Patterns
Form with Submit
import bootstack as bs
app = bs.App()
# State
username = bs.Signal("")
password = bs.Signal("")
# UI
form = bs.PackFrame(app, gap=10, padding=20)
form.pack(fill="both", expand=True)
bs.Label(form, text="Username:").pack()
bs.TextEntry(form, textsignal=username).pack()
bs.Label(form, text="Password:").pack()
bs.PasswordEntry(form, textsignal=password).pack()
# Submit reads current signal values
def on_submit():
print(f"Login: {username.get()} / {password.get()}")
bs.Button(form, text="Login", command=on_submit).pack()
app.mainloop()
Live Preview
import bootstack as bs
app = bs.App()
content = bs.Signal("")
main = bs.PackFrame(app, gap=20, padding=20)
main.pack(fill="both", expand=True)
left = bs.PackFrame(main, gap=8)
left.pack(fill="both", expand=True)
bs.Label(left, text="Editor").pack(anchor="w")
entry = bs.TextEntry(left)
entry.pack(fill="x")
entry.on_input(lambda e: content.set(e.data["text"]))
right = bs.PackFrame(main, gap=8)
right.pack(fill="both", expand=True)
bs.Label(right, text="Preview").pack(anchor="w")
bs.Label(right, textsignal=content, wraplength=300).pack(anchor="w")
app.mainloop()
Keyboard Shortcuts
import bootstack as bs
app = bs.App()
def save():
print("Saving...")
def quit_app():
app.destroy()
# Global shortcuts
app.bind("<Control-s>", lambda e: save())
app.bind("<Control-q>", lambda e: quit_app())
bs.Label(app, text="Press Ctrl+S to save, Ctrl+Q to quit").pack(pady=20)
app.mainloop()
What Signals Are Built On
Signals are implemented using Tk variables (StringVar, IntVar, etc.) with traces.
This means:
- Signals work with any Tk widget
- They integrate with Tk's event loop
- They're not a separate system—they're Tk-native
The Signal API provides a cleaner interface:
| Tk Variables | Signals |
|---|---|
var.trace_add() |
signal.subscribe() |
var.get() |
signal.get() / signal.value |
var.set() |
signal.set() / signal.value = ... |
Summary
- Signals for shared state and reactive updates
- Callbacks for discrete actions (button clicks)
- Events for low-level input (keyboard, mouse)
Use signals when state is shared. Use callbacks when an action happens. Use events when you need input details.
Next Steps
- App Structure — how applications are organized
- Layout — building layouts with containers
- Color & Theming — applying consistent styling