Authoring 2: Build the widget
Now make example.counter real. A widget is a class registered with api.widget, carrying its config as fields and rendering with a draw() method.
Register a widget class
Section titled “Register a widget class”api.widget(name) is a decorator. Pair it with @attrs.define so led-ticker can inspect your config fields:
import attrsfrom led_ticker.plugin import Color, draw_text, make_color, resolve_font
def register(api): @api.widget("counter") @attrs.define class Counter: since: str label: str = "DAY" color: Color | None = None bg_color: Color | None = Noneattrs is a small library for declarative classes; it ships with led-ticker (no separate install), and @attrs.define is what lets the loader read your field names and defaults to build the widget from TOML.
The class lives inside register() so the api object is in scope when the @api.widget decorator runs — treat the def register(api): wrapper as boilerplate every plugin has.
Each field is a config key. In TOML:
[[playlist.section.widget]]type = "example.counter"since = "2024-01-01"label = "DAY"color = [130, 220, 255]bg_color = [10, 10, 40]That [[playlist.section.widget]] block goes inside a playlist section in your config.toml — see Your first config for the surrounding structure.
Implement draw()
Section titled “Implement draw()”draw() paints one frame and returns (canvas, end_x) — the canvas and the x-coordinate where your content ends (so the engine can chain widgets). A canvas is the display surface the engine hands your widget for this frame — you paint onto it (here via draw_text) and return it unchanged.
def draw(self, canvas, cursor_pos=0, *, y_offset=0, font_color=None): if self.bg_color is not None: canvas.Fill(self.bg_color.red, self.bg_color.green, self.bg_color.blue) font = resolve_font("6x12") color = self.color if self.color is not None else make_color(255, 255, 255) text = f"{self.label} {self._days()}" end_x = draw_text(canvas, font, text, cursor_pos, 10 + y_offset, color) return canvas, end_xThe helpers all come from led_ticker.plugin:
resolve_font("6x12")— a bundled BDF font (or a hi-res name + size).make_color(r, g, b)— aColorwithout importing the matrix library.draw_text(canvas, font, text, x, y, color)— draws text (with inline emoji support) and returns the end-x.
To paint a background, call the canvas’s own canvas.Fill(r, g, b) before your text. bg_color is a raw Color, so self.bg_color.red / .green / .blue give the channels (that’s the bg_color = [10, 10, 40] in the TOML above). Leave bg_color unset and the widget just draws over whatever’s already on the canvas.
Rich text: per-character animated colors
Section titled “Rich text: per-character animated colors”draw_text already renders inline :emoji:, but it takes only a flat Color. When your widget should give text per-character animated colors (rainbow, gradient, shimmer) — as data widgets like the pool monitor do — reach for draw_with_emoji, which accepts a ColorProvider:
from led_ticker.plugin import ( as_color_provider, count_text_chars, draw_with_emoji, make_color,)
# Your widget receives font_color as a ColorProvider (or None).# as_color_provider wraps a fallback Color so you always have a provider:provider = font_color if font_color is not None else as_color_provider(make_color(255, 255, 255))
text = f"TEMP {value}°"total = count_text_chars(text) # counts `:slug:` tokens as one char eachend_x = draw_with_emoji(canvas, font, cursor_pos, y, provider, text, frame=self.frame_for("font_color"), total_chars=total)Reach for draw_with_emoji over draw_text purely when the color is a ColorProvider rather than a flat Color — both render inline emoji identically. count_text_chars measures the total — pair it with the provider’s total_chars argument so a rainbow sweeps correctly across any inline emoji in the string.
For named color constants (RED, DEFAULT_COLOR, RGB_WHITE, …) use led_ticker.plugin.colors:
from led_ticker.plugin import colors
color = colors.REDFull table: API reference → Helpers.
The _days() helper is plain Python:
import datetime as _dt# ... def _days(self): return (_dt.date.today() - _dt.date.fromisoformat(self.since)).daysValidate config
Section titled “Validate config”Add a validate_config classmethod to reject bad config before the sign runs (it shows up in led-ticker validate). It receives the raw, pre-coercion config and returns a list of error strings:
@classmethod def validate_config(cls, cfg): errors = [] since = cfg.get("since") if since is None: errors.append("since is required (a YYYY-MM-DD date)") else: try: start = _dt.date.fromisoformat(str(since)) except ValueError: errors.append(f"since must be a YYYY-MM-DD date; got {since!r}") else: if start > _dt.date.today(): errors.append(f"since must not be in the future; got {since!r}") return errorsThe complete widget
Section titled “The complete widget”Assembled, that’s the whole plugin — drop it in config/plugins/example/__init__.py (or package it, next page):
"""Minimal example led-ticker plugin — the worked example for the authoring guide.
Drop `example/` into your `config/plugins/` (local use), or package it with an`[project.entry-points."led_ticker.plugins"] example = "example:register"`entry (packaged use), then reference it in TOML as `type = "example.counter"`.
Imports only `led_ticker.plugin` (the public surface) plus `attrs` and stdlib —never a private `led_ticker.*` module."""
import datetime as _dt
import attrs
from led_ticker.plugin import Color, draw_text, make_color, resolve_font
def register(api): @api.widget("counter") @attrs.define class Counter: """Shows whole days since a configured date, e.g. ``DAY 42``."""
# Config fields. The loader builds the widget from your TOML, passing # declared keys as constructor kwargs; `@attrs.define` lets it inspect them. since: str # required — a YYYY-MM-DD date (validate_config enforces it) label: str = "DAY" # `color` is a known color key: the loader coerces an [r, g, b] list in # TOML into a Color before your widget sees it (None = default white). color: Color | None = None # `bg_color` is also a raw color key. Paint it behind the text with the # canvas's own Fill() (None = leave the background untouched). bg_color: Color | None = None
@classmethod def validate_config(cls, cfg): """Pre-coercion config check; return a list of human-readable errors.""" errors = [] since = cfg.get("since") if since is None: errors.append("since is required (a YYYY-MM-DD date)") else: try: start = _dt.date.fromisoformat(str(since)) except ValueError: errors.append(f"since must be a YYYY-MM-DD date; got {since!r}") else: if start > _dt.date.today(): errors.append(f"since must not be in the future; got {since!r}") return errors
def _days(self): return (_dt.date.today() - _dt.date.fromisoformat(self.since)).days
def draw(self, canvas, cursor_pos=0, *, y_offset=0, font_color=None): """Render `<label> <N>` onto the canvas; return (canvas, end_x).""" if self.bg_color is not None: canvas.Fill(self.bg_color.red, self.bg_color.green, self.bg_color.blue) font = resolve_font("6x12") color = self.color if self.color is not None else make_color(255, 255, 255) text = f"{self.label} {self._days()}" end_x = draw_text(canvas, font, text, cursor_pos, 10 + y_offset, color) return canvas, end_xSee it run
Section titled “See it run”You don’t need hardware to check your work — the test in the next chapter drives draw() directly, and once the plugin is packaged you can render a preview GIF (next page). That preview is how the DAY 2346 panel at the top of the guide was made.