Settings Panel — Selection Controls in Action 🚀¶
Build a complete settings panel with Switch, Checkbox, Slider, RadioGroup, and SegmentedControl — and see how to bind all of them cleanly to a single typed state dataclass.
What you'll build¶
A settings panel split into four sections, plus a live summary card:
| Section | Widgets | Controls |
|---|---|---|
| Notifications | Switch + Checkbox | Push notifications, e-mail alerts, sounds |
| Appearance | SegmentedControl + Slider | Theme (System/Light/Dark), font size, quality |
| Audio & Storage | Slider + Switch | Playback volume, auto-save drafts |
| Language | RadioGroup | Interface language |
| Live summary | Card (read-only) | Real-time reflection of all the values above |
Every interaction — dragging a slider, checking a box, picking a segment — immediately updates the summary card, making two-way binding concretely visible.
Note — why a live summary?
The summary card is not decoration. It proves that each control genuinely modifies the shared state. If you click a control and the summary does not change, there is a bug. It is the fastest smoke test possible.
Prerequisites¶
Recommended reading before continuing:
- Basic tutorial —
App,view, andset_state - Managing state — how the update cycle works
- Execution modes — WASM vs. server
Creating the project¶
Step 1 — Defining option constants¶
Before the state, define the option lists for the selection controls. Keeping them as constants at the top of the file avoids duplication and makes future adjustments easy.
from __future__ import annotations
_THEME_OPTIONS: list[str] = ["System", "Light", "Dark"]
_LANGUAGE_OPTIONS: list[str] = ["English", "Português", "Español", "Français"]
_QUALITY_OPTIONS: list[str] = ["Low", "Medium", "High", "Ultra"]
Tip — index as state, not the string
The state stores the index (theme_index: int = 0), not the string "System". This keeps the serialized state compact and language-independent. To display the label, use _THEME_OPTIONS[state.theme_index] at render time.
Step 2 — Modelling the state¶
With the options defined, model exactly what needs to persist between renders:
from dataclasses import dataclass
@dataclass
class SettingsState:
"""All mutable settings controlled by the panel.
Attributes:
notifications_enabled: Master switch for push notifications.
email_alerts: Whether to send e-mail alerts on events.
sound_enabled: Whether in-app sounds are active.
auto_save: Whether drafts are saved automatically.
theme_index: Index into ``_THEME_OPTIONS`` (0=System, 1=Light, 2=Dark).
language_index: Index into ``_LANGUAGE_OPTIONS``.
volume: Playback volume in ``[0, 100]``.
font_size: Preferred font size in ``[10, 30]`` logical points.
quality_index: Index into ``_QUALITY_OPTIONS`` (stream/render quality).
"""
notifications_enabled: bool = True
email_alerts: bool = False
sound_enabled: bool = True
auto_save: bool = True
theme_index: int = 0
language_index: int = 0
volume: float = 70.0
font_size: float = 16.0
quality_index: int = 2
def make_state() -> SettingsState:
"""Build the initial settings state.
Returns:
A fresh :class:`SettingsState` with sensible defaults.
"""
return SettingsState()
make_state is the function tempestweb calls to initialize the app. It must exist with that exact name in the module.
Step 3 — Event types¶
Two event types arrive from input controls. Import them from tempestweb._core.widgets.events:
| Type | Used by | Relevant field |
|---|---|---|
ToggleEvent |
Switch, Checkbox |
.checked: bool |
SlideEvent |
Slider |
.value: float |
RadioGroup and SegmentedControl deliver the index (int) directly to the callback — no event wrapper.
Step 4 — Notifications section¶
The first section uses Switch for the master control and two Checkbox widgets for sub-options. The UI is organised in a _notifications_card function that takes app and returns a Card:
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import AppBar, Card, Divider, Scaffold
from tempestweb._core.style import AlignItems, Edge, FontWeight
from tempestweb._core.widgets import Checkbox, Column, Row, Switch, Text
from tempestweb._core.widgets.events import ToggleEvent
def _notifications_card(app: App[SettingsState]) -> Widget:
"""Render the Notifications section with Switch and Checkbox controls.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the notification preference controls.
"""
state: SettingsState = app.state
def on_notifications_toggle(event: ToggleEvent) -> None:
"""Toggle master notification switch."""
app.set_state(lambda s: setattr(s, "notifications_enabled", event.checked))
def on_email_toggle(event: ToggleEvent) -> None:
"""Toggle e-mail alert preference."""
app.set_state(lambda s: setattr(s, "email_alerts", event.checked))
def on_sound_toggle(event: ToggleEvent) -> None:
"""Toggle in-app sound preference."""
app.set_state(lambda s: setattr(s, "sound_enabled", event.checked))
return Card(
key="notifications-card",
children=[
Text(
content="Notifications",
key="notif-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="notif-divider"),
Row(
key="notif-master-row",
style=Style(gap=12.0, align=AlignItems.CENTER),
children=[
Text(
content="Enable notifications",
key="notif-master-label",
style=Style(font_size=14.0, grow=1.0),
),
Switch(
checked=state.notifications_enabled,
on_change=on_notifications_toggle,
key="notif-switch",
),
],
),
Checkbox(
label="Send e-mail alerts",
checked=state.email_alerts,
on_change=on_email_toggle,
key="email-checkbox",
),
Checkbox(
label="Play sounds",
checked=state.sound_enabled,
on_change=on_sound_toggle,
key="sound-checkbox",
),
],
)
Note — Switch in a Row with grow=1.0
The Text with grow=1.0 takes all available space in the row, pushing the Switch to the right — the classic settings-row pattern seen in iOS and Android. The gap=12.0 on the Row adds horizontal spacing between the two.
Step 5 — Appearance section¶
This section introduces SegmentedControl (for theme and quality) and Slider (for font size):
from tempestweb._core.components import SegmentedControl
from tempestweb._core.widgets import Slider
from tempestweb._core.widgets.events import SlideEvent
def _appearance_card(app: App[SettingsState]) -> Widget:
"""Render the Appearance section with SegmentedControl and Slider controls.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the theme, font-size and quality controls.
"""
state: SettingsState = app.state
def on_theme_select(index: int) -> None:
"""Select a colour theme."""
app.set_state(lambda s: setattr(s, "theme_index", index))
def on_quality_select(index: int) -> None:
"""Select the render/stream quality level."""
app.set_state(lambda s: setattr(s, "quality_index", index))
def on_font_size_change(event: SlideEvent) -> None:
"""Adjust the preferred font size."""
app.set_state(lambda s: setattr(s, "font_size", round(event.value, 1)))
return Card(
key="appearance-card",
children=[
Text(
content="Appearance",
key="appearance-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="appearance-divider"),
Text(
content="Theme",
key="theme-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
SegmentedControl(
options=_THEME_OPTIONS,
selected=state.theme_index,
on_select=on_theme_select,
key="theme-segments",
),
Text(
content=f"Font size: {state.font_size:.0f} pt",
key="font-size-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
Slider(
value=state.font_size,
min_value=10.0,
max_value=30.0,
step=1.0,
on_change=on_font_size_change,
key="font-slider",
),
Text(
content="Render quality",
key="quality-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
SegmentedControl(
options=_QUALITY_OPTIONS,
selected=state.quality_index,
on_select=on_quality_select,
key="quality-segments",
),
],
)
Tip — dynamic label above the Slider
The Text before the Slider uses f"Font size: {state.font_size:.0f} pt". Each slider movement changes the state → view is called again → the label updates. No local variable or manual ref needed: the state is the source of truth.
Step 6 — Audio & Storage section¶
Volume via Slider and auto-save via Switch, following the same patterns:
def _audio_card(app: App[SettingsState]) -> Widget:
"""Render the Audio section with a volume Slider and auto-save Switch.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the audio and save controls.
"""
state: SettingsState = app.state
def on_volume_change(event: SlideEvent) -> None:
"""Adjust playback volume."""
app.set_state(lambda s: setattr(s, "volume", round(event.value)))
def on_auto_save_toggle(event: ToggleEvent) -> None:
"""Toggle auto-save preference."""
app.set_state(lambda s: setattr(s, "auto_save", event.checked))
return Card(
key="audio-card",
children=[
Text(
content="Audio & Storage",
key="audio-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="audio-divider"),
Text(
content=f"Volume: {state.volume:.0f}%",
key="volume-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
Slider(
value=state.volume,
min_value=0.0,
max_value=100.0,
step=1.0,
on_change=on_volume_change,
key="volume-slider",
),
Row(
key="auto-save-row",
style=Style(gap=12.0, align=AlignItems.CENTER),
children=[
Text(
content="Auto-save drafts",
key="auto-save-label",
style=Style(font_size=14.0, grow=1.0),
),
Switch(
checked=state.auto_save,
on_change=on_auto_save_toggle,
key="auto-save-switch",
),
],
),
],
)
Step 7 — Language section¶
RadioGroup is the right choice for single selection when all items should be visible at once:
from tempestweb._core.components import RadioGroup
def _language_card(app: App[SettingsState]) -> Widget:
"""Render the Language section with a RadioGroup control.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the language radio group.
"""
state: SettingsState = app.state
def on_language_select(index: int) -> None:
"""Select the preferred interface language."""
app.set_state(lambda s: setattr(s, "language_index", index))
return Card(
key="language-card",
children=[
Text(
content="Language",
key="language-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="language-divider"),
RadioGroup(
options=_LANGUAGE_OPTIONS,
selected=state.language_index,
on_select=on_language_select,
key="language-radio",
),
],
)
Note — RadioGroup vs. SegmentedControl
Use RadioGroup when there are more than 3-4 options or when labels are long — it stacks options vertically. Use SegmentedControl for 2-4 short options that fit on a single horizontal line.
Step 8 — The live summary card¶
This function takes the state directly (without the full app) because it does not need to register any handlers — it is read-only:
def _summary_card(state: SettingsState) -> Widget:
"""Render a live summary of all current settings.
This card re-renders on every state change and shows all selected values
so the user can verify that every control is truly bound to the state.
Args:
state: The current snapshot of :class:`SettingsState`.
Returns:
A ``Card`` listing all current setting values.
"""
theme_name: str = _THEME_OPTIONS[state.theme_index]
language_name: str = _LANGUAGE_OPTIONS[state.language_index]
quality_name: str = _QUALITY_OPTIONS[state.quality_index]
notif_text: str = "on" if state.notifications_enabled else "off"
email_text: str = "yes" if state.email_alerts else "no"
sound_text: str = "on" if state.sound_enabled else "off"
save_text: str = "on" if state.auto_save else "off"
lines: list[Widget] = [
Text(
content="Live summary",
key="summary-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="summary-divider"),
Text(
content=f"Notifications: {notif_text} | E-mail alerts: {email_text}",
key="summary-notif",
style=Style(font_size=13.0),
),
Text(
content=f"Sound: {sound_text} | Auto-save: {save_text}",
key="summary-sound",
style=Style(font_size=13.0),
),
Text(
content=(
f"Theme: {theme_name} | Font: {state.font_size:.0f} pt"
f" | Quality: {quality_name}"
),
key="summary-appearance",
style=Style(font_size=13.0),
),
Text(
content=f"Volume: {state.volume:.0f}% | Language: {language_name}",
key="summary-audio",
style=Style(font_size=13.0),
),
]
return Card(key="summary-card", children=lines)
Step 9 — Assembling everything in view¶
The view function is tempestweb's entry point. It calls each section builder and organises them in a Scaffold with an AppBar:
def view(app: App[SettingsState]) -> Widget:
"""Render the full settings panel from the current state.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The full widget tree for the current state.
"""
return Scaffold(
key="settings-scaffold",
app_bar=AppBar(title="Settings", key="settings-appbar"),
body=Column(
key="settings-body",
style=Style(gap=16.0, padding=Edge.all(16.0)),
children=[
_notifications_card(app),
_appearance_card(app),
_audio_card(app),
_language_card(app),
_summary_card(app.state),
],
),
)
Tip — _summary_card(app.state) vs. _summary_card(app)
Passing app.state (instead of the full app) to the summary card communicates clearly that it is read-only. Any reader instantly knows that function registers no handlers. It is a design convention, not a technical constraint.
The complete app ✅¶
Here is the complete examples/settings-panel/app.py, ready to copy:
"""Settings panel — demonstrates selection controls bound to a settings dataclass.
Every control (Switch, Checkbox, Slider, RadioGroup, SegmentedControl) is wired
to a dedicated field in :class:`SettingsState`. Any change immediately re-renders
a live summary card at the bottom that reflects the current state — so the demo
makes the two-way binding visible.
Run unchanged in both modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
"""
from __future__ import annotations
from dataclasses import dataclass
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import (
AppBar,
Card,
Divider,
RadioGroup,
Scaffold,
SegmentedControl,
)
from tempestweb._core.style import AlignItems, Edge, FontWeight
from tempestweb._core.widgets import (
Checkbox,
Column,
Row,
Slider,
Switch,
Text,
)
from tempestweb._core.widgets.events import SlideEvent, ToggleEvent
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_THEME_OPTIONS: list[str] = ["System", "Light", "Dark"]
_LANGUAGE_OPTIONS: list[str] = ["English", "Português", "Español", "Français"]
_QUALITY_OPTIONS: list[str] = ["Low", "Medium", "High", "Ultra"]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@dataclass
class SettingsState:
"""All mutable settings controlled by the panel.
Attributes:
notifications_enabled: Master switch for push notifications.
email_alerts: Whether to send e-mail alerts on events.
sound_enabled: Whether in-app sounds are active.
auto_save: Whether drafts are saved automatically.
theme_index: Index into ``_THEME_OPTIONS`` (0=System, 1=Light, 2=Dark).
language_index: Index into ``_LANGUAGE_OPTIONS``.
volume: Playback volume in ``[0, 100]``.
font_size: Preferred font size in ``[10, 30]`` logical points.
quality_index: Index into ``_QUALITY_OPTIONS`` (stream/render quality).
"""
notifications_enabled: bool = True
email_alerts: bool = False
sound_enabled: bool = True
auto_save: bool = True
theme_index: int = 0
language_index: int = 0
volume: float = 70.0
font_size: float = 16.0
quality_index: int = 2
def make_state() -> SettingsState:
"""Build the initial settings state.
Returns:
A fresh :class:`SettingsState` with sensible defaults.
"""
return SettingsState()
# ---------------------------------------------------------------------------
# Section builders
# ---------------------------------------------------------------------------
def _notifications_card(app: App[SettingsState]) -> Widget:
"""Render the Notifications section with Switch and Checkbox controls.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the notification preference controls.
"""
state: SettingsState = app.state
def on_notifications_toggle(event: ToggleEvent) -> None:
"""Toggle master notification switch."""
app.set_state(lambda s: setattr(s, "notifications_enabled", event.checked))
def on_email_toggle(event: ToggleEvent) -> None:
"""Toggle e-mail alert preference."""
app.set_state(lambda s: setattr(s, "email_alerts", event.checked))
def on_sound_toggle(event: ToggleEvent) -> None:
"""Toggle in-app sound preference."""
app.set_state(lambda s: setattr(s, "sound_enabled", event.checked))
return Card(
key="notifications-card",
children=[
Text(
content="Notifications",
key="notif-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="notif-divider"),
Row(
key="notif-master-row",
style=Style(gap=12.0, align=AlignItems.CENTER),
children=[
Text(
content="Enable notifications",
key="notif-master-label",
style=Style(font_size=14.0, grow=1.0),
),
Switch(
checked=state.notifications_enabled,
on_change=on_notifications_toggle,
key="notif-switch",
),
],
),
Checkbox(
label="Send e-mail alerts",
checked=state.email_alerts,
on_change=on_email_toggle,
key="email-checkbox",
),
Checkbox(
label="Play sounds",
checked=state.sound_enabled,
on_change=on_sound_toggle,
key="sound-checkbox",
),
],
)
def _appearance_card(app: App[SettingsState]) -> Widget:
"""Render the Appearance section with SegmentedControl and Slider controls.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the theme, font-size and quality controls.
"""
state: SettingsState = app.state
def on_theme_select(index: int) -> None:
"""Select a colour theme."""
app.set_state(lambda s: setattr(s, "theme_index", index))
def on_quality_select(index: int) -> None:
"""Select the render/stream quality level."""
app.set_state(lambda s: setattr(s, "quality_index", index))
def on_font_size_change(event: SlideEvent) -> None:
"""Adjust the preferred font size."""
app.set_state(lambda s: setattr(s, "font_size", round(event.value, 1)))
return Card(
key="appearance-card",
children=[
Text(
content="Appearance",
key="appearance-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="appearance-divider"),
Text(
content="Theme",
key="theme-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
SegmentedControl(
options=_THEME_OPTIONS,
selected=state.theme_index,
on_select=on_theme_select,
key="theme-segments",
),
Text(
content=f"Font size: {state.font_size:.0f} pt",
key="font-size-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
Slider(
value=state.font_size,
min_value=10.0,
max_value=30.0,
step=1.0,
on_change=on_font_size_change,
key="font-slider",
),
Text(
content="Render quality",
key="quality-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
SegmentedControl(
options=_QUALITY_OPTIONS,
selected=state.quality_index,
on_select=on_quality_select,
key="quality-segments",
),
],
)
def _audio_card(app: App[SettingsState]) -> Widget:
"""Render the Audio section with a volume Slider and auto-save Switch.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the audio and save controls.
"""
state: SettingsState = app.state
def on_volume_change(event: SlideEvent) -> None:
"""Adjust playback volume."""
app.set_state(lambda s: setattr(s, "volume", round(event.value)))
def on_auto_save_toggle(event: ToggleEvent) -> None:
"""Toggle auto-save preference."""
app.set_state(lambda s: setattr(s, "auto_save", event.checked))
return Card(
key="audio-card",
children=[
Text(
content="Audio & Storage",
key="audio-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="audio-divider"),
Text(
content=f"Volume: {state.volume:.0f}%",
key="volume-label",
style=Style(font_size=13.0, font_weight=FontWeight.BOLD),
),
Slider(
value=state.volume,
min_value=0.0,
max_value=100.0,
step=1.0,
on_change=on_volume_change,
key="volume-slider",
),
Row(
key="auto-save-row",
style=Style(gap=12.0, align=AlignItems.CENTER),
children=[
Text(
content="Auto-save drafts",
key="auto-save-label",
style=Style(font_size=14.0, grow=1.0),
),
Switch(
checked=state.auto_save,
on_change=on_auto_save_toggle,
key="auto-save-switch",
),
],
),
],
)
def _language_card(app: App[SettingsState]) -> Widget:
"""Render the Language section with a RadioGroup control.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
A ``Card`` containing the language radio group.
"""
state: SettingsState = app.state
def on_language_select(index: int) -> None:
"""Select the preferred interface language."""
app.set_state(lambda s: setattr(s, "language_index", index))
return Card(
key="language-card",
children=[
Text(
content="Language",
key="language-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="language-divider"),
RadioGroup(
options=_LANGUAGE_OPTIONS,
selected=state.language_index,
on_select=on_language_select,
key="language-radio",
),
],
)
def _summary_card(state: SettingsState) -> Widget:
"""Render a live summary of all current settings.
Args:
state: The current snapshot of :class:`SettingsState`.
Returns:
A ``Card`` listing all current setting values.
"""
theme_name: str = _THEME_OPTIONS[state.theme_index]
language_name: str = _LANGUAGE_OPTIONS[state.language_index]
quality_name: str = _QUALITY_OPTIONS[state.quality_index]
notif_text: str = "on" if state.notifications_enabled else "off"
email_text: str = "yes" if state.email_alerts else "no"
sound_text: str = "on" if state.sound_enabled else "off"
save_text: str = "on" if state.auto_save else "off"
lines: list[Widget] = [
Text(
content="Live summary",
key="summary-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="summary-divider"),
Text(
content=f"Notifications: {notif_text} | E-mail alerts: {email_text}",
key="summary-notif",
style=Style(font_size=13.0),
),
Text(
content=f"Sound: {sound_text} | Auto-save: {save_text}",
key="summary-sound",
style=Style(font_size=13.0),
),
Text(
content=(
f"Theme: {theme_name} | Font: {state.font_size:.0f} pt"
f" | Quality: {quality_name}"
),
key="summary-appearance",
style=Style(font_size=13.0),
),
Text(
content=f"Volume: {state.volume:.0f}% | Language: {language_name}",
key="summary-audio",
style=Style(font_size=13.0),
),
]
return Card(key="summary-card", children=lines)
# ---------------------------------------------------------------------------
# view
# ---------------------------------------------------------------------------
def view(app: App[SettingsState]) -> Widget:
"""Render the full settings panel from the current state.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The full widget tree for the current state.
"""
return Scaffold(
key="settings-scaffold",
app_bar=AppBar(title="Settings", key="settings-appbar"),
body=Column(
key="settings-body",
style=Style(gap=16.0, padding=Edge.all(16.0)),
children=[
_notifications_card(app),
_appearance_card(app),
_audio_card(app),
_language_card(app),
_summary_card(app.state),
],
),
)
Running the example ▶¶
Mode A — Python in the browser (Pyodide / WASM)¶
Python runs inside the browser via Pyodide. No server required — open the URL printed in the terminal.
Mode B — Python on the server (FastAPI + WebSocket)¶
Python runs on the server; the browser receives JSON patches over WebSocket and applies them to the DOM.
Verification
In either mode, confirm that:
AppBarshows the title Settings at the top- Four cards appear: Notifications, Appearance, Audio & Storage, Language
- Toggling the master notifications
Switchupdates thenotificationsfield in the summary card - Moving the volume slider changes the "Volume: XX%" label above it and the corresponding field in the summary
- Clicking a segment on the theme
SegmentedControlchanges theThemefield in the summary - Selecting a language in the
RadioGroupchanges theLanguagefield in the summary
Automated verification ✅¶
# Lint
ruff check .
# Format
ruff format --check .
# Types
mypy --strict tempestweb
# Tests
pytest -q
All four must pass green. The example is written to be mypy --strict clean — every variable, parameter, and return value is annotated explicitly.
How it works under the hood¶
The update cycle¶
User interacts with a control
│
▼
handler (e.g. on_volume_change)
│
▼
app.set_state(lambda mutator)
│
▼
tempestweb applies the mutator → new state
│
▼
view(app) called again → new widget tree
│
▼
reconciler computes diff (minimal patches)
│
▼
DOM updated — only what changed
Why split into section builders?¶
view could build everything inline, but it would be over 200 lines. Splitting into _notifications_card, _appearance_card, and so on brings two benefits:
- Readability: each function fits on one screen — immediate purpose, no scrolling.
- Testability: each builder takes
App[SettingsState]and returnsWidget— you can test them in isolation by injecting anappwith a fixed state.
State as index, not as string¶
Storing theme_index: int instead of theme: str has an important consequence: the same serialized state works with option lists in any language. If you want to localize theme labels, just swap _THEME_OPTIONS — the state itself does not change.
Recap¶
In this tutorial you learned:
- ✅ Model multiple control types (bool, int, float) in a single typed dataclass
- ✅ Use
SwitchandCheckboxwithToggleEvent.checked - ✅ Use
SliderwithSlideEvent.valueand explicit rounding - ✅ Use
SegmentedControlandRadioGroupwith an integer index as state - ✅ Organise the UI into independent, testable section builders
- ✅ Build a live summary card as proof of two-way binding
- ✅ Use
Scaffold+AppBaras the standard page structure
Next steps¶
- 💡 Explore Tabs Profile to see
SwitchandCheckboxinside a tabbed panel - 💡 See Stopwatch to learn temporal state management with
asyncio - 💡 Read Managing state for a complete treatment of the
set_statecycle - 💡 Add persistence: serialize
SettingsStatetolocalStoragein Mode A viapyodide.ffi, or to a REST endpoint in Mode B