Skip to content

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)

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)

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