Writing a transition
This is a how-to for plugin authors: write a custom transition — the short animation played between two widgets. You’ll build a wipe that sweeps the old frame away behind a colored line, then reveals the new one.
What you’ll need:
- A scaffolded plugin — see the authoring guide; you only import
led_ticker.plugin. - No hardware needed: you’ll preview with
make render-demo(run from the repo aftermake dev). - Your plugin installed (
pip install -e .from its directory) —make render-demoonly picks up installed plugins.

The frame_at contract
Section titled “The frame_at contract”A transition is a class with one method, frame_at, called once per frame while the transition plays:
def frame_at(self, t, canvas, outgoing, incoming, **kwargs): ...truns from 0.0 to 1.0 — the transition’s progress. Att=0you’d show onlyoutgoing; att=1.0, onlyincoming.canvasis what you draw on (the pixel grid for this frame). The engine clears it for you before each call — don’t clear it yourself.outgoingandincomingare the two frames. You don’t read their pixels — you ask each to paint itself withframe.draw(canvas, cursor_pos=N), wherecursor_posis a horizontal pixel offset (0= in place).- The return value is ignored — the engine renders whatever you drew onto
canvas. Returningcanvasis a harmless convention.
The drawing tools
Section titled “The drawing tools”You composite each frame with three canvas calls:
canvas.width/canvas.height— the panel size in pixels (usegetattr(canvas, "height", 16)to be safe).canvas.SubFill(x, y, w, h, r, g, b)— fill a rectangle with a color. This is how you “clip”: a frame drawn withdraw()can’t be cropped, so you draw it in full and then black out the part you don’t want withSubFill(…, 0, 0, 0).canvas.SetPixel(x, y, r, g, b)— set a single pixel (here, the sweep line).
Build the wipe
Section titled “Build the wipe”Draw the outgoing frame, black out everything the sweep has already crossed, draw the colored sweep line at the moving edge, and at the very end snap to the incoming frame:
def frame_at(self, t, canvas, outgoing, incoming, **kwargs): w = canvas.width h = getattr(canvas, "height", 16)
if t >= 1.0: incoming.draw(canvas, cursor_pos=0) return canvas
edge = int(t * w) # the sweep edge moves left -> right, 0 .. w
outgoing.draw(canvas, cursor_pos=0) # 1. the old frame fills the canvas if edge > 0: # 2. black out everything the sweep has passed canvas.SubFill(0, 0, edge, h, 0, 0, 0) for dx in range(2): # 3. a 2px colored sweep line at the edge x = edge + dx if 0 <= x < w: for y in range(h): canvas.SetPixel(x, y, self.color[0], self.color[1], self.color[2]) return canvasTwo finishing touches, both wired into the class in the complete listing below: set min_frames — a floor on the frame count, so the sweep stays smooth even when the configured duration is short — and accept a color field so the sweep line is configurable from TOML.
Register and use it
Section titled “Register and use it”-
Register the class in your plugin’s
register(api):@api.transition("wipe")class Wipe: ...It’s namespaced — in the
example_transitionplugin it becomesexample_transition.wipe. -
Use it on a section in your
config/config.toml(optionally with acolor):[[playlist.section]]transition = { type = "example_transition.wipe", color = [255, 0, 0] } -
Install your plugin so led-ticker can find it.
make render-demoloads only installed plugins (see Package & install), then preview — no hardware needed:Terminal window pip install -e . # run from your plugin's directorymake render-demo CONFIG=config/config.toml OUT=preview.gifopen preview.gif # macOS; xdg-open on Linux
Complete listing
Section titled “Complete listing”The full plugin — examples/plugins/example_transition/__init__.py:
"""Example led-ticker plugin: a custom 'wipe' transition (the 'Writing a transition' how-to).
Drop `example_transition/` into your `config/plugins/` (local use), or package itwith an `[project.entry-points."led_ticker.plugins"] example_transition = "example_transition:register"`entry, then use it in TOML as `transition = {type = "example_transition.wipe"}`.
Imports only `led_ticker.plugin` (the public surface) plus stdlib."""
def register(api): @api.transition("wipe") class Wipe: # Enough frames for a smooth sweep regardless of the configured duration. min_frames = 16
# A config-driven field: `transition = {type = "example_transition.wipe", # color = [255, 0, 0]}` passes `color` here. Default: cyan. def __init__(self, color=(0, 255, 255)): self.color = color
def frame_at(self, t, canvas, outgoing, incoming, **kwargs): # The engine clears the canvas before each call — don't clear it here. # Draw onto `canvas`; the return value is ignored (returning canvas # is just a convention). w = canvas.width h = getattr(canvas, "height", 16)
if t >= 1.0: incoming.draw(canvas, cursor_pos=0) return canvas
edge = int(t * w) # the sweep edge moves left -> right, 0 .. w
outgoing.draw(canvas, cursor_pos=0) # 1. the old frame fills the canvas if edge > 0: # 2. black out everything the sweep has passed canvas.SubFill(0, 0, edge, h, 0, 0, 0) for dx in range(2): # 3. a 2px colored sweep line at the edge x = edge + dx if 0 <= x < w: for y in range(h): canvas.SetPixel(x, y, self.color[0], self.color[1], self.color[2]) return canvasGoing hi-res (bigsign)
Section titled “Going hi-res (bigsign)”The wipe above paints in logical pixels and works everywhere. On the bigsign (default_scale > 1) the canvas your frame_at receives is a scaled wrapper — every SetPixel it forwards expands to a scale × scale block. That’s perfect for text and lo-res sprites, but a hi-res transition (a sprite drawn at the panel’s native resolution) needs to reach past the wrapper and paint individual physical LEDs. The shipped baseball package does exactly this; here’s the pattern.
Three public symbols from led_ticker.plugin do the work:
from led_ticker.plugin import is_scaled, unwrap_to_real, snap_reset, SNAP_THRESHOLDis_scaled(canvas)—Trueonly on the scaled (bigsign) path. Gate your hi-res branch with this, notisinstance(canvas, ScaledCanvas)—is_scaledis the supported predicate that replaces theisinstancecheck. Atscale = 1it returnsFalse, so the sameframe_atfalls through to your lo-res code automatically.unwrap_to_real(canvas)— peels the wrapper and returns the underlying real canvas. Call its.SetPixel(x, y, r, g, b)to light one physical LED at native resolution (no block expansion). For the panel size, readreal.width/real.heightoff the unwrapped canvas. (If you’d rather not juggle the scale yourself,paint_hires(canvas, callback)hands your callback the real canvas plus itsscaleandy_offset_real.)snap_reset(canvas, incoming_bg_color)+SNAP_THRESHOLD— near the end of the transition (t >= SNAP_THRESHOLD, ≈0.95), reset and draw the incoming widget.snap_resetFills the section’s background color (orClears when it’sNone) so a bg-colored section doesn’t flash “incoming on black” for one tick on bordered widgets. Pass the bg through fromkwargs:snap_reset(canvas, kwargs.get("incoming_bg_color")).
Put together, a hi-res transition branches in frame_at, paints physical pixels in its hi-res helper, and snaps at the threshold:
from led_ticker.plugin import is_scaled, unwrap_to_real, snap_reset, SNAP_THRESHOLD
class Ball: min_frames = 40
def frame_at(self, t, canvas, outgoing, incoming, **kwargs): if t >= 1.0: incoming.draw(canvas, cursor_pos=0) return canvas # The wrapper only appears on the bigsign — lo-res panels fall through. if is_scaled(canvas): return self._frame_at_hires(t, canvas, outgoing, incoming, **kwargs) return self._frame_at_lowres(t, canvas, outgoing, incoming, **kwargs)
def _frame_at_hires(self, t, canvas, outgoing, incoming, **kwargs): real = unwrap_to_real(canvas) # the real canvas, native pixels panel_w, panel_h = real.width, real.height
outgoing.draw(canvas) # outgoing still draws through the wrapper
# ... compute the sprite/trail position from t, then light physical LEDs: x = int(t * panel_w) for y in range(panel_h): real.SetPixel(x, y, 255, 255, 255) # one native pixel — no block expansion
if t >= SNAP_THRESHOLD: # ≈0.95 — wrap it up snap_reset(canvas, kwargs.get("incoming_bg_color")) incoming.draw(canvas) return canvasThe lo-res helper (_frame_at_lowres) is just your scale-1 logic from earlier — same wipe, drawn through the wrapper.
For the exact signatures of is_scaled, unwrap_to_real, paint_hires, snap_reset, and SNAP_THRESHOLD, see the API reference. The baseball package is a real, shipped example of this pattern.
Hi-res sprite transitions
Section titled “Hi-res sprite transitions”If your transition is an animated sprite (a gif or webp that crosses the panel), HiresSpec + render_hires_frame handle the sprite decode, scaling, trail, and snap for you — you only need to provide the file and set trail.
Bundle the sprite inside your plugin package and build a HiresSpec at module load:
from pathlib import Pathfrom led_ticker.plugin import HiresSpec, is_scaled, render_hires_frame
_HERE = Path(__file__).parent_SPEC = HiresSpec( sprite_path=_HERE / "sprites" / "zoom.gif", # gif or webp, bundled with the plugin flip_horizontal=False, trail="black", # "none" | "black" | "rainbow")render_hires_frame decodes the sprite to the panel’s native height, drives the traversal from t, and calls snap_reset at SNAP_THRESHOLD automatically. In frame_at, gate on is_scaled(canvas) (the supported predicate, per above — only True on the bigsign) and forward **kwargs so incoming_bg_color and friends reach the renderer. Fall back to your own lo-res drawing on the small sign or in test stubs:
def register(api): @api.transition("zoom") class Zoom: def frame_at(self, t, canvas, outgoing, incoming, **kwargs): if t >= 1.0: incoming.draw(canvas, cursor_pos=0) return canvas if is_scaled(canvas): # bigsign -> hi-res sprite return render_hires_frame( t, canvas, outgoing, incoming, _SPEC, **kwargs ) # small sign / tests -> your own lo-res fallback return self._lowres(t, canvas, outgoing, incoming, **kwargs)trail fills the band behind the leading sprite to erase the outgoing widget’s text: "none" leaves the outgoing frame visible (good for transparent sprites), "black" paints the trail black, and "rainbow" sweeps a hue cycle (the nyancat style). For the full HiresSpec and render_hires_frame signatures see the API reference.
If it doesn’t work
Section titled “If it doesn’t work”- “Unknown transition” / the name isn’t found — check the namespaced
type(example_transition.wipe, notwipe) and that the plugin is installed/loaded (see Installing a plugin). - The transition flickers or is too quick to see — raise
min_frames, or the section’s transition duration. - The canvas looks smeared / wrong — don’t call
canvas.Clear()orcanvas.Fill()yourself; the engine clears before eachframe_at.