Build a Pomodoro plugin
The Pomodoro example that ships in mixlar-sdk/examples/pomodoro/ is not a toy scaffold — it’s a complete, working focus-timer plugin, short enough to read top to bottom in a few minutes, that touches every major surface of the SDK: macro actions, a bundled device widget, declarative settings, and live data pushes.
This guide walks through the example file by file. By the end you’ll understand how the pieces fit together and be able to run it yourself, with or without hardware.
What it demonstrates
Section titled “What it demonstrates”| Feature | Where |
|---|---|
Macro actions via MacroActions + @action |
pomodoro.py — Start/Pause/Reset, grouped "Timer" |
| A bundled device widget | widgets/pomodoro/widget.json — countdown arc, status label, Start/Pause + Reset buttons |
| Live data pushes | push_state(), called from on_widget_shown and a notional tick() |
| Interactive element handling | on_widget_event — routes the two buttons back to the same start/pause/reset logic the macros use |
| Declarative settings | settings.schema.json — work minutes, break minutes, auto-start |
| A valid manifest declaring the widget | plugin.json |
-
Start with the manifest
Section titled “Start with the manifest”Every plugin needs a
plugin.jsonthat declares its identity and which widgets it bundles:{"manifest": 1,"id": "pomodoro","name": "Pomodoro Timer","version": "1.0.0","author": "Mixlar Labs","description": "A focus timer with a device widget: countdown arc, Start/Pause, Reset.","entry": "pomodoro.py","icon": "fa5s.hourglass-half","icon_color": "#FF4D14","widgets": [{ "id": "pomodoro", "name": "Pomodoro Timer", "version": "1.0.0" }],"publisher": "","sig": ""}entrypoints at the Python module the host loads, and thewidgetsarray pairs the plugin with thewidgets/pomodoro/folder next to it.publisherandsigare filled in at packaging time — see Code-signing plugins. -
Declare macro actions
Section titled “Declare macro actions”Instead of hand-writing the usual on-press/on-release quartet for each button, the plugin mixes in
MacroActionsand decorates a method per action with@action:from mixlar import MixlarPluginfrom mixlar.macros import MacroActions, actionclass PomodoroPlugin(MacroActions, MixlarPlugin):plugin_id = "pomodoro"plugin_name = "Pomodoro Timer"plugin_version = "1.0.0"plugin_author = "Mixlar Labs"plugin_description = "A focus timer with a device widget."plugin_icon = "fa5s.hourglass-half"plugin_icon_color = "#FF4D14"# Pairs this plugin with widgets/pomodoro/widget.json — every# push_widget_data key below is auto-namespaced "pomodoro.<key>".widget_id = "pomodoro"@action("start", "Start", icon="fa5s.play", group="Timer")def _start(self, step) -> None:self.running = Trueself.push_state()@action("pause", "Pause", icon="fa5s.pause", group="Timer")def _pause(self, step) -> None:self.running = Falseself.push_state()@action("reset", "Reset", icon="fa5s.undo", group="Timer")def _reset(self, step) -> None:self.mode = "work"self.running = Falseself.remaining = self._work_seconds()self.push_state()Three actions —
start,pause,reset— all grouped under"Timer"so they show up together wherever macros are picked. Each one flips the plugin’s internal state and immediately callspush_state()so the widget reflects the change without waiting for a tick. For the full macro API (grouping, icons, step context), see Macros. -
Load initial state
Section titled “Load initial state”on_loadsets up the timer’s in-memory state the moment the plugin is loaded — no macro or widget interaction has happened yet:def on_load(self, settings) -> None:super().on_load(settings)self.mode = "work" # "work" | "break"self.running = Falseself.sessions_done = 0self.remaining = self._work_seconds()_work_seconds()and_break_seconds()read from settings (covered next), falling back toWORK_DEFAULT_MIN = 25andBREAK_DEFAULT_MIN = 5if nothing has been configured yet. -
Wire up settings
Section titled “Wire up settings”settings.schema.jsondeclares three fields — the host renders a settings UI from this schema automatically:{"schema": 1,"fields": [{"key": "work_minutes","type": "number","label": "Work minutes","default": 25,"min": 1,"max": 90,"step": 1,"help": "Length of a focus session."},{"key": "break_minutes","type": "number","label": "Break minutes","default": 5,"min": 1,"max": 30,"step": 1,"help": "Length of the rest period after each work session."},{"key": "auto_start","type": "bool","label": "Auto-start next session","default": false,"help": "Automatically begin the next work/break period instead of waiting for a tap."}]}The plugin resolves those keys through the ordinary
_pget/_psethelpers, with the schema defaults mirrored in code as a fallback:def _work_seconds(self) -> int:return int(float(self._pget("work_minutes", WORK_DEFAULT_MIN))) * 60def _break_seconds(self) -> int:return int(float(self._pget("break_minutes", BREAK_DEFAULT_MIN))) * 60def _auto_start(self) -> bool:return bool(self._pget("auto_start", False))See Settings for the full set of field types and the
_pget/_psetcontract. -
Build the widget
Section titled “Build the widget”widgets/pomodoro/widget.jsonlays out a countdown arc, a remaining-time label, a status label, and two buttons:{"mixw": 1,"name": "Pomodoro Timer","version": "1.0.0","bg": "16171B","elements": [{ "t": "arc", "x": 14, "y": 14, "d": 100, "w": 10, "c": "FF4D14", "bgc": "2C2C2E","bind": "pomodoro.pct", "min": 0, "max": 100, "fmt": "{v}%", "ls": 16, "lc": "FFFFFF" },{ "t": "label", "x": 134, "y": 16, "w": 96, "s": 26, "a": "c", "c": "FFFFFF","bind": "pomodoro.remaining" },{ "t": "label", "x": 14, "y": 118, "w": 100, "s": 12, "a": "c", "c": "98989E","bind": "pomodoro.status" },{ "t": "btn", "id": "start_pause", "x": 134, "y": 54, "w": 96, "h": 30, "r": 8, "s": 13,"c": "3A3A3C", "tc": "FFFFFF", "bind": "pomodoro.start_pause", "fmt": "{v}" },{ "t": "btn", "id": "reset", "x": 134, "y": 92, "w": 96, "h": 30, "r": 8, "s": 13,"c": "2C2C2E", "tc": "FFFFFF", "text": "Reset" }]}Each element that shows live data uses
bindto subscribe to apush_widget_datakey —pomodoro.pct,pomodoro.remaining,pomodoro.status,pomodoro.start_pause. Theresetbutton has nobind; it’s static text and only exists to fire a press event. For the element schema (t,x/y,bind,fmt, and friends) see Elements and the full Widget spec. -
Push live state
Section titled “Push live state”push_state()is the single place that computes derived values and pushes all four bound keys at once:def push_state(self) -> None:total = self._work_seconds() if self.mode == "work" else self._break_seconds()pct = 0 if total <= 0 else max(0, min(100, round(100 * self.remaining / total)))label = "Work" if self.mode == "work" else "Break"status = f"{label} — {'Running' if self.running else 'Paused'}"self.push_widget_data("pct", pct)self.push_widget_data("remaining", _mmss(self.remaining))self.push_widget_data("status", status)self.push_widget_data("start_pause", "Pause" if self.running else "Start")It’s called from two places:
on_widget_shown— the moment the widget comes on screen, so the device shows correct state immediately instead of waiting for the next tick.tick()— see the next step.
def on_widget_shown(self, widget_id: str) -> None:"""The widget came on screen — push full state immediately."""self.push_state()on_widget_hiddenis deliberately a no-op here — the timer keeps running in the background when you look away, because a Pomodoro that pauses itself isn’t very useful. -
Handle button presses
Section titled “Handle button presses”on_widget_eventroutes the widget’s two buttons back through the exact same start/pause/reset logic the macros use, so triggering from the device and triggering from a macro stay in sync:def on_widget_event(self, widget_id: str, element_id: str, action: str) -> None:if widget_id != self.widget_id:returnif action != "press":returnif element_id == "start_pause":if self.running:self._pause({})else:self._start({})elif element_id == "reset":self._reset({})Note the guard on
widget_idandaction— a plugin can bundle more than one widget, and elements can fire events other thanpress. See Data & events for the full event surface. -
Advance the timer with
Section titled “Advance the timer with tick()”tick()There is no per-plugin scheduler in the host yet, so
tick()is a notional hook — call it however often you like (aQTimer, a test, a REPL, an emulator loop) and the widget updates:def tick(self, seconds: int = 1) -> None:if not self.running:returnself.remaining = max(0, self.remaining - seconds)if self.remaining == 0:self._advance_session()self.push_state()def _advance_session(self) -> None:if self.mode == "work":self.sessions_done += 1self.mode = "break"self.remaining = self._break_seconds()else:self.mode = "work"self.remaining = self._work_seconds()self.running = self._auto_start()_advance_session()is where a work session rolls into a break (and back), incrementingsessions_doneand respecting theauto_startsetting from step 4.
Run it without hardware
Section titled “Run it without hardware”The example validates and runs entirely through the emulator — no device required:
cd examples/pomodoromixlar-sdk validate # lints cleanmixlar-sdk emulate --render preview.png # renders the widget to a PNGmixlar-sdk emulate --interactive # press Start/Reset from a REPLIn the interactive REPL you can press the widget’s buttons and inspect the pushed state directly:
mixlar> press start_pausemixlar> snapshot pomodoro.pct = 0 pomodoro.remaining = 25:00 pomodoro.start_pause = Pause pomodoro.status = Work — Runningmixlar> press resetmixlar> press start_pauseTo drive tick() directly and watch a full countdown, script the emulator instead of using the interactive REPL:
from mixlar.emulator import MockDeviceimport pomodoro # the example's entry module, once on sys.path
plugin = pomodoro.PomodoroPlugin()plugin.on_load(None)device = MockDevice()device.attach()device.bind_plugin(plugin)device.load_widget("widgets/pomodoro/widget.json")
plugin.tick(0) # push the idle statedevice.press("start_pause") # Startplugin.tick(60) # advance a minuteprint(device.snapshot())For everything validate, emulate --render, and emulate --interactive support, see Validate and emulate. Once you’re happy with the plugin, Pack, sign & publish covers turning it into a distributable .mixplugin.
Where to go next
Section titled “Where to go next”- Macros — the full
@actionAPI used for Start/Pause/Reset here. - Settings — the schema and
_pget/_psetcontract behindwork_minutes/break_minutes/auto_start. - Elements and Widget spec — every property available on
arc,label, andbtnelements. - Data & events — the full
push_widget_data/on_widget_eventcontract. - Convert a plugin — adapting an existing script into this shape.