Building a Widget¶
Tesserae Engine has no buttons, switches, or sliders. It has the blocks they're built
from, and this page builds one — an on/off switch — to show how the blocks
fit: layout and paint for how it looks, listeners for what it does,
animation for how it moves, and accessibility for what a screen reader says.
The finished widget is examples/switch.py, which also checks every
behavior below headlessly.
What it has to do¶
- Toggle on a click, and on Space or Enter while it's focused.
- Ease its thumb across and its track to the "on" color.
- Grow its thumb a little on hover.
- Show a focus ring when it was reached by keyboard, but not after a click.
- Tell assistive technology it's a switch, what it's called, and whether it's on — and toggle when asked to.
The structure¶
Two boxes: a rounded track, and a round thumb inside it. The track is the widget — it's the node that takes focus and carries the accessibility role — and it lays the thumb out with its own padding:
TRACK_OFF = (0xE6, 0xE0, 0xE9, 0xFF)
TRACK_ON = (0x67, 0x50, 0xA4, 0xFF)
THUMB = (0xFF, 0xFF, 0xFF, 0xFF)
TRAVEL = 20.0 # track width 52 - padding 2*4 - thumb 24
class Switch:
def __init__(self, window, label, checked=False, on_change=None):
self.checked = checked
self.on_change = on_change
self.node = window.create(
"box", width=52, height=32, corner_radius=16, padding=4,
align_items="center", fill=TRACK_OFF,
shadows=[(NO_RING, 0, 0, 0, 0)],
role="switch", label=label, checked=checked,
focusable=True, cursor="pointer",
)
self.thumb = window.create(
"box", width=24, height=24, corner_radius=12, fill=THUMB,
shadows=[((0, 0, 0, 60), 0, 1, 2, 0)],
)
self.node.add_child(self.thumb)
role="switch", label, and checked are all a screen reader needs, and
because the role is button-like, Tesserae Engine offers assistive technology an
activate action for it — see Accessibility.
focusable=True puts it in the Tab order.
Toggling¶
Everything that should toggle the switch arrives as one event. A click on
the thumb bubbles up to the track; Space and Enter on a focused node fire
click; and an assistive technology's activate request does too. So one
listener covers all of them:
self.node.on("click", self.toggle)
def toggle(self):
self.checked = not self.checked
self.node.set(checked=self.checked)
self.paint(duration_ms=200)
if self.on_change:
self.on_change(self.checked)
Setting checked keeps the accessibility state in step with what's on
screen.
Moving¶
The thumb slides with translate_x rather than a layout change: transforms
animate, and they move what's painted without relaying out anything around
it. The track's fill eases at the same time, on the same curve:
EASE = (0.2, 0.0, 0.0, 1.0)
def paint(self, duration_ms):
self.thumb.animate("translate_x", TRAVEL if self.checked else 0.0,
duration_ms, easing=EASE)
self.node.animate("fill", TRACK_ON if self.checked else TRACK_OFF,
duration_ms, easing=EASE)
The constructor calls self.paint(duration_ms=0), so a switch created
checked=True starts in place. Because animate starts from the current
value, toggling twice quickly reverses the thumb mid-slide rather than
jumping.
Hover¶
pointer_enter and pointer_leave cover the whole switch — they don't fire
as the pointer crosses from the track onto the thumb — so a hover effect
needs just the two:
self.node.on("pointer_enter", lambda: self.thumb.animate("scale", 1.1, 100))
self.node.on("pointer_leave", lambda: self.thumb.animate("scale", 1.0, 100))
The focus ring¶
A ring is a shadow with no blur and a 3px spread — it sits outside the
track, so it never shifts layout. focus_visible says whether focus came
from the keyboard, so a mouse user never sees the ring:
RING = (0x67, 0x50, 0xA4, 0x80)
NO_RING = (0, 0, 0, 0)
self.node.on("focus", self.show_focus)
self.node.on("unfocus", lambda: self.node.animate(
"shadows", [(NO_RING, 0, 0, 0, 0)], 100))
def show_focus(self, event):
if event.focus_visible:
self.node.animate("shadows", [(RING, 0, 0, 0, 3)], 100)
The track starts with one transparent shadow of the same shape, so the ring fades in rather than appearing.
Using it¶
window = Window(width=320, height=120)
window.root.set(align_items="center", gap=12)
wifi = Switch(window, "Wi-Fi", on_change=lambda on: print("Wi-Fi", on))
window.root.add_child(window.create("text", text="Wi-Fi", width=60, height=20))
window.root.add_child(wifi.node)
Testing it¶
window.simulate drives the real input pipeline and window.advance moves
time, so the whole widget is testable without a display:
window.advance(0) # pin the clock
window.simulate("click", node=wifi.thumb) # bubbles to the track
window.advance(200)
assert wifi.node.get("checked") is True
assert wifi.thumb.get("translate_x") == 20.0
window.simulate("key_down", key="space") # the track is focused now
window.advance(200)
assert wifi.node.get("checked") is False
Going further¶
The same pieces build the rest of a widget set:
- A slider captures the pointer on
pointer_downso a drag keeps tracking off the track, steps onarrow_left/arrow_rightinkey_down, and answersincrement,decrement, andset_valueina11y_action—examples/slider.pyis one. Akey_downlistener keeps every key for itself: while the slider, or anything inside a node with such a listener, is focused, the arrows, Page Up/Down, Home, and End no longer scroll the scroll view around it, even keys the listener ignores. The switch above takes Space and Enter asclickinstead, so those keys still scroll the page around it. See Keyboard scrolling. - A button's ripple is a round box inside a
clip_childrenbutton, scaled up and faded out from the press point, destroying itself from itson_complete—examples/ripple.py. - A menu or dialog is a layer.
- A text field is a
text_inputinside a box that paints the field's background, border, and label — see Text.