Skip to content

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.

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
  1. Every plugin needs a plugin.json that 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": ""
    }

    entry points at the Python module the host loads, and the widgets array pairs the plugin with the widgets/pomodoro/ folder next to it. publisher and sig are filled in at packaging time — see Code-signing plugins.

  2. Instead of hand-writing the usual on-press/on-release quartet for each button, the plugin mixes in MacroActions and decorates a method per action with @action:

    from mixlar import MixlarPlugin
    from mixlar.macros import MacroActions, action
    class 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 = True
    self.push_state()
    @action("pause", "Pause", icon="fa5s.pause", group="Timer")
    def _pause(self, step) -> None:
    self.running = False
    self.push_state()
    @action("reset", "Reset", icon="fa5s.undo", group="Timer")
    def _reset(self, step) -> None:
    self.mode = "work"
    self.running = False
    self.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 calls push_state() so the widget reflects the change without waiting for a tick. For the full macro API (grouping, icons, step context), see Macros.

  3. on_load sets 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 = False
    self.sessions_done = 0
    self.remaining = self._work_seconds()

    _work_seconds() and _break_seconds() read from settings (covered next), falling back to WORK_DEFAULT_MIN = 25 and BREAK_DEFAULT_MIN = 5 if nothing has been configured yet.

  4. settings.schema.json declares 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/_pset helpers, 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))) * 60
    def _break_seconds(self) -> int:
    return int(float(self._pget("break_minutes", BREAK_DEFAULT_MIN))) * 60
    def _auto_start(self) -> bool:
    return bool(self._pget("auto_start", False))

    See Settings for the full set of field types and the _pget/_pset contract.

  5. widgets/pomodoro/widget.json lays 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 bind to subscribe to a push_widget_data key — pomodoro.pct, pomodoro.remaining, pomodoro.status, pomodoro.start_pause. The reset button has no bind; 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.

  6. 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_hidden is 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.

  7. on_widget_event routes 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:
    return
    if action != "press":
    return
    if element_id == "start_pause":
    if self.running:
    self._pause({})
    else:
    self._start({})
    elif element_id == "reset":
    self._reset({})

    Note the guard on widget_id and action — a plugin can bundle more than one widget, and elements can fire events other than press. See Data & events for the full event surface.

  8. There is no per-plugin scheduler in the host yet, so tick() is a notional hook — call it however often you like (a QTimer, a test, a REPL, an emulator loop) and the widget updates:

    def tick(self, seconds: int = 1) -> None:
    if not self.running:
    return
    self.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 += 1
    self.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), incrementing sessions_done and respecting the auto_start setting from step 4.

The example validates and runs entirely through the emulator — no device required:

Terminal window
cd examples/pomodoro
mixlar-sdk validate # lints clean
mixlar-sdk emulate --render preview.png # renders the widget to a PNG
mixlar-sdk emulate --interactive # press Start/Reset from a REPL

In the interactive REPL you can press the widget’s buttons and inspect the pushed state directly:

mixlar> press start_pause
mixlar> snapshot
pomodoro.pct = 0
pomodoro.remaining = 25:00
pomodoro.start_pause = Pause
pomodoro.status = Work — Running
mixlar> press reset
mixlar> press start_pause

To drive tick() directly and watch a full countdown, script the emulator instead of using the interactive REPL:

from mixlar.emulator import MockDevice
import 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 state
device.press("start_pause") # Start
plugin.tick(60) # advance a minute
print(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.

  • Macros — the full @action API used for Start/Pause/Reset here.
  • Settings — the schema and _pget/_pset contract behind work_minutes/break_minutes/auto_start.
  • Elements and Widget spec — every property available on arc, label, and btn elements.
  • Data & events — the full push_widget_data / on_widget_event contract.
  • Convert a plugin — adapting an existing script into this shape.