Skip to content

Dui

dui

Declarative UI packages for deckui (.dui).

Load SVG + YAML packages and use them as touchscreen cards or physical keys without writing any Python rendering code.

Examples:

::

from deckui import DuiCard, DuiKey

# Resolve by name — uses the built-in DUI repository
card = DuiCard("DashboardCard")
key  = DuiKey("IconKey")
key.set("label", "Power")

@card.on("toggle_play_pause")
async def handle():
    ...

# Add a custom search path for your own packages
from deckui import add_dui_path
add_dui_path("~/my-dui-packages")

VALID_CATEGORIES module-attribute

VALID_CATEGORIES = frozenset({'media', 'productivity', 'system', 'gaming', 'social', 'development', 'utilities', 'streaming', 'home-automation', 'communication'})

Controlled vocabulary for the category manifest field.

SpinnerAnimator

Drive frame-by-frame spinner animation on an asyncio event loop.

The animator cycles through pre-rendered frames at a fixed interval, pushing each frame to the device via the provided push_fn.

Parameters:

Name Type Description Default
frames list[bytes]

List of encoded image bytes (one per frame).

required
interval_ms int

Milliseconds between frame updates.

required
push_fn PushFn

Async callable (frame_bytes) -> None that sends a frame to the hardware.

required
Source code in src/deckui/dui/animator.py
class SpinnerAnimator:
    """Drive frame-by-frame spinner animation on an asyncio event loop.

    The animator cycles through pre-rendered frames at a fixed interval,
    pushing each frame to the device via the provided *push_fn*.

    Parameters
    ----------
    frames
        List of encoded image bytes (one per frame).
    interval_ms
        Milliseconds between frame updates.
    push_fn
        Async callable ``(frame_bytes) -> None`` that sends a frame
        to the hardware.
    """

    def __init__(
        self,
        frames: list[bytes],
        interval_ms: int,
        push_fn: PushFn,
    ) -> None:
        if not frames:
            raise ValueError("frames must not be empty")
        self._frames = frames
        self._interval = interval_ms / 1000.0
        self._push_fn = push_fn
        self._task: asyncio.Task[None] | None = None
        self._running = False

    @property
    def is_running(self) -> bool:
        """Whether the animation is currently playing."""
        return self._running

    async def start(self) -> None:
        """Begin the animation loop.

        If already running, this is a no-op.
        """
        if self._running:
            return
        self._running = True
        self._task = asyncio.create_task(self._loop(), name="deckui-spinner")

    async def stop(self) -> None:
        """Stop the animation loop.

        Cancels the running task and waits for it to finish.
        """
        if not self._running:
            return
        self._running = False
        if self._task is not None and not self._task.done():
            self._task.cancel()
            with suppress(asyncio.CancelledError):
                await self._task
            self._task = None

    async def _loop(self) -> None:
        """Cycle through frames at the configured interval."""
        idx = 0
        n = len(self._frames)
        try:
            while self._running:
                frame = self._frames[idx % n]
                try:
                    await self._push_fn(frame)
                except Exception:
                    logger.exception("Error pushing spinner frame")
                idx += 1
                await asyncio.sleep(self._interval)
        except asyncio.CancelledError:
            pass

is_running property

is_running: bool

Whether the animation is currently playing.

start async

start() -> None

Begin the animation loop.

If already running, this is a no-op.

Source code in src/deckui/dui/animator.py
async def start(self) -> None:
    """Begin the animation loop.

    If already running, this is a no-op.
    """
    if self._running:
        return
    self._running = True
    self._task = asyncio.create_task(self._loop(), name="deckui-spinner")

stop async

stop() -> None

Stop the animation loop.

Cancels the running task and waits for it to finish.

Source code in src/deckui/dui/animator.py
async def stop(self) -> None:
    """Stop the animation loop.

    Cancels the running task and waits for it to finish.
    """
    if not self._running:
        return
    self._running = False
    if self._task is not None and not self._task.done():
        self._task.cancel()
        with suppress(asyncio.CancelledError):
            await self._task
        self._task = None

DuiCard

Bases: Card

A touchscreen card whose layout and events are defined by a .dui package.

Instead of writing a Python class with imperative Pillow rendering, you describe the UI in an SVG layout and a YAML manifest. The card loads the package, lets you set binding values, and renders the SVG into a PIL Image for the Stream Deck touchscreen.

Examples:

::

from deckui import DuiCard

# Resolve by name from the DUI repository
card = DuiCard("AudioCard")
card.set("artist", "Ash Walker")

@card.on("toggle_play_pause")
async def handle():
    ...

You can also pass a pre-loaded :class:~deckui.dui.schema.PackageSpec directly::

from deckui.dui import load_package, DuiCard

spec = load_package("./AudioCard.dui")
card = DuiCard(spec)

The card index is assigned automatically when you install the card on a screen with :meth:~deckui.ui.screen.Screen.set_card.

Parameters:

Name Type Description Default
spec PackageSpec or str

A validated :class:~deckui.dui.schema.PackageSpec, or a package name (e.g. "DashboardCard") to resolve from the DUI repository.

required
Source code in src/deckui/dui/card.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
class DuiCard(Card):
    """A touchscreen card whose layout and events are defined by a .dui package.

    Instead of writing a Python class with imperative Pillow rendering,
    you describe the UI in an SVG layout and a YAML manifest.  The card
    loads the package, lets you set binding values, and renders the SVG
    into a PIL Image for the Stream Deck touchscreen.

    Examples
    --------
    ::

        from deckui import DuiCard

        # Resolve by name from the DUI repository
        card = DuiCard("AudioCard")
        card.set("artist", "Ash Walker")

        @card.on("toggle_play_pause")
        async def handle():
            ...

    You can also pass a pre-loaded :class:`~deckui.dui.schema.PackageSpec`
    directly::

        from deckui.dui import load_package, DuiCard

        spec = load_package("./AudioCard.dui")
        card = DuiCard(spec)

    The card index is assigned automatically when you install the card
    on a screen with :meth:`~deckui.ui.screen.Screen.set_card`.

    Parameters
    ----------
    spec : PackageSpec or str
        A validated :class:`~deckui.dui.schema.PackageSpec`, or a
        package name (e.g. ``"DashboardCard"``) to resolve from the
        DUI repository.
    """

    def __init__(self, spec: PackageSpec | str) -> None:
        if isinstance(spec, str):
            from .repository import resolve_dui

            spec = resolve_dui(spec)
        super().__init__()
        self._spec = spec
        self._renderer = SvgRenderer(spec)
        self._events = EventMap(spec.events, spec.regions)
        self._busy = False
        self._animator: SpinnerAnimator | None = None
        self._spinner_frames: SpinnerFrames | None = None
        self._push_fn: PushFn | None = None
        self._panel_size: tuple[int, int] | None = None

    @property
    def spec(self) -> PackageSpec:
        """The package specification backing this card."""
        return self._spec

    @property
    def is_busy(self) -> bool:
        """Whether a busy-guarded handler is currently executing."""
        return self._busy

    @property
    def is_animating(self) -> bool:
        """Whether a spinner animation is currently running."""
        return self._animator is not None and self._animator.is_running

    def set_push_fn(self, push_fn: PushFn, panel_size: tuple[int, int]) -> None:
        """Set the async function used to push animation frames to the device.

        This must be called before spinner animations can play.
        Typically set by :class:`~deckui.runtime.deck.Deck`.

        Parameters
        ----------
        push_fn
            Async callable ``(frame_bytes) -> None``.
        panel_size
            ``(width, height)`` of the touchscreen panel this card occupies,
            used to size spinner frames.
        """
        self._push_fn = push_fn
        self._panel_size = panel_size

    def set(self, name: str, value: Any) -> DuiCard:
        """Set a binding value.  Marks the card dirty if changed.

        Parameters
        ----------
        name
            Binding name as defined in the manifest.
        value
            New value (type depends on binding kind).

        Returns
        -------
        DuiCard
            self, for method chaining.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        if self._renderer.set(name, value):
            self.mark_dirty()
        return self

    def set_many(self, **kwargs: Any) -> DuiCard:
        """Set multiple binding values at once.

        Returns
        -------
        DuiCard
            self, for method chaining.
        """
        if self._renderer.set_many(**kwargs):
            self.mark_dirty()
        return self

    def get(self, name: str) -> Any:
        """Get the current value of a binding.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        return self._renderer.get(name)

    def set_range(
        self, name: str, value: float, *, min_val: float = 0, max_val: float = 1
    ) -> DuiCard:
        """Set a range/slider binding using a domain-scale value.

        Normalises *value* from ``[min_val, max_val]`` to ``[0.0, 1.0]``
        and delegates to :meth:`set`.

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        value
            Value in domain units (e.g. 0–100 for a percentage).
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        DuiCard
            self, for method chaining.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        clamped = max(min_val, min(max_val, value))
        normalised = (clamped - min_val) / (max_val - min_val)
        return self.set(name, normalised)

    def adjust_range(
        self, name: str, delta: float, *, min_val: float = 0, max_val: float = 1
    ) -> float:
        """Adjust a range/slider binding by *delta* domain-scale units.

        Reads the current normalised value, denormalises it, adds *delta*,
        clamps, re-normalises, and calls :meth:`set`.

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        delta
            Amount to add in domain units (negative to decrease).
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        float
            The new value in domain units (clamped to
            ``[min_val, max_val]``), so callers can use it for display
            without back-computing.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        current_norm = float(self.get(name) or 0.0)
        current_domain = min_val + current_norm * (max_val - min_val)
        new_domain = max(min_val, min(max_val, current_domain + delta))
        normalised = (new_domain - min_val) / (max_val - min_val)
        self.set(name, normalised)
        return new_domain

    def get_range(
        self, name: str, *, min_val: float = 0, max_val: float = 1
    ) -> float:
        """Get a range/slider binding denormalised to domain units.

        Parameters
        ----------
        name
            Binding name.
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        float
            The current value in domain units.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        current_norm = float(self.get(name) or 0.0)
        return min_val + current_norm * (max_val - min_val)

    def on(self, event_name: str) -> Callable[[AsyncHandler], AsyncHandler]:
        """Decorator to register a handler for a named semantic event.

        Examples
        --------
        ::

            @card.on("toggle_play_pause")
            async def handle():
                ...

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.

        Returns
        -------
        Callable
            A decorator that registers the handler and returns it unchanged.
        """

        def decorator(fn: AsyncHandler) -> AsyncHandler:
            self._events.on(event_name, self._wrap_handler(fn))
            return fn

        return decorator

    def bind_event(self, event_name: str, handler: AsyncHandler) -> None:
        """Imperatively register a handler for a named semantic event.

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.
        handler
            The async callable to invoke.
        """
        self._events.on(event_name, self._wrap_handler(handler))

    def bind(
        self,
        name: str,
        event: AsyncEvent,
        *,
        transform: Callable[..., Any] | None = None,
    ) -> DuiCard:
        """Subscribe to *event*; on emit, write binding *name* and refresh.

        This fuses three operations that otherwise repeat across every
        controller: subscribe to a service ``AsyncEvent``, translate
        the emitted value into the binding's domain, and request a
        refresh.

        Without *transform*, the binding receives the first positional
        argument from the event (``args[0]``).  With *transform*, the
        callable is invoked with all event ``args``/``kwargs`` and its
        return value becomes the binding value.

        The subscriber lives for the lifetime of the card -- there is
        no automatic teardown.  Bind once during construction or
        :meth:`on_attach`-style lifecycle hooks.

        Parameters
        ----------
        name
            Binding name as defined in the manifest.
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to (e.g. a service's ``on_volume_changed``).
        transform
            Optional sync callable that maps event args to the binding
            value.  If ``None``, ``args[0]`` is used.

        Returns
        -------
        DuiCard
            self, for method chaining.
        """

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            value = (
                (args[0] if args else None)
                if transform is None
                else transform(*args, **kwargs)
            )
            self.set(name, value)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def bind_range(
        self,
        name: str,
        event: AsyncEvent,
        *,
        min_val: float = 0,
        max_val: float = 1,
        transform: Callable[..., float] | None = None,
    ) -> DuiCard:
        """Subscribe to *event*; on emit, write binding *name* via :meth:`set_range`.

        Same shape as :meth:`bind`, but routes through :meth:`set_range`
        so the emitted value can be in domain units (e.g. a 0--100
        percentage).

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to.
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.
        transform
            Optional sync callable that maps event args to a numeric
            value in domain units.  If ``None``, ``args[0]`` is used.

        Returns
        -------
        DuiCard
            self, for method chaining.

        Raises
        ------
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            value = (
                float(args[0])
                if transform is None
                else float(transform(*args, **kwargs))
            )
            self.set_range(name, value, min_val=min_val, max_val=max_val)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def bind_many(
        self,
        event: AsyncEvent,
        transform: Callable[..., dict[str, Any]],
    ) -> DuiCard:
        """Subscribe to *event*; transform args into a dict and :meth:`set_many` it.

        Use this when one event drives several bindings at once -- e.g.
        a ``track_changed`` event populating ``artist``, ``title``,
        ``album``, and ``state`` from a single track dict.

        Parameters
        ----------
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to.
        transform
            Required sync callable that maps event args to a dict of
            binding names to values.

        Returns
        -------
        DuiCard
            self, for method chaining.
        """

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            values = transform(*args, **kwargs)
            self.set_many(**values)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def forward(
        self,
        event_name: str,
        target: Callable[..., Any],
    ) -> DuiCard:
        """Register *target* as the handler for manifest event *event_name*.

        Sugar for the very common shape::

            @card.on("toggle")
            async def _h() -> None:
                await svc.toggle()

        which becomes::

            card.forward("toggle", svc.toggle)

        *target* may be an async function or any sync callable that
        returns an awaitable (e.g. a lambda whose body invokes an
        async method).  All positional and keyword arguments emitted
        by the event are forwarded to *target*.

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.
        target
            Async-callable forwarding target.

        Returns
        -------
        DuiCard
            self, for method chaining.
        """

        async def _handler(*args: Any, **kwargs: Any) -> None:
            await target(*args, **kwargs)

        self._events.on(event_name, self._wrap_handler(_handler))
        return self

    def _wrap_handler(self, fn: AsyncHandler) -> AsyncHandler:
        """Wrap *fn* so any state changes trigger a refresh after it runs.

        For events dispatched synchronously from the deck's event loop
        (key press, encoder press, non-accumulated turns) the deck
        already calls ``refresh()`` when the card is dirty after the
        handler runs.  But several event paths fire from detached
        asyncio tasks where the dispatcher is no longer in scope:

        * Accumulator flushes (``accumulate: true`` encoder turns).
        * Hold timers (``encoder_hold`` / ``key_hold``).

        Without this wrapper, those handlers can mutate bindings
        without ever triggering a render -- the user's display goes
        silently stale (e.g. brightness slider lagging behind rapid
        encoder spins).  Wrapping at the registration boundary makes
        every handler self-refreshing, regardless of how it's
        dispatched.

        The wrapper:

        * Is a no-op when no refresh callback is wired (i.e. before the
          card is installed on a screen).
        * Is idempotent when the handler already calls
          :meth:`request_refresh` itself -- the second call finds the
          card clean and skips re-rendering.
        """

        async def _wrapped(*args: Any, **kwargs: Any) -> None:
            await fn(*args, **kwargs)
            if self.is_dirty:
                await self.request_refresh()

        # Preserve the original for testing / introspection.
        _wrapped.__wrapped__ = fn  # type: ignore[attr-defined]
        return _wrapped

    def render(self) -> Image.Image:
        """Render the SVG layout with current bindings to a PIL Image.

        Returns
        -------
        Image.Image
            A panel-sized RGB :class:`~PIL.Image.Image` (sized from the
            ``.dui`` SVG's intrinsic dimensions).
        """
        return self._renderer.render()

    async def prepare_assets(self) -> None:
        """No-op — .dui cards manage their own assets via the SVG."""

    def handle_encoder_turn(self, direction: int) -> None:
        """Route encoder turn through the event map."""
        handler = self._events.handle_encoder_turn(direction)
        if handler is not None:
            self.queue_pending_callback(handler, (direction,))

    def handle_encoder_press(self) -> None:
        """Route encoder press through the event map."""
        for handler in self._events.handle_encoder_press():
            self.queue_pending_callback(handler, ())

    def handle_encoder_release(self) -> None:
        """Route encoder release through the event map."""
        for handler in self._events.handle_encoder_release():
            self.queue_pending_callback(handler, ())

    async def dispatch_touch(self, event: TouchEvent) -> None:
        """Dispatch touch events through regions and the event map.

        Falls back to the base Card touch handlers (on_tap, etc.)
        if the event map doesn't handle the event.
        """
        handler = self._events.handle_touch(event.event_type, event.x, event.y)
        if handler is not None:
            await handler()
        else:
            await super().dispatch_touch(event)

    async def start_busy(self) -> None:
        """Enter the busy state and start the spinner animation.

        While busy, the card suppresses further ``start_busy()`` calls.
        The spinner keeps running until :meth:`finish_busy` is called.

        If no spinner is configured in the manifest the busy flag is
        still set (suppressing duplicate calls) but no animation plays.
        """
        if self._busy:
            return
        self._busy = True
        await self._start_spinner()

    async def finish_busy(self) -> None:
        """Stop the spinner and exit the busy state.

        Call this from your application code when the asynchronous
        work is truly complete (e.g. after receiving a state update
        from an external system).

        If the card is not currently busy this is a no-op.
        """
        if not self._busy:
            return
        await self._stop_spinner()
        self._busy = False
        self.mark_dirty()

    async def _start_spinner(self) -> None:
        """Start the spinner animation if configured."""
        if (
            self._spec.spinner is None
            or self._push_fn is None
            or self._panel_size is None
        ):
            return

        width, height = self._panel_size
        rendered_svg = self._renderer.render_svg()
        self._spinner_frames = SpinnerFrames(
            self._spec,
            width=width,
            height=height,
            rendered_svg=rendered_svg,
        )

        self._animator = SpinnerAnimator(
            frames=self._spinner_frames.frames,
            interval_ms=self._spinner_frames.interval_ms,
            push_fn=self._push_fn,
        )
        await self._animator.start()

    async def _stop_spinner(self) -> None:
        """Stop the spinner animation."""
        if self._animator is not None:
            await self._animator.stop()
            self._animator = None

    def cleanup(self) -> None:
        """Cancel pending accumulators and release resources."""
        self._events.cancel_accumulators()

spec property

spec: PackageSpec

The package specification backing this card.

is_busy property

is_busy: bool

Whether a busy-guarded handler is currently executing.

is_animating property

is_animating: bool

Whether a spinner animation is currently running.

set_push_fn

set_push_fn(push_fn: PushFn, panel_size: tuple[int, int]) -> None

Set the async function used to push animation frames to the device.

This must be called before spinner animations can play. Typically set by :class:~deckui.runtime.deck.Deck.

Parameters:

Name Type Description Default
push_fn PushFn

Async callable (frame_bytes) -> None.

required
panel_size tuple[int, int]

(width, height) of the touchscreen panel this card occupies, used to size spinner frames.

required
Source code in src/deckui/dui/card.py
def set_push_fn(self, push_fn: PushFn, panel_size: tuple[int, int]) -> None:
    """Set the async function used to push animation frames to the device.

    This must be called before spinner animations can play.
    Typically set by :class:`~deckui.runtime.deck.Deck`.

    Parameters
    ----------
    push_fn
        Async callable ``(frame_bytes) -> None``.
    panel_size
        ``(width, height)`` of the touchscreen panel this card occupies,
        used to size spinner frames.
    """
    self._push_fn = push_fn
    self._panel_size = panel_size

set

set(name: str, value: Any) -> DuiCard

Set a binding value. Marks the card dirty if changed.

Parameters:

Name Type Description Default
name str

Binding name as defined in the manifest.

required
value Any

New value (type depends on binding kind).

required

Returns:

Type Description
DuiCard

self, for method chaining.

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/card.py
def set(self, name: str, value: Any) -> DuiCard:
    """Set a binding value.  Marks the card dirty if changed.

    Parameters
    ----------
    name
        Binding name as defined in the manifest.
    value
        New value (type depends on binding kind).

    Returns
    -------
    DuiCard
        self, for method chaining.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    if self._renderer.set(name, value):
        self.mark_dirty()
    return self

set_many

set_many(**kwargs: Any) -> DuiCard

Set multiple binding values at once.

Returns:

Type Description
DuiCard

self, for method chaining.

Source code in src/deckui/dui/card.py
def set_many(self, **kwargs: Any) -> DuiCard:
    """Set multiple binding values at once.

    Returns
    -------
    DuiCard
        self, for method chaining.
    """
    if self._renderer.set_many(**kwargs):
        self.mark_dirty()
    return self

get

get(name: str) -> Any

Get the current value of a binding.

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/card.py
def get(self, name: str) -> Any:
    """Get the current value of a binding.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    return self._renderer.get(name)

set_range

set_range(name: str, value: float, *, min_val: float = 0, max_val: float = 1) -> DuiCard

Set a range/slider binding using a domain-scale value.

Normalises value from [min_val, max_val] to [0.0, 1.0] and delegates to :meth:set.

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
value float

Value in domain units (e.g. 0–100 for a percentage).

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
DuiCard

self, for method chaining.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/card.py
def set_range(
    self, name: str, value: float, *, min_val: float = 0, max_val: float = 1
) -> DuiCard:
    """Set a range/slider binding using a domain-scale value.

    Normalises *value* from ``[min_val, max_val]`` to ``[0.0, 1.0]``
    and delegates to :meth:`set`.

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    value
        Value in domain units (e.g. 0–100 for a percentage).
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    DuiCard
        self, for method chaining.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    clamped = max(min_val, min(max_val, value))
    normalised = (clamped - min_val) / (max_val - min_val)
    return self.set(name, normalised)

adjust_range

adjust_range(name: str, delta: float, *, min_val: float = 0, max_val: float = 1) -> float

Adjust a range/slider binding by delta domain-scale units.

Reads the current normalised value, denormalises it, adds delta, clamps, re-normalises, and calls :meth:set.

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
delta float

Amount to add in domain units (negative to decrease).

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
float

The new value in domain units (clamped to [min_val, max_val]), so callers can use it for display without back-computing.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/card.py
def adjust_range(
    self, name: str, delta: float, *, min_val: float = 0, max_val: float = 1
) -> float:
    """Adjust a range/slider binding by *delta* domain-scale units.

    Reads the current normalised value, denormalises it, adds *delta*,
    clamps, re-normalises, and calls :meth:`set`.

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    delta
        Amount to add in domain units (negative to decrease).
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    float
        The new value in domain units (clamped to
        ``[min_val, max_val]``), so callers can use it for display
        without back-computing.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    current_norm = float(self.get(name) or 0.0)
    current_domain = min_val + current_norm * (max_val - min_val)
    new_domain = max(min_val, min(max_val, current_domain + delta))
    normalised = (new_domain - min_val) / (max_val - min_val)
    self.set(name, normalised)
    return new_domain

get_range

get_range(name: str, *, min_val: float = 0, max_val: float = 1) -> float

Get a range/slider binding denormalised to domain units.

Parameters:

Name Type Description Default
name str

Binding name.

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
float

The current value in domain units.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/card.py
def get_range(
    self, name: str, *, min_val: float = 0, max_val: float = 1
) -> float:
    """Get a range/slider binding denormalised to domain units.

    Parameters
    ----------
    name
        Binding name.
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    float
        The current value in domain units.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    current_norm = float(self.get(name) or 0.0)
    return min_val + current_norm * (max_val - min_val)

on

on(event_name: str) -> Callable[[AsyncHandler], AsyncHandler]

Decorator to register a handler for a named semantic event.

Examples:

::

@card.on("toggle_play_pause")
async def handle():
    ...

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required

Returns:

Type Description
Callable

A decorator that registers the handler and returns it unchanged.

Source code in src/deckui/dui/card.py
def on(self, event_name: str) -> Callable[[AsyncHandler], AsyncHandler]:
    """Decorator to register a handler for a named semantic event.

    Examples
    --------
    ::

        @card.on("toggle_play_pause")
        async def handle():
            ...

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.

    Returns
    -------
    Callable
        A decorator that registers the handler and returns it unchanged.
    """

    def decorator(fn: AsyncHandler) -> AsyncHandler:
        self._events.on(event_name, self._wrap_handler(fn))
        return fn

    return decorator

bind_event

bind_event(event_name: str, handler: AsyncHandler) -> None

Imperatively register a handler for a named semantic event.

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required
handler AsyncHandler

The async callable to invoke.

required
Source code in src/deckui/dui/card.py
def bind_event(self, event_name: str, handler: AsyncHandler) -> None:
    """Imperatively register a handler for a named semantic event.

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.
    handler
        The async callable to invoke.
    """
    self._events.on(event_name, self._wrap_handler(handler))

bind

bind(name: str, event: AsyncEvent, *, transform: Callable[..., Any] | None = None) -> DuiCard

Subscribe to event; on emit, write binding name and refresh.

This fuses three operations that otherwise repeat across every controller: subscribe to a service AsyncEvent, translate the emitted value into the binding's domain, and request a refresh.

Without transform, the binding receives the first positional argument from the event (args[0]). With transform, the callable is invoked with all event args/kwargs and its return value becomes the binding value.

The subscriber lives for the lifetime of the card -- there is no automatic teardown. Bind once during construction or :meth:on_attach-style lifecycle hooks.

Parameters:

Name Type Description Default
name str

Binding name as defined in the manifest.

required
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to (e.g. a service's on_volume_changed).

required
transform Callable[..., Any] | None

Optional sync callable that maps event args to the binding value. If None, args[0] is used.

None

Returns:

Type Description
DuiCard

self, for method chaining.

Source code in src/deckui/dui/card.py
def bind(
    self,
    name: str,
    event: AsyncEvent,
    *,
    transform: Callable[..., Any] | None = None,
) -> DuiCard:
    """Subscribe to *event*; on emit, write binding *name* and refresh.

    This fuses three operations that otherwise repeat across every
    controller: subscribe to a service ``AsyncEvent``, translate
    the emitted value into the binding's domain, and request a
    refresh.

    Without *transform*, the binding receives the first positional
    argument from the event (``args[0]``).  With *transform*, the
    callable is invoked with all event ``args``/``kwargs`` and its
    return value becomes the binding value.

    The subscriber lives for the lifetime of the card -- there is
    no automatic teardown.  Bind once during construction or
    :meth:`on_attach`-style lifecycle hooks.

    Parameters
    ----------
    name
        Binding name as defined in the manifest.
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to (e.g. a service's ``on_volume_changed``).
    transform
        Optional sync callable that maps event args to the binding
        value.  If ``None``, ``args[0]`` is used.

    Returns
    -------
    DuiCard
        self, for method chaining.
    """

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        value = (
            (args[0] if args else None)
            if transform is None
            else transform(*args, **kwargs)
        )
        self.set(name, value)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

bind_range

bind_range(name: str, event: AsyncEvent, *, min_val: float = 0, max_val: float = 1, transform: Callable[..., float] | None = None) -> DuiCard

Subscribe to event; on emit, write binding name via :meth:set_range.

Same shape as :meth:bind, but routes through :meth:set_range so the emitted value can be in domain units (e.g. a 0--100 percentage).

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to.

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1
transform Callable[..., float] | None

Optional sync callable that maps event args to a numeric value in domain units. If None, args[0] is used.

None

Returns:

Type Description
DuiCard

self, for method chaining.

Raises:

Type Description
ValueError

If min_val equals max_val.

Source code in src/deckui/dui/card.py
def bind_range(
    self,
    name: str,
    event: AsyncEvent,
    *,
    min_val: float = 0,
    max_val: float = 1,
    transform: Callable[..., float] | None = None,
) -> DuiCard:
    """Subscribe to *event*; on emit, write binding *name* via :meth:`set_range`.

    Same shape as :meth:`bind`, but routes through :meth:`set_range`
    so the emitted value can be in domain units (e.g. a 0--100
    percentage).

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to.
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.
    transform
        Optional sync callable that maps event args to a numeric
        value in domain units.  If ``None``, ``args[0]`` is used.

    Returns
    -------
    DuiCard
        self, for method chaining.

    Raises
    ------
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        value = (
            float(args[0])
            if transform is None
            else float(transform(*args, **kwargs))
        )
        self.set_range(name, value, min_val=min_val, max_val=max_val)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

bind_many

bind_many(event: AsyncEvent, transform: Callable[..., dict[str, Any]]) -> DuiCard

Subscribe to event; transform args into a dict and :meth:set_many it.

Use this when one event drives several bindings at once -- e.g. a track_changed event populating artist, title, album, and state from a single track dict.

Parameters:

Name Type Description Default
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to.

required
transform Callable[..., dict[str, Any]]

Required sync callable that maps event args to a dict of binding names to values.

required

Returns:

Type Description
DuiCard

self, for method chaining.

Source code in src/deckui/dui/card.py
def bind_many(
    self,
    event: AsyncEvent,
    transform: Callable[..., dict[str, Any]],
) -> DuiCard:
    """Subscribe to *event*; transform args into a dict and :meth:`set_many` it.

    Use this when one event drives several bindings at once -- e.g.
    a ``track_changed`` event populating ``artist``, ``title``,
    ``album``, and ``state`` from a single track dict.

    Parameters
    ----------
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to.
    transform
        Required sync callable that maps event args to a dict of
        binding names to values.

    Returns
    -------
    DuiCard
        self, for method chaining.
    """

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        values = transform(*args, **kwargs)
        self.set_many(**values)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

forward

forward(event_name: str, target: Callable[..., Any]) -> DuiCard

Register target as the handler for manifest event event_name.

Sugar for the very common shape::

@card.on("toggle")
async def _h() -> None:
    await svc.toggle()

which becomes::

card.forward("toggle", svc.toggle)

target may be an async function or any sync callable that returns an awaitable (e.g. a lambda whose body invokes an async method). All positional and keyword arguments emitted by the event are forwarded to target.

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required
target Callable[..., Any]

Async-callable forwarding target.

required

Returns:

Type Description
DuiCard

self, for method chaining.

Source code in src/deckui/dui/card.py
def forward(
    self,
    event_name: str,
    target: Callable[..., Any],
) -> DuiCard:
    """Register *target* as the handler for manifest event *event_name*.

    Sugar for the very common shape::

        @card.on("toggle")
        async def _h() -> None:
            await svc.toggle()

    which becomes::

        card.forward("toggle", svc.toggle)

    *target* may be an async function or any sync callable that
    returns an awaitable (e.g. a lambda whose body invokes an
    async method).  All positional and keyword arguments emitted
    by the event are forwarded to *target*.

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.
    target
        Async-callable forwarding target.

    Returns
    -------
    DuiCard
        self, for method chaining.
    """

    async def _handler(*args: Any, **kwargs: Any) -> None:
        await target(*args, **kwargs)

    self._events.on(event_name, self._wrap_handler(_handler))
    return self

render

render() -> Image.Image

Render the SVG layout with current bindings to a PIL Image.

Returns:

Type Description
Image

A panel-sized RGB :class:~PIL.Image.Image (sized from the .dui SVG's intrinsic dimensions).

Source code in src/deckui/dui/card.py
def render(self) -> Image.Image:
    """Render the SVG layout with current bindings to a PIL Image.

    Returns
    -------
    Image.Image
        A panel-sized RGB :class:`~PIL.Image.Image` (sized from the
        ``.dui`` SVG's intrinsic dimensions).
    """
    return self._renderer.render()

prepare_assets async

prepare_assets() -> None

No-op — .dui cards manage their own assets via the SVG.

Source code in src/deckui/dui/card.py
async def prepare_assets(self) -> None:
    """No-op — .dui cards manage their own assets via the SVG."""

handle_encoder_turn

handle_encoder_turn(direction: int) -> None

Route encoder turn through the event map.

Source code in src/deckui/dui/card.py
def handle_encoder_turn(self, direction: int) -> None:
    """Route encoder turn through the event map."""
    handler = self._events.handle_encoder_turn(direction)
    if handler is not None:
        self.queue_pending_callback(handler, (direction,))

handle_encoder_press

handle_encoder_press() -> None

Route encoder press through the event map.

Source code in src/deckui/dui/card.py
def handle_encoder_press(self) -> None:
    """Route encoder press through the event map."""
    for handler in self._events.handle_encoder_press():
        self.queue_pending_callback(handler, ())

handle_encoder_release

handle_encoder_release() -> None

Route encoder release through the event map.

Source code in src/deckui/dui/card.py
def handle_encoder_release(self) -> None:
    """Route encoder release through the event map."""
    for handler in self._events.handle_encoder_release():
        self.queue_pending_callback(handler, ())

dispatch_touch async

dispatch_touch(event: TouchEvent) -> None

Dispatch touch events through regions and the event map.

Falls back to the base Card touch handlers (on_tap, etc.) if the event map doesn't handle the event.

Source code in src/deckui/dui/card.py
async def dispatch_touch(self, event: TouchEvent) -> None:
    """Dispatch touch events through regions and the event map.

    Falls back to the base Card touch handlers (on_tap, etc.)
    if the event map doesn't handle the event.
    """
    handler = self._events.handle_touch(event.event_type, event.x, event.y)
    if handler is not None:
        await handler()
    else:
        await super().dispatch_touch(event)

start_busy async

start_busy() -> None

Enter the busy state and start the spinner animation.

While busy, the card suppresses further start_busy() calls. The spinner keeps running until :meth:finish_busy is called.

If no spinner is configured in the manifest the busy flag is still set (suppressing duplicate calls) but no animation plays.

Source code in src/deckui/dui/card.py
async def start_busy(self) -> None:
    """Enter the busy state and start the spinner animation.

    While busy, the card suppresses further ``start_busy()`` calls.
    The spinner keeps running until :meth:`finish_busy` is called.

    If no spinner is configured in the manifest the busy flag is
    still set (suppressing duplicate calls) but no animation plays.
    """
    if self._busy:
        return
    self._busy = True
    await self._start_spinner()

finish_busy async

finish_busy() -> None

Stop the spinner and exit the busy state.

Call this from your application code when the asynchronous work is truly complete (e.g. after receiving a state update from an external system).

If the card is not currently busy this is a no-op.

Source code in src/deckui/dui/card.py
async def finish_busy(self) -> None:
    """Stop the spinner and exit the busy state.

    Call this from your application code when the asynchronous
    work is truly complete (e.g. after receiving a state update
    from an external system).

    If the card is not currently busy this is a no-op.
    """
    if not self._busy:
        return
    await self._stop_spinner()
    self._busy = False
    self.mark_dirty()

cleanup

cleanup() -> None

Cancel pending accumulators and release resources.

Source code in src/deckui/dui/card.py
def cleanup(self) -> None:
    """Cancel pending accumulators and release resources."""
    self._events.cancel_accumulators()

EventMap

Map physical hardware events to named semantic events.

Handles simple mappings (encoder_press → semantic name) as well as compound gestures:

  • encoder_press_release: fires only if the press duration is within max_duration_ms.
  • encoder_hold / key_hold: starts a timer on press and fires the handler after hold_ms while the key/encoder is still held. Suppresses any press_release or release event for that press–release cycle.
  • encoder_turn: fires turn events only while the encoder is not pressed. Turning while pressed never falls through to this mapping. Immediately after releasing a press cycle that included at least one turn, encoder_turn is suppressed for release_turn_grace_ms to debounce the spurious tick a finger often produces while lifting off the dial.
  • encoder_press_turn: fires turn events only while the encoder is held down. When declared with a direction filter, mismatching turns while pressed are silent — there is no fallback to encoder_turn. Any turn while pressed cancels a pending encoder_hold regardless of whether a handler fires.
  • Direction filtering: encoder_turn with direction: left only fires for counter-clockwise turns.

Parameters:

Name Type Description Default
events tuple[EventMapping, ...]

Event mappings from the package manifest.

required
regions tuple[Region, ...]

Touch regions from the package manifest.

()
release_turn_grace_ms int

Suppression window (in milliseconds) applied to plain encoder_turn events immediately after the encoder is released following a press cycle that included at least one turn. Defaults to :data:~deckui.dui.schema.DEFAULT_RELEASE_TURN_GRACE_MS. Pass 0 to disable.

DEFAULT_RELEASE_TURN_GRACE_MS
Source code in src/deckui/dui/event_map.py
class EventMap:
    """Map physical hardware events to named semantic events.

    Handles simple mappings (encoder_press → semantic name) as well as
    compound gestures:

    - ``encoder_press_release``: fires only if the press duration is
      within ``max_duration_ms``.
    - ``encoder_hold`` / ``key_hold``: starts a timer on press and fires
      the handler after ``hold_ms`` while the key/encoder is still held.
      Suppresses any ``press_release`` or ``release`` event for that
      press–release cycle.
    - ``encoder_turn``: fires turn events only while the encoder is
      **not** pressed.  Turning while pressed never falls through to
      this mapping.  Immediately after releasing a press cycle that
      included at least one turn, ``encoder_turn`` is suppressed for
      ``release_turn_grace_ms`` to debounce the spurious tick a finger
      often produces while lifting off the dial.
    - ``encoder_press_turn``: fires turn events only while the encoder
      is held down.  When declared with a direction filter, mismatching
      turns while pressed are silent — there is no fallback to
      ``encoder_turn``.  Any turn while pressed cancels a pending
      ``encoder_hold`` regardless of whether a handler fires.
    - Direction filtering: ``encoder_turn`` with ``direction: left``
      only fires for counter-clockwise turns.

    Parameters
    ----------
    events
        Event mappings from the package manifest.
    regions
        Touch regions from the package manifest.
    release_turn_grace_ms
        Suppression window (in milliseconds) applied to plain
        ``encoder_turn`` events immediately after the encoder is released
        following a press cycle that included at least one turn.  Defaults
        to :data:`~deckui.dui.schema.DEFAULT_RELEASE_TURN_GRACE_MS`.  Pass
        ``0`` to disable.
    """

    def __init__(
        self,
        events: tuple[EventMapping, ...],
        regions: tuple[Region, ...] = (),
        release_turn_grace_ms: int = DEFAULT_RELEASE_TURN_GRACE_MS,
    ) -> None:
        self._mappings = events
        self._regions = regions
        self._handlers: dict[str, AsyncHandler] = {}

        self._press_time: float | None = None
        self._pressed = False

        self._hold_task: asyncio.Task[None] | None = None
        self._hold_fired = False

        self._press_had_turn = False
        self._release_turn_grace_s = max(0, release_turn_grace_ms) / 1000.0
        self._turn_suppressed_until = 0.0

        self._by_source: dict[str, list[EventMapping]] = {}
        for mapping in events:
            self._by_source.setdefault(mapping.source, []).append(mapping)

        self._accumulators: dict[str, DialAccumulator] = {}

    def on(self, event_name: str, handler: AsyncHandler) -> None:
        """Register a handler for a named semantic event.

        Parameters
        ----------
        event_name
            The semantic event name (as defined in the manifest).
        handler
            An async callable to invoke when the event fires.
            For accumulated events the handler signature must accept
            a single ``int`` argument (the net accumulated steps).

        Raises
        ------
        KeyError
            If *event_name* is not defined in the manifest.
        """
        known = {m.name for m in self._mappings}
        if event_name not in known:
            raise KeyError(
                f"Unknown event '{event_name}'. Defined events: {sorted(known)}"
            )

        mapping = next(m for m in self._mappings if m.name == event_name)
        if mapping.accumulate:
            kwargs: dict[str, Any] = {}
            if mapping.accumulate_delay is not None:
                kwargs["delay"] = mapping.accumulate_delay
            if mapping.accumulate_max_steps is not None:
                kwargs["max_steps"] = mapping.accumulate_max_steps
            self._accumulators[event_name] = DialAccumulator(handler, **kwargs)

        self._handlers[event_name] = handler

    @property
    def event_names(self) -> list[str]:
        """All semantic event names defined in this map."""
        return [m.name for m in self._mappings]

    def _start_hold_timer(self, source: str) -> None:
        """Start a hold timer for the first matching hold mapping.

        Parameters
        ----------
        source
            ``"key_hold"`` or ``"encoder_hold"``.
        """
        for mapping in self._by_source.get(source, []):
            handler = self._handlers.get(mapping.name)
            if handler is None:
                continue
            hold_ms = mapping.hold_ms
            if hold_ms is None:
                continue  # pragma: no cover — validated by loader
            self._hold_task = asyncio.ensure_future(
                self._hold_delay(hold_ms / 1000.0, handler)
            )
            return

    async def _hold_delay(self, seconds: float, handler: AsyncHandler) -> None:
        """Sleep then fire the hold handler if still pressed."""
        await asyncio.sleep(seconds)
        if self._pressed:
            self._hold_fired = True
            await handler()

    def _cancel_hold_timer(self) -> None:
        """Cancel an in-progress hold timer if one is running."""
        if self._hold_task is not None:
            self._hold_task.cancel()
            self._hold_task = None

    def cancel_accumulators(self) -> None:
        """Cancel all active dial accumulators and discard pending ticks."""
        for acc in self._accumulators.values():
            acc.cancel()

    def handle_encoder_turn(self, direction: int) -> AsyncHandler | None:
        """Match an encoder turn to a semantic event.

        Parameters
        ----------
        direction
            Positive for clockwise, negative for counter-clockwise.

        Returns
        -------
        AsyncHandler or None
            The matched handler, or ``None`` if no mapping matched.
            For accumulated events this returns ``None`` because the
            tick is forwarded to the :class:`DialAccumulator` which
            schedules its own async flush.
        """
        if self._pressed:
            self._cancel_hold_timer()
            self._press_had_turn = True

            for mapping in self._by_source.get("encoder_press_turn", []):
                if self._direction_matches(mapping, direction):
                    acc = self._accumulators.get(mapping.name)
                    if acc is not None:
                        acc.tick(direction)
                        return None
                    h = self._handlers.get(mapping.name)
                    if h is not None:
                        return h
            return None

        if time.monotonic() < self._turn_suppressed_until:
            return None

        for mapping in self._by_source.get("encoder_turn", []):
            if self._direction_matches(mapping, direction):
                acc = self._accumulators.get(mapping.name)
                if acc is not None:
                    acc.tick(direction)
                    return None
                h = self._handlers.get(mapping.name)
                if h is not None:
                    return h

        return None

    def handle_encoder_press(self) -> list[AsyncHandler]:
        """Match an encoder press to semantic events.

        Records the press timestamp for gesture detection and starts
        a hold timer if an ``encoder_hold`` mapping exists.

        Returns
        -------
        list[AsyncHandler]
            A list of matched handlers (may be empty).
        """
        self._press_time = time.monotonic()
        self._pressed = True
        self._hold_fired = False
        self._press_had_turn = False

        self._start_hold_timer("encoder_hold")

        handlers: list[AsyncHandler] = []
        for mapping in self._by_source.get("encoder_press", []):
            handler = self._handlers.get(mapping.name)
            if handler is not None:
                handlers.append(handler)
        return handlers

    def handle_encoder_release(self) -> list[AsyncHandler]:
        """Match an encoder release to semantic events.

        Cancels any running hold timer.  The simple ``encoder_release``
        event always fires regardless of whether a hold fired.
        ``encoder_press_release`` is suppressed when a hold already
        fired for this press–release cycle (since the interaction was
        a hold, not a press-release gesture).

        If at least one turn occurred during the press cycle, opens a
        ``release_turn_grace_ms`` window during which subsequent plain
        ``encoder_turn`` events are ignored.

        Returns
        -------
        list[AsyncHandler]
            A list of matched handlers (may be empty).
        """
        self._pressed = False
        self._cancel_hold_timer()

        hold_fired = self._hold_fired
        self._hold_fired = False

        if self._press_had_turn and self._release_turn_grace_s > 0:
            self._turn_suppressed_until = (
                time.monotonic() + self._release_turn_grace_s
            )
        self._press_had_turn = False

        handlers: list[AsyncHandler] = []

        if not hold_fired and self._press_time is not None:
            elapsed_ms = (time.monotonic() - self._press_time) * 1000

            for mapping in self._by_source.get("encoder_press_release", []):
                max_ms = mapping.max_duration_ms
                if max_ms is None or elapsed_ms <= max_ms:
                    handler = self._handlers.get(mapping.name)
                    if handler is not None:
                        handlers.append(handler)

        self._press_time = None

        for mapping in self._by_source.get("encoder_release", []):
            handler = self._handlers.get(mapping.name)
            if handler is not None:
                handlers.append(handler)

        return handlers

    def handle_key_press(self) -> list[AsyncHandler]:
        """Match a key press to semantic events.

        Records the press timestamp and starts a hold timer if a
        ``key_hold`` mapping exists.

        Returns
        -------
        list[AsyncHandler]
            A list of matched handlers (may be empty).
        """
        self._press_time = time.monotonic()
        self._pressed = True
        self._hold_fired = False

        self._start_hold_timer("key_hold")

        handlers: list[AsyncHandler] = []
        for mapping in self._by_source.get("key_press", []):
            handler = self._handlers.get(mapping.name)
            if handler is not None:
                handlers.append(handler)
        return handlers

    def handle_key_release(self) -> list[AsyncHandler]:
        """Match a key release to semantic events.

        Cancels any running hold timer.  The simple ``key_release``
        event always fires regardless of whether a hold fired.
        ``key_press_release`` is suppressed when a hold already
        fired for this press–release cycle (since the interaction was
        a hold, not a press-release gesture).

        Returns
        -------
        list[AsyncHandler]
            A list of matched handlers (may be empty).
        """
        self._pressed = False
        self._cancel_hold_timer()

        hold_fired = self._hold_fired
        self._hold_fired = False

        handlers: list[AsyncHandler] = []

        if not hold_fired and self._press_time is not None:
            elapsed_ms = (time.monotonic() - self._press_time) * 1000

            for mapping in self._by_source.get("key_press_release", []):
                max_ms = mapping.max_duration_ms
                if max_ms is None or elapsed_ms <= max_ms:
                    handler = self._handlers.get(mapping.name)
                    if handler is not None:
                        handlers.append(handler)

        self._press_time = None

        for mapping in self._by_source.get("key_release", []):
            handler = self._handlers.get(mapping.name)
            if handler is not None:
                handlers.append(handler)

        return handlers

    def handle_touch(
        self, event_type: EventType, x: int, y: int
    ) -> AsyncHandler | None:
        """Match a touch event to a semantic event via regions.

        Parameters
        ----------
        event_type
            The touch event type (TOUCH_SHORT or TOUCH_LONG).
        x
            Touch x coordinate (relative to card origin).
        y
            Touch y coordinate (relative to card origin).

        Returns
        -------
        AsyncHandler or None
            The matched handler, or ``None`` if no region/mapping matched.
        """
        from ..runtime.events import EventType as ET_Enum

        if event_type == ET_Enum.TOUCH_SHORT:
            touch_name = "tap"
        elif event_type == ET_Enum.TOUCH_LONG:
            touch_name = "long_press"
        else:
            return None

        for region in self._regions:
            if touch_name not in region.events:
                continue
            if (
                region.x <= x < region.x + region.width
                and region.y <= y < region.y + region.height
            ):
                for mapping in self._by_source.get(touch_name, []):
                    h = self._handlers.get(mapping.name)
                    if h is not None:
                        return h

        for mapping in self._by_source.get(touch_name, []):
            h = self._handlers.get(mapping.name)
            if h is not None:
                return h

        return None

    @staticmethod
    def _direction_matches(mapping: EventMapping, direction: int) -> bool:
        """Check if a turn direction matches the mapping's filter."""
        if mapping.direction is None:
            return True
        if mapping.direction == "left" and direction < 0:
            return True
        return mapping.direction == "right" and direction > 0

event_names property

event_names: list[str]

All semantic event names defined in this map.

on

on(event_name: str, handler: AsyncHandler) -> None

Register a handler for a named semantic event.

Parameters:

Name Type Description Default
event_name str

The semantic event name (as defined in the manifest).

required
handler AsyncHandler

An async callable to invoke when the event fires. For accumulated events the handler signature must accept a single int argument (the net accumulated steps).

required

Raises:

Type Description
KeyError

If event_name is not defined in the manifest.

Source code in src/deckui/dui/event_map.py
def on(self, event_name: str, handler: AsyncHandler) -> None:
    """Register a handler for a named semantic event.

    Parameters
    ----------
    event_name
        The semantic event name (as defined in the manifest).
    handler
        An async callable to invoke when the event fires.
        For accumulated events the handler signature must accept
        a single ``int`` argument (the net accumulated steps).

    Raises
    ------
    KeyError
        If *event_name* is not defined in the manifest.
    """
    known = {m.name for m in self._mappings}
    if event_name not in known:
        raise KeyError(
            f"Unknown event '{event_name}'. Defined events: {sorted(known)}"
        )

    mapping = next(m for m in self._mappings if m.name == event_name)
    if mapping.accumulate:
        kwargs: dict[str, Any] = {}
        if mapping.accumulate_delay is not None:
            kwargs["delay"] = mapping.accumulate_delay
        if mapping.accumulate_max_steps is not None:
            kwargs["max_steps"] = mapping.accumulate_max_steps
        self._accumulators[event_name] = DialAccumulator(handler, **kwargs)

    self._handlers[event_name] = handler

cancel_accumulators

cancel_accumulators() -> None

Cancel all active dial accumulators and discard pending ticks.

Source code in src/deckui/dui/event_map.py
def cancel_accumulators(self) -> None:
    """Cancel all active dial accumulators and discard pending ticks."""
    for acc in self._accumulators.values():
        acc.cancel()

handle_encoder_turn

handle_encoder_turn(direction: int) -> AsyncHandler | None

Match an encoder turn to a semantic event.

Parameters:

Name Type Description Default
direction int

Positive for clockwise, negative for counter-clockwise.

required

Returns:

Type Description
AsyncHandler or None

The matched handler, or None if no mapping matched. For accumulated events this returns None because the tick is forwarded to the :class:DialAccumulator which schedules its own async flush.

Source code in src/deckui/dui/event_map.py
def handle_encoder_turn(self, direction: int) -> AsyncHandler | None:
    """Match an encoder turn to a semantic event.

    Parameters
    ----------
    direction
        Positive for clockwise, negative for counter-clockwise.

    Returns
    -------
    AsyncHandler or None
        The matched handler, or ``None`` if no mapping matched.
        For accumulated events this returns ``None`` because the
        tick is forwarded to the :class:`DialAccumulator` which
        schedules its own async flush.
    """
    if self._pressed:
        self._cancel_hold_timer()
        self._press_had_turn = True

        for mapping in self._by_source.get("encoder_press_turn", []):
            if self._direction_matches(mapping, direction):
                acc = self._accumulators.get(mapping.name)
                if acc is not None:
                    acc.tick(direction)
                    return None
                h = self._handlers.get(mapping.name)
                if h is not None:
                    return h
        return None

    if time.monotonic() < self._turn_suppressed_until:
        return None

    for mapping in self._by_source.get("encoder_turn", []):
        if self._direction_matches(mapping, direction):
            acc = self._accumulators.get(mapping.name)
            if acc is not None:
                acc.tick(direction)
                return None
            h = self._handlers.get(mapping.name)
            if h is not None:
                return h

    return None

handle_encoder_press

handle_encoder_press() -> list[AsyncHandler]

Match an encoder press to semantic events.

Records the press timestamp for gesture detection and starts a hold timer if an encoder_hold mapping exists.

Returns:

Type Description
list[AsyncHandler]

A list of matched handlers (may be empty).

Source code in src/deckui/dui/event_map.py
def handle_encoder_press(self) -> list[AsyncHandler]:
    """Match an encoder press to semantic events.

    Records the press timestamp for gesture detection and starts
    a hold timer if an ``encoder_hold`` mapping exists.

    Returns
    -------
    list[AsyncHandler]
        A list of matched handlers (may be empty).
    """
    self._press_time = time.monotonic()
    self._pressed = True
    self._hold_fired = False
    self._press_had_turn = False

    self._start_hold_timer("encoder_hold")

    handlers: list[AsyncHandler] = []
    for mapping in self._by_source.get("encoder_press", []):
        handler = self._handlers.get(mapping.name)
        if handler is not None:
            handlers.append(handler)
    return handlers

handle_encoder_release

handle_encoder_release() -> list[AsyncHandler]

Match an encoder release to semantic events.

Cancels any running hold timer. The simple encoder_release event always fires regardless of whether a hold fired. encoder_press_release is suppressed when a hold already fired for this press–release cycle (since the interaction was a hold, not a press-release gesture).

If at least one turn occurred during the press cycle, opens a release_turn_grace_ms window during which subsequent plain encoder_turn events are ignored.

Returns:

Type Description
list[AsyncHandler]

A list of matched handlers (may be empty).

Source code in src/deckui/dui/event_map.py
def handle_encoder_release(self) -> list[AsyncHandler]:
    """Match an encoder release to semantic events.

    Cancels any running hold timer.  The simple ``encoder_release``
    event always fires regardless of whether a hold fired.
    ``encoder_press_release`` is suppressed when a hold already
    fired for this press–release cycle (since the interaction was
    a hold, not a press-release gesture).

    If at least one turn occurred during the press cycle, opens a
    ``release_turn_grace_ms`` window during which subsequent plain
    ``encoder_turn`` events are ignored.

    Returns
    -------
    list[AsyncHandler]
        A list of matched handlers (may be empty).
    """
    self._pressed = False
    self._cancel_hold_timer()

    hold_fired = self._hold_fired
    self._hold_fired = False

    if self._press_had_turn and self._release_turn_grace_s > 0:
        self._turn_suppressed_until = (
            time.monotonic() + self._release_turn_grace_s
        )
    self._press_had_turn = False

    handlers: list[AsyncHandler] = []

    if not hold_fired and self._press_time is not None:
        elapsed_ms = (time.monotonic() - self._press_time) * 1000

        for mapping in self._by_source.get("encoder_press_release", []):
            max_ms = mapping.max_duration_ms
            if max_ms is None or elapsed_ms <= max_ms:
                handler = self._handlers.get(mapping.name)
                if handler is not None:
                    handlers.append(handler)

    self._press_time = None

    for mapping in self._by_source.get("encoder_release", []):
        handler = self._handlers.get(mapping.name)
        if handler is not None:
            handlers.append(handler)

    return handlers

handle_key_press

handle_key_press() -> list[AsyncHandler]

Match a key press to semantic events.

Records the press timestamp and starts a hold timer if a key_hold mapping exists.

Returns:

Type Description
list[AsyncHandler]

A list of matched handlers (may be empty).

Source code in src/deckui/dui/event_map.py
def handle_key_press(self) -> list[AsyncHandler]:
    """Match a key press to semantic events.

    Records the press timestamp and starts a hold timer if a
    ``key_hold`` mapping exists.

    Returns
    -------
    list[AsyncHandler]
        A list of matched handlers (may be empty).
    """
    self._press_time = time.monotonic()
    self._pressed = True
    self._hold_fired = False

    self._start_hold_timer("key_hold")

    handlers: list[AsyncHandler] = []
    for mapping in self._by_source.get("key_press", []):
        handler = self._handlers.get(mapping.name)
        if handler is not None:
            handlers.append(handler)
    return handlers

handle_key_release

handle_key_release() -> list[AsyncHandler]

Match a key release to semantic events.

Cancels any running hold timer. The simple key_release event always fires regardless of whether a hold fired. key_press_release is suppressed when a hold already fired for this press–release cycle (since the interaction was a hold, not a press-release gesture).

Returns:

Type Description
list[AsyncHandler]

A list of matched handlers (may be empty).

Source code in src/deckui/dui/event_map.py
def handle_key_release(self) -> list[AsyncHandler]:
    """Match a key release to semantic events.

    Cancels any running hold timer.  The simple ``key_release``
    event always fires regardless of whether a hold fired.
    ``key_press_release`` is suppressed when a hold already
    fired for this press–release cycle (since the interaction was
    a hold, not a press-release gesture).

    Returns
    -------
    list[AsyncHandler]
        A list of matched handlers (may be empty).
    """
    self._pressed = False
    self._cancel_hold_timer()

    hold_fired = self._hold_fired
    self._hold_fired = False

    handlers: list[AsyncHandler] = []

    if not hold_fired and self._press_time is not None:
        elapsed_ms = (time.monotonic() - self._press_time) * 1000

        for mapping in self._by_source.get("key_press_release", []):
            max_ms = mapping.max_duration_ms
            if max_ms is None or elapsed_ms <= max_ms:
                handler = self._handlers.get(mapping.name)
                if handler is not None:
                    handlers.append(handler)

    self._press_time = None

    for mapping in self._by_source.get("key_release", []):
        handler = self._handlers.get(mapping.name)
        if handler is not None:
            handlers.append(handler)

    return handlers

handle_touch

handle_touch(event_type: EventType, x: int, y: int) -> AsyncHandler | None

Match a touch event to a semantic event via regions.

Parameters:

Name Type Description Default
event_type EventType

The touch event type (TOUCH_SHORT or TOUCH_LONG).

required
x int

Touch x coordinate (relative to card origin).

required
y int

Touch y coordinate (relative to card origin).

required

Returns:

Type Description
AsyncHandler or None

The matched handler, or None if no region/mapping matched.

Source code in src/deckui/dui/event_map.py
def handle_touch(
    self, event_type: EventType, x: int, y: int
) -> AsyncHandler | None:
    """Match a touch event to a semantic event via regions.

    Parameters
    ----------
    event_type
        The touch event type (TOUCH_SHORT or TOUCH_LONG).
    x
        Touch x coordinate (relative to card origin).
    y
        Touch y coordinate (relative to card origin).

    Returns
    -------
    AsyncHandler or None
        The matched handler, or ``None`` if no region/mapping matched.
    """
    from ..runtime.events import EventType as ET_Enum

    if event_type == ET_Enum.TOUCH_SHORT:
        touch_name = "tap"
    elif event_type == ET_Enum.TOUCH_LONG:
        touch_name = "long_press"
    else:
        return None

    for region in self._regions:
        if touch_name not in region.events:
            continue
        if (
            region.x <= x < region.x + region.width
            and region.y <= y < region.y + region.height
        ):
            for mapping in self._by_source.get(touch_name, []):
                h = self._handlers.get(mapping.name)
                if h is not None:
                    return h

    for mapping in self._by_source.get(touch_name, []):
        h = self._handlers.get(mapping.name)
        if h is not None:
            return h

    return None

IconifyError

Bases: Exception

Raised when an Iconify icon cannot be resolved.

Source code in src/deckui/dui/iconify.py
class IconifyError(Exception):
    """Raised when an Iconify icon cannot be resolved."""

DuiKey

Bases: KeySlot

A physical key whose layout and events are defined by a .dui package.

DuiKey extends :class:~deckui.ui.controls.key_slot.KeySlot so that it is accepted wherever a KeySlot is expected. It replaces the icon + label rendering with SVG-based rendering from a .dui package.

Examples:

::

from deckui import DuiKey

# Resolve by name from the DUI repository
key = DuiKey("IconKey")
key.set("label", "Shutdown")

@key.on_event("activate")
async def handle():
    ...

You can also pass a pre-loaded :class:~deckui.dui.schema.PackageSpec directly::

from deckui.dui import load_package, DuiKey

spec = load_package("./PowerKey.dui")
key = DuiKey(spec)

The key index is assigned automatically when you install the key on a screen with :meth:~deckui.ui.screen.Screen.set_key.

Parameters:

Name Type Description Default
spec PackageSpec or str

A validated :class:~deckui.dui.schema.PackageSpec, or a package name (e.g. "IconKey") to resolve from the DUI repository.

required
Source code in src/deckui/dui/key.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
class DuiKey(KeySlot):
    """A physical key whose layout and events are defined by a .dui package.

    ``DuiKey`` extends :class:`~deckui.ui.controls.key_slot.KeySlot`
    so that it is accepted wherever a ``KeySlot`` is expected.  It
    replaces the icon + label rendering with SVG-based rendering from
    a ``.dui`` package.

    Examples
    --------
    ::

        from deckui import DuiKey

        # Resolve by name from the DUI repository
        key = DuiKey("IconKey")
        key.set("label", "Shutdown")

        @key.on_event("activate")
        async def handle():
            ...

    You can also pass a pre-loaded :class:`~deckui.dui.schema.PackageSpec`
    directly::

        from deckui.dui import load_package, DuiKey

        spec = load_package("./PowerKey.dui")
        key = DuiKey(spec)

    The key index is assigned automatically when you install the key
    on a screen with :meth:`~deckui.ui.screen.Screen.set_key`.

    Parameters
    ----------
    spec : PackageSpec or str
        A validated :class:`~deckui.dui.schema.PackageSpec`, or a
        package name (e.g. ``"IconKey"``) to resolve from the DUI
        repository.
    """

    def __init__(self, spec: PackageSpec | str) -> None:
        if isinstance(spec, str):
            from .repository import resolve_dui

            spec = resolve_dui(spec)
        super().__init__()
        self._spec = spec
        self._renderer = SvgRenderer(spec)
        self._events = EventMap(spec.events)
        self._dirty = True
        self._busy = False
        self._animator: SpinnerAnimator | None = None
        self._spinner_frames: SpinnerFrames | None = None
        self._push_fn: PushFn | None = None
        self._key_size: tuple[int, int] | None = None

    @property
    def spec(self) -> PackageSpec:
        """The package specification backing this key."""
        return self._spec

    @property
    def is_busy(self) -> bool:
        """Whether a busy-guarded handler is currently executing."""
        return self._busy

    @property
    def is_animating(self) -> bool:
        """Whether a spinner animation is currently running."""
        return self._animator is not None and self._animator.is_running

    def set_push_fn(self, push_fn: PushFn, key_size: tuple[int, int]) -> None:
        """Set the async function used to push animation frames to the device.

        Parameters
        ----------
        push_fn
            Async callable ``(frame_bytes) -> None``.
        key_size
            ``(width, height)`` of the device's key — used to size
            spinner frames.
        """
        self._push_fn = push_fn
        self._key_size = key_size

    def set(self, name: str, value: Any) -> DuiKey:
        """Set a binding value.  Marks the key dirty if changed.

        Parameters
        ----------
        name
            Binding name as defined in the manifest.
        value
            New value (type depends on binding kind).

        Returns
        -------
        DuiKey
            self, for method chaining.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        if self._renderer.set(name, value):
            self._dirty = True
        return self

    def set_many(self, **kwargs: Any) -> DuiKey:
        """Set multiple binding values at once.

        Returns
        -------
        DuiKey
            self, for method chaining.
        """
        if self._renderer.set_many(**kwargs):
            self._dirty = True
        return self

    def get(self, name: str) -> Any:
        """Get the current value of a binding.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        return self._renderer.get(name)

    def set_range(
        self, name: str, value: float, *, min_val: float = 0, max_val: float = 1
    ) -> DuiKey:
        """Set a range/slider binding using a domain-scale value.

        Normalises *value* from ``[min_val, max_val]`` to ``[0.0, 1.0]``
        and delegates to :meth:`set`.

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        value
            Value in domain units (e.g. 0–100 for a percentage).
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        DuiKey
            self, for method chaining.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        clamped = max(min_val, min(max_val, value))
        normalised = (clamped - min_val) / (max_val - min_val)
        return self.set(name, normalised)

    def adjust_range(
        self, name: str, delta: float, *, min_val: float = 0, max_val: float = 1
    ) -> float:
        """Adjust a range/slider binding by *delta* domain-scale units.

        Reads the current normalised value, denormalises it, adds *delta*,
        clamps, re-normalises, and calls :meth:`set`.

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        delta
            Amount to add in domain units (negative to decrease).
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        float
            The new value in domain units (clamped to
            ``[min_val, max_val]``), so callers can use it for display
            without back-computing.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        current_norm = float(self.get(name) or 0.0)
        current_domain = min_val + current_norm * (max_val - min_val)
        new_domain = max(min_val, min(max_val, current_domain + delta))
        normalised = (new_domain - min_val) / (max_val - min_val)
        self.set(name, normalised)
        return new_domain

    def get_range(
        self, name: str, *, min_val: float = 0, max_val: float = 1
    ) -> float:
        """Get a range/slider binding denormalised to domain units.

        Parameters
        ----------
        name
            Binding name.
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.

        Returns
        -------
        float
            The current value in domain units.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")
        current_norm = float(self.get(name) or 0.0)
        return min_val + current_norm * (max_val - min_val)

    def on_event(self, event_name: str) -> Callable[[AsyncHandler], AsyncHandler]:
        """Decorator to register a handler for a named semantic event.

        Examples
        --------
        ::

            @key.on_event("activate")
            async def handle():
                ...

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.

        Returns
        -------
        Callable
            A decorator that registers the handler and returns it unchanged.
        """

        def decorator(fn: AsyncHandler) -> AsyncHandler:
            self._events.on(event_name, self._wrap_handler(fn))
            return fn

        return decorator

    def bind_event(self, event_name: str, handler: AsyncHandler) -> None:
        """Imperatively register a handler for a named semantic event.

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.
        handler
            The async callable to invoke.
        """
        self._events.on(event_name, self._wrap_handler(handler))

    def bind(
        self,
        name: str,
        event: AsyncEvent,
        *,
        transform: Callable[..., Any] | None = None,
    ) -> DuiKey:
        """Subscribe to *event*; on emit, write binding *name* and refresh.

        Mirror of :meth:`DuiCard.bind`.  Subscribes to a service
        ``AsyncEvent``, optionally transforms the emitted value, calls
        :meth:`set`, and requests a refresh if the binding changed.

        Without *transform*, the binding receives the first positional
        argument from the event.  With *transform*, the callable is
        invoked with all event ``args``/``kwargs`` and its return
        value becomes the binding value.

        Parameters
        ----------
        name
            Binding name as defined in the manifest.
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to.
        transform
            Optional sync callable that maps event args to the binding
            value.  If ``None``, ``args[0]`` is used.

        Returns
        -------
        DuiKey
            self, for method chaining.
        """

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            value = (
                (args[0] if args else None)
                if transform is None
                else transform(*args, **kwargs)
            )
            self.set(name, value)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def bind_range(
        self,
        name: str,
        event: AsyncEvent,
        *,
        min_val: float = 0,
        max_val: float = 1,
        transform: Callable[..., float] | None = None,
    ) -> DuiKey:
        """Subscribe to *event*; on emit, write binding *name* via :meth:`set_range`.

        Mirror of :meth:`DuiCard.bind_range`.

        Parameters
        ----------
        name
            Binding name (must be a ``range`` or ``slider`` binding).
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to.
        min_val
            Lower bound of the domain range.
        max_val
            Upper bound of the domain range.
        transform
            Optional sync callable that maps event args to a numeric
            value in domain units.  If ``None``, ``args[0]`` is used.

        Returns
        -------
        DuiKey
            self, for method chaining.

        Raises
        ------
        ValueError
            If *min_val* equals *max_val*.
        """
        if min_val == max_val:
            raise ValueError("min_val and max_val must not be equal")

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            value = (
                float(args[0])
                if transform is None
                else float(transform(*args, **kwargs))
            )
            self.set_range(name, value, min_val=min_val, max_val=max_val)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def bind_many(
        self,
        event: AsyncEvent,
        transform: Callable[..., dict[str, Any]],
    ) -> DuiKey:
        """Subscribe to *event*; transform args into a dict and :meth:`set_many` it.

        Mirror of :meth:`DuiCard.bind_many`.

        Parameters
        ----------
        event
            The :class:`~deckui.runtime.async_event.AsyncEvent` to
            subscribe to.
        transform
            Required sync callable that maps event args to a dict of
            binding names to values.

        Returns
        -------
        DuiKey
            self, for method chaining.
        """

        async def _on_event(*args: Any, **kwargs: Any) -> None:
            values = transform(*args, **kwargs)
            self.set_many(**values)
            if self.is_dirty:
                await self.request_refresh()

        event.subscribe(_on_event)
        return self

    def forward(
        self,
        event_name: str,
        target: Callable[..., Any],
    ) -> DuiKey:
        """Register *target* as the handler for manifest event *event_name*.

        Mirror of :meth:`DuiCard.forward`.  Sugar for forwarding a DUI
        event directly to a service method or callable.

        Parameters
        ----------
        event_name
            Semantic event name from the manifest.
        target
            Async-callable forwarding target (async function or sync
            callable returning an awaitable).

        Returns
        -------
        DuiKey
            self, for method chaining.
        """

        async def _handler(*args: Any, **kwargs: Any) -> None:
            await target(*args, **kwargs)

        self._events.on(event_name, self._wrap_handler(_handler))
        return self

    def _wrap_handler(self, fn: AsyncHandler) -> AsyncHandler:
        """Wrap *fn* so any state changes trigger a refresh after it runs.

        Mirrors :meth:`DuiCard._wrap_handler`.  Without this, hold-timer
        handlers (``key_hold``) -- which fire from a detached asyncio
        task -- would mutate bindings without ever triggering a render.
        Wrapping at the registration boundary makes every handler
        self-refreshing regardless of how it's dispatched.
        """

        async def _wrapped(*args: Any, **kwargs: Any) -> None:
            await fn(*args, **kwargs)
            if self.is_dirty:
                await self.request_refresh()

        _wrapped.__wrapped__ = fn  # type: ignore[attr-defined]
        return _wrapped

    def render_image(
        self,
        key_size: tuple[int, int],
        image_format: str = "JPEG",
    ) -> bytes:
        """Render the SVG layout to image bytes for the key.

        The SVG is rasterised and scaled edge-to-edge to *key_size*
        (no margins or padding) and encoded in *image_format*.

        Parameters
        ----------
        key_size
            Target key dimensions ``(width, height)`` in pixels.
        image_format
            Image encoding format (``"JPEG"`` or ``"BMP"``).

        Returns
        -------
        bytes
            Encoded image bytes.
        """
        img = self._renderer.render()
        if img.size != key_size:
            img = img.resize(key_size, Image.Resampling.LANCZOS)
        if img.mode != "RGB":
            img = img.convert("RGB")
        return _encode_image(img, image_format)

    @property
    def has_dui_content(self) -> bool:
        """Always ``True`` — this key is backed by a .dui package."""
        return True

    async def dispatch(self, pressed: bool) -> None:
        """Dispatch a key press/release through the event map.

        All matching handlers (simple and compound) are called.
        Falls back to the base KeySlot handlers if the event map
        returns no matches.
        """
        handlers = (
            self._events.handle_key_press()
            if pressed
            else self._events.handle_key_release()
        )

        if handlers:
            for handler in handlers:
                await handler()
        else:
            await super().dispatch(pressed)

    async def start_busy(self) -> None:
        """Enter the busy state and start the spinner animation.

        While busy, the key suppresses further ``start_busy()`` calls.
        The spinner keeps running until :meth:`finish_busy` is called.

        If no spinner is configured in the manifest the busy flag is
        still set (suppressing duplicate calls) but no animation plays.
        """
        if self._busy:
            return
        self._busy = True
        await self._start_spinner()

    async def finish_busy(self) -> None:
        """Stop the spinner and exit the busy state.

        Call this from your application code when the asynchronous
        work is truly complete (e.g. after receiving a state update
        from an external system).

        If the key is not currently busy this is a no-op.
        """
        if not self._busy:
            return
        await self._stop_spinner()
        self._busy = False
        self._dirty = True

    async def _start_spinner(self) -> None:
        """Start the spinner animation if configured."""
        if (
            self._spec.spinner is None
            or self._push_fn is None
            or self._key_size is None
        ):
            return

        width, height = self._key_size
        rendered_svg = self._renderer.render_svg()
        self._spinner_frames = SpinnerFrames(
            self._spec,
            width=width,
            height=height,
            rendered_svg=rendered_svg,
        )

        self._animator = SpinnerAnimator(
            frames=self._spinner_frames.frames,
            interval_ms=self._spinner_frames.interval_ms,
            push_fn=self._push_fn,
        )
        await self._animator.start()

    async def _stop_spinner(self) -> None:
        """Stop the spinner animation."""
        if self._animator is not None:
            await self._animator.stop()
            self._animator = None

spec property

spec: PackageSpec

The package specification backing this key.

is_busy property

is_busy: bool

Whether a busy-guarded handler is currently executing.

is_animating property

is_animating: bool

Whether a spinner animation is currently running.

has_dui_content property

has_dui_content: bool

Always True — this key is backed by a .dui package.

set_push_fn

set_push_fn(push_fn: PushFn, key_size: tuple[int, int]) -> None

Set the async function used to push animation frames to the device.

Parameters:

Name Type Description Default
push_fn PushFn

Async callable (frame_bytes) -> None.

required
key_size tuple[int, int]

(width, height) of the device's key — used to size spinner frames.

required
Source code in src/deckui/dui/key.py
def set_push_fn(self, push_fn: PushFn, key_size: tuple[int, int]) -> None:
    """Set the async function used to push animation frames to the device.

    Parameters
    ----------
    push_fn
        Async callable ``(frame_bytes) -> None``.
    key_size
        ``(width, height)`` of the device's key — used to size
        spinner frames.
    """
    self._push_fn = push_fn
    self._key_size = key_size

set

set(name: str, value: Any) -> DuiKey

Set a binding value. Marks the key dirty if changed.

Parameters:

Name Type Description Default
name str

Binding name as defined in the manifest.

required
value Any

New value (type depends on binding kind).

required

Returns:

Type Description
DuiKey

self, for method chaining.

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/key.py
def set(self, name: str, value: Any) -> DuiKey:
    """Set a binding value.  Marks the key dirty if changed.

    Parameters
    ----------
    name
        Binding name as defined in the manifest.
    value
        New value (type depends on binding kind).

    Returns
    -------
    DuiKey
        self, for method chaining.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    if self._renderer.set(name, value):
        self._dirty = True
    return self

set_many

set_many(**kwargs: Any) -> DuiKey

Set multiple binding values at once.

Returns:

Type Description
DuiKey

self, for method chaining.

Source code in src/deckui/dui/key.py
def set_many(self, **kwargs: Any) -> DuiKey:
    """Set multiple binding values at once.

    Returns
    -------
    DuiKey
        self, for method chaining.
    """
    if self._renderer.set_many(**kwargs):
        self._dirty = True
    return self

get

get(name: str) -> Any

Get the current value of a binding.

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/key.py
def get(self, name: str) -> Any:
    """Get the current value of a binding.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    return self._renderer.get(name)

set_range

set_range(name: str, value: float, *, min_val: float = 0, max_val: float = 1) -> DuiKey

Set a range/slider binding using a domain-scale value.

Normalises value from [min_val, max_val] to [0.0, 1.0] and delegates to :meth:set.

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
value float

Value in domain units (e.g. 0–100 for a percentage).

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
DuiKey

self, for method chaining.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/key.py
def set_range(
    self, name: str, value: float, *, min_val: float = 0, max_val: float = 1
) -> DuiKey:
    """Set a range/slider binding using a domain-scale value.

    Normalises *value* from ``[min_val, max_val]`` to ``[0.0, 1.0]``
    and delegates to :meth:`set`.

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    value
        Value in domain units (e.g. 0–100 for a percentage).
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    DuiKey
        self, for method chaining.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    clamped = max(min_val, min(max_val, value))
    normalised = (clamped - min_val) / (max_val - min_val)
    return self.set(name, normalised)

adjust_range

adjust_range(name: str, delta: float, *, min_val: float = 0, max_val: float = 1) -> float

Adjust a range/slider binding by delta domain-scale units.

Reads the current normalised value, denormalises it, adds delta, clamps, re-normalises, and calls :meth:set.

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
delta float

Amount to add in domain units (negative to decrease).

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
float

The new value in domain units (clamped to [min_val, max_val]), so callers can use it for display without back-computing.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/key.py
def adjust_range(
    self, name: str, delta: float, *, min_val: float = 0, max_val: float = 1
) -> float:
    """Adjust a range/slider binding by *delta* domain-scale units.

    Reads the current normalised value, denormalises it, adds *delta*,
    clamps, re-normalises, and calls :meth:`set`.

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    delta
        Amount to add in domain units (negative to decrease).
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    float
        The new value in domain units (clamped to
        ``[min_val, max_val]``), so callers can use it for display
        without back-computing.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    current_norm = float(self.get(name) or 0.0)
    current_domain = min_val + current_norm * (max_val - min_val)
    new_domain = max(min_val, min(max_val, current_domain + delta))
    normalised = (new_domain - min_val) / (max_val - min_val)
    self.set(name, normalised)
    return new_domain

get_range

get_range(name: str, *, min_val: float = 0, max_val: float = 1) -> float

Get a range/slider binding denormalised to domain units.

Parameters:

Name Type Description Default
name str

Binding name.

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1

Returns:

Type Description
float

The current value in domain units.

Raises:

Type Description
KeyError

If name is not a known binding.

ValueError

If min_val equals max_val.

Source code in src/deckui/dui/key.py
def get_range(
    self, name: str, *, min_val: float = 0, max_val: float = 1
) -> float:
    """Get a range/slider binding denormalised to domain units.

    Parameters
    ----------
    name
        Binding name.
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.

    Returns
    -------
    float
        The current value in domain units.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")
    current_norm = float(self.get(name) or 0.0)
    return min_val + current_norm * (max_val - min_val)

on_event

on_event(event_name: str) -> Callable[[AsyncHandler], AsyncHandler]

Decorator to register a handler for a named semantic event.

Examples:

::

@key.on_event("activate")
async def handle():
    ...

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required

Returns:

Type Description
Callable

A decorator that registers the handler and returns it unchanged.

Source code in src/deckui/dui/key.py
def on_event(self, event_name: str) -> Callable[[AsyncHandler], AsyncHandler]:
    """Decorator to register a handler for a named semantic event.

    Examples
    --------
    ::

        @key.on_event("activate")
        async def handle():
            ...

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.

    Returns
    -------
    Callable
        A decorator that registers the handler and returns it unchanged.
    """

    def decorator(fn: AsyncHandler) -> AsyncHandler:
        self._events.on(event_name, self._wrap_handler(fn))
        return fn

    return decorator

bind_event

bind_event(event_name: str, handler: AsyncHandler) -> None

Imperatively register a handler for a named semantic event.

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required
handler AsyncHandler

The async callable to invoke.

required
Source code in src/deckui/dui/key.py
def bind_event(self, event_name: str, handler: AsyncHandler) -> None:
    """Imperatively register a handler for a named semantic event.

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.
    handler
        The async callable to invoke.
    """
    self._events.on(event_name, self._wrap_handler(handler))

bind

bind(name: str, event: AsyncEvent, *, transform: Callable[..., Any] | None = None) -> DuiKey

Subscribe to event; on emit, write binding name and refresh.

Mirror of :meth:DuiCard.bind. Subscribes to a service AsyncEvent, optionally transforms the emitted value, calls :meth:set, and requests a refresh if the binding changed.

Without transform, the binding receives the first positional argument from the event. With transform, the callable is invoked with all event args/kwargs and its return value becomes the binding value.

Parameters:

Name Type Description Default
name str

Binding name as defined in the manifest.

required
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to.

required
transform Callable[..., Any] | None

Optional sync callable that maps event args to the binding value. If None, args[0] is used.

None

Returns:

Type Description
DuiKey

self, for method chaining.

Source code in src/deckui/dui/key.py
def bind(
    self,
    name: str,
    event: AsyncEvent,
    *,
    transform: Callable[..., Any] | None = None,
) -> DuiKey:
    """Subscribe to *event*; on emit, write binding *name* and refresh.

    Mirror of :meth:`DuiCard.bind`.  Subscribes to a service
    ``AsyncEvent``, optionally transforms the emitted value, calls
    :meth:`set`, and requests a refresh if the binding changed.

    Without *transform*, the binding receives the first positional
    argument from the event.  With *transform*, the callable is
    invoked with all event ``args``/``kwargs`` and its return
    value becomes the binding value.

    Parameters
    ----------
    name
        Binding name as defined in the manifest.
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to.
    transform
        Optional sync callable that maps event args to the binding
        value.  If ``None``, ``args[0]`` is used.

    Returns
    -------
    DuiKey
        self, for method chaining.
    """

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        value = (
            (args[0] if args else None)
            if transform is None
            else transform(*args, **kwargs)
        )
        self.set(name, value)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

bind_range

bind_range(name: str, event: AsyncEvent, *, min_val: float = 0, max_val: float = 1, transform: Callable[..., float] | None = None) -> DuiKey

Subscribe to event; on emit, write binding name via :meth:set_range.

Mirror of :meth:DuiCard.bind_range.

Parameters:

Name Type Description Default
name str

Binding name (must be a range or slider binding).

required
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to.

required
min_val float

Lower bound of the domain range.

0
max_val float

Upper bound of the domain range.

1
transform Callable[..., float] | None

Optional sync callable that maps event args to a numeric value in domain units. If None, args[0] is used.

None

Returns:

Type Description
DuiKey

self, for method chaining.

Raises:

Type Description
ValueError

If min_val equals max_val.

Source code in src/deckui/dui/key.py
def bind_range(
    self,
    name: str,
    event: AsyncEvent,
    *,
    min_val: float = 0,
    max_val: float = 1,
    transform: Callable[..., float] | None = None,
) -> DuiKey:
    """Subscribe to *event*; on emit, write binding *name* via :meth:`set_range`.

    Mirror of :meth:`DuiCard.bind_range`.

    Parameters
    ----------
    name
        Binding name (must be a ``range`` or ``slider`` binding).
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to.
    min_val
        Lower bound of the domain range.
    max_val
        Upper bound of the domain range.
    transform
        Optional sync callable that maps event args to a numeric
        value in domain units.  If ``None``, ``args[0]`` is used.

    Returns
    -------
    DuiKey
        self, for method chaining.

    Raises
    ------
    ValueError
        If *min_val* equals *max_val*.
    """
    if min_val == max_val:
        raise ValueError("min_val and max_val must not be equal")

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        value = (
            float(args[0])
            if transform is None
            else float(transform(*args, **kwargs))
        )
        self.set_range(name, value, min_val=min_val, max_val=max_val)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

bind_many

bind_many(event: AsyncEvent, transform: Callable[..., dict[str, Any]]) -> DuiKey

Subscribe to event; transform args into a dict and :meth:set_many it.

Mirror of :meth:DuiCard.bind_many.

Parameters:

Name Type Description Default
event AsyncEvent

The :class:~deckui.runtime.async_event.AsyncEvent to subscribe to.

required
transform Callable[..., dict[str, Any]]

Required sync callable that maps event args to a dict of binding names to values.

required

Returns:

Type Description
DuiKey

self, for method chaining.

Source code in src/deckui/dui/key.py
def bind_many(
    self,
    event: AsyncEvent,
    transform: Callable[..., dict[str, Any]],
) -> DuiKey:
    """Subscribe to *event*; transform args into a dict and :meth:`set_many` it.

    Mirror of :meth:`DuiCard.bind_many`.

    Parameters
    ----------
    event
        The :class:`~deckui.runtime.async_event.AsyncEvent` to
        subscribe to.
    transform
        Required sync callable that maps event args to a dict of
        binding names to values.

    Returns
    -------
    DuiKey
        self, for method chaining.
    """

    async def _on_event(*args: Any, **kwargs: Any) -> None:
        values = transform(*args, **kwargs)
        self.set_many(**values)
        if self.is_dirty:
            await self.request_refresh()

    event.subscribe(_on_event)
    return self

forward

forward(event_name: str, target: Callable[..., Any]) -> DuiKey

Register target as the handler for manifest event event_name.

Mirror of :meth:DuiCard.forward. Sugar for forwarding a DUI event directly to a service method or callable.

Parameters:

Name Type Description Default
event_name str

Semantic event name from the manifest.

required
target Callable[..., Any]

Async-callable forwarding target (async function or sync callable returning an awaitable).

required

Returns:

Type Description
DuiKey

self, for method chaining.

Source code in src/deckui/dui/key.py
def forward(
    self,
    event_name: str,
    target: Callable[..., Any],
) -> DuiKey:
    """Register *target* as the handler for manifest event *event_name*.

    Mirror of :meth:`DuiCard.forward`.  Sugar for forwarding a DUI
    event directly to a service method or callable.

    Parameters
    ----------
    event_name
        Semantic event name from the manifest.
    target
        Async-callable forwarding target (async function or sync
        callable returning an awaitable).

    Returns
    -------
    DuiKey
        self, for method chaining.
    """

    async def _handler(*args: Any, **kwargs: Any) -> None:
        await target(*args, **kwargs)

    self._events.on(event_name, self._wrap_handler(_handler))
    return self

render_image

render_image(key_size: tuple[int, int], image_format: str = 'JPEG') -> bytes

Render the SVG layout to image bytes for the key.

The SVG is rasterised and scaled edge-to-edge to key_size (no margins or padding) and encoded in image_format.

Parameters:

Name Type Description Default
key_size tuple[int, int]

Target key dimensions (width, height) in pixels.

required
image_format str

Image encoding format ("JPEG" or "BMP").

'JPEG'

Returns:

Type Description
bytes

Encoded image bytes.

Source code in src/deckui/dui/key.py
def render_image(
    self,
    key_size: tuple[int, int],
    image_format: str = "JPEG",
) -> bytes:
    """Render the SVG layout to image bytes for the key.

    The SVG is rasterised and scaled edge-to-edge to *key_size*
    (no margins or padding) and encoded in *image_format*.

    Parameters
    ----------
    key_size
        Target key dimensions ``(width, height)`` in pixels.
    image_format
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded image bytes.
    """
    img = self._renderer.render()
    if img.size != key_size:
        img = img.resize(key_size, Image.Resampling.LANCZOS)
    if img.mode != "RGB":
        img = img.convert("RGB")
    return _encode_image(img, image_format)

dispatch async

dispatch(pressed: bool) -> None

Dispatch a key press/release through the event map.

All matching handlers (simple and compound) are called. Falls back to the base KeySlot handlers if the event map returns no matches.

Source code in src/deckui/dui/key.py
async def dispatch(self, pressed: bool) -> None:
    """Dispatch a key press/release through the event map.

    All matching handlers (simple and compound) are called.
    Falls back to the base KeySlot handlers if the event map
    returns no matches.
    """
    handlers = (
        self._events.handle_key_press()
        if pressed
        else self._events.handle_key_release()
    )

    if handlers:
        for handler in handlers:
            await handler()
    else:
        await super().dispatch(pressed)

start_busy async

start_busy() -> None

Enter the busy state and start the spinner animation.

While busy, the key suppresses further start_busy() calls. The spinner keeps running until :meth:finish_busy is called.

If no spinner is configured in the manifest the busy flag is still set (suppressing duplicate calls) but no animation plays.

Source code in src/deckui/dui/key.py
async def start_busy(self) -> None:
    """Enter the busy state and start the spinner animation.

    While busy, the key suppresses further ``start_busy()`` calls.
    The spinner keeps running until :meth:`finish_busy` is called.

    If no spinner is configured in the manifest the busy flag is
    still set (suppressing duplicate calls) but no animation plays.
    """
    if self._busy:
        return
    self._busy = True
    await self._start_spinner()

finish_busy async

finish_busy() -> None

Stop the spinner and exit the busy state.

Call this from your application code when the asynchronous work is truly complete (e.g. after receiving a state update from an external system).

If the key is not currently busy this is a no-op.

Source code in src/deckui/dui/key.py
async def finish_busy(self) -> None:
    """Stop the spinner and exit the busy state.

    Call this from your application code when the asynchronous
    work is truly complete (e.g. after receiving a state update
    from an external system).

    If the key is not currently busy this is a no-op.
    """
    if not self._busy:
        return
    await self._stop_spinner()
    self._busy = False
    self._dirty = True

PackageError

Bases: Exception

Raised when a .dui package is invalid or cannot be loaded.

Source code in src/deckui/dui/loader.py
class PackageError(Exception):
    """Raised when a .dui package is invalid or cannot be loaded."""

DuiRepository

Registry of .dui package search paths with in-memory caching.

Directories are searched in priority order — the most recently added path wins when two directories contain a package with the same name. The bundled packages directory is always present as the lowest-priority source and cannot be removed.

Parameters:

Name Type Description Default
include_bundled bool

Whether to include the bundled packages directory. Set to False only in tests that need a completely empty repo.

True

Examples:

::

repo = DuiRepository()
repo.add_path("/home/user/my-packages")
spec = repo.resolve("IconKey")
Source code in src/deckui/dui/repository.py
class DuiRepository:
    """Registry of .dui package search paths with in-memory caching.

    Directories are searched in *priority order* — the most recently
    added path wins when two directories contain a package with the
    same name.  The bundled packages directory is always present as
    the lowest-priority source and cannot be removed.

    Parameters
    ----------
    include_bundled : bool, default=True
        Whether to include the bundled packages directory.  Set to
        ``False`` only in tests that need a completely empty repo.

    Examples
    --------
    ::

        repo = DuiRepository()
        repo.add_path("/home/user/my-packages")
        spec = repo.resolve("IconKey")
    """

    def __init__(self, *, include_bundled: bool = True) -> None:
        self._paths: list[Path] = []
        self._cache: dict[str, PackageSpec] = {}
        self._include_bundled = include_bundled

    # ── Path management ───────────────────────────────────────────────

    def add_path(self, path: str | Path) -> None:
        """Register a directory as a DUI package source.

        The directory is inserted at the *highest* priority position.
        If it is already registered it is moved to the top.  The
        in-memory cache is cleared so that subsequent :meth:`resolve`
        calls pick up any overrides.

        Parameters
        ----------
        path : str or Path
            Directory containing ``.dui`` package subdirectories.

        Raises
        ------
        PackageError
            If *path* does not exist or is not a directory.
        """
        resolved = Path(path).expanduser().resolve()
        if not resolved.is_dir():
            raise PackageError(f"DUI path is not a directory: {resolved}")

        # Move to top if already present, otherwise append
        if resolved in self._paths:
            self._paths.remove(resolved)
        self._paths.append(resolved)
        self._cache.clear()
        logger.info("Added DUI path: %s (priority %d)", resolved, len(self._paths))

    def remove_path(self, path: str | Path) -> None:
        """Unregister a directory.  Clears the cache.

        Parameters
        ----------
        path : str or Path
            Previously registered directory.

        Raises
        ------
        ValueError
            If *path* is not currently registered.
        """
        resolved = Path(path).expanduser().resolve()
        try:
            self._paths.remove(resolved)
        except ValueError:
            raise ValueError(f"Path not registered: {resolved}") from None
        self._cache.clear()
        logger.info("Removed DUI path: %s", resolved)

    def list_paths(self) -> list[Path]:
        """Return registered search paths in priority order (highest first).

        The bundled directory is included at the end when
        ``include_bundled`` is ``True``.

        Returns
        -------
        list[Path]
            Ordered list of directories.
        """
        # Reverse so that last-added (highest priority) comes first
        result = list(reversed(self._paths))
        if self._include_bundled and _BUNDLED_DIR.is_dir():
            result.append(_BUNDLED_DIR)
        return result

    # ── Resolution & caching ──────────────────────────────────────────

    def resolve(self, name: str) -> PackageSpec:
        """Look up a package by name and return a cached spec.

        Search order: user paths (most recently added first), then
        bundled packages.  The result is cached so that repeated calls
        with the same *name* return the identical :class:`PackageSpec`
        object without re-reading the filesystem.

        Parameters
        ----------
        name : str
            Package name **without** the ``.dui`` suffix
            (e.g. ``"IconKey"``, not ``"IconKey.dui"``).

        Returns
        -------
        PackageSpec
            A validated, frozen package specification.

        Raises
        ------
        PackageError
            If no package with that name exists in any registered path.
        """
        cached = self._cache.get(name)
        if cached is not None:
            return cached

        pkg_dir = self._find(name)
        if pkg_dir is None:
            paths_desc = ", ".join(str(p) for p in self.list_paths()) or "(none)"
            raise PackageError(
                f"DUI package '{name}' not found. "
                f"Searched paths: {paths_desc}"
            )

        spec = load_package(pkg_dir)
        self._cache[name] = spec
        logger.debug("Resolved DUI package '%s' from %s", name, pkg_dir)
        return spec

    def _find(self, name: str) -> Path | None:
        """Locate the directory for *name* across all search paths.

        Parameters
        ----------
        name : str
            Package name without the ``.dui`` suffix.

        Returns
        -------
        Path or None
            Absolute path to the ``.dui`` directory, or ``None`` if
            not found in any registered path.
        """
        dir_name = f"{name}.dui"

        # User paths in priority order (last-added first)
        for base in reversed(self._paths):
            candidate = base / dir_name
            if candidate.is_dir():
                return candidate

        # Bundled fallback
        if self._include_bundled and _BUNDLED_DIR.is_dir():
            candidate = _BUNDLED_DIR / dir_name
            if candidate.is_dir():
                return candidate

        return None

    # ── Cache management ──────────────────────────────────────────────

    def clear_cache(self) -> None:
        """Drop all cached :class:`PackageSpec` objects.

        Subsequent :meth:`resolve` calls will re-read packages from
        disk.
        """
        count = len(self._cache)
        self._cache.clear()
        if count:
            logger.debug("Cleared DUI package cache (%d entries)", count)

    def invalidate(self, name: str) -> None:
        """Remove a single package from the cache.

        Use this after editing a ``.dui`` package on disk to force
        :meth:`resolve` to reload it.

        Parameters
        ----------
        name : str
            Package name to invalidate.
        """
        removed = self._cache.pop(name, None)
        if removed is not None:
            logger.debug("Invalidated cached DUI package '%s'", name)

    # ── Introspection ─────────────────────────────────────────────────

    def list_packages(self) -> list[str]:
        """List all package names visible across all search paths.

        Packages are returned in alphabetical order.  If the same name
        exists in multiple paths only one entry is returned.

        Returns
        -------
        list[str]
            Sorted package names (without the ``.dui`` suffix).
        """
        names: set[str] = set()
        for base in self.list_paths():
            if base.is_dir():
                for entry in base.iterdir():
                    if entry.is_dir() and entry.suffix == ".dui":
                        names.add(entry.stem)
        return sorted(names)

    def __repr__(self) -> str:
        n_paths = len(self._paths) + (1 if self._include_bundled else 0)
        return (
            f"DuiRepository(paths={n_paths}, "
            f"cached={len(self._cache)}, "
            f"bundled={self._include_bundled})"
        )

add_path

add_path(path: str | Path) -> None

Register a directory as a DUI package source.

The directory is inserted at the highest priority position. If it is already registered it is moved to the top. The in-memory cache is cleared so that subsequent :meth:resolve calls pick up any overrides.

Parameters:

Name Type Description Default
path str or Path

Directory containing .dui package subdirectories.

required

Raises:

Type Description
PackageError

If path does not exist or is not a directory.

Source code in src/deckui/dui/repository.py
def add_path(self, path: str | Path) -> None:
    """Register a directory as a DUI package source.

    The directory is inserted at the *highest* priority position.
    If it is already registered it is moved to the top.  The
    in-memory cache is cleared so that subsequent :meth:`resolve`
    calls pick up any overrides.

    Parameters
    ----------
    path : str or Path
        Directory containing ``.dui`` package subdirectories.

    Raises
    ------
    PackageError
        If *path* does not exist or is not a directory.
    """
    resolved = Path(path).expanduser().resolve()
    if not resolved.is_dir():
        raise PackageError(f"DUI path is not a directory: {resolved}")

    # Move to top if already present, otherwise append
    if resolved in self._paths:
        self._paths.remove(resolved)
    self._paths.append(resolved)
    self._cache.clear()
    logger.info("Added DUI path: %s (priority %d)", resolved, len(self._paths))

remove_path

remove_path(path: str | Path) -> None

Unregister a directory. Clears the cache.

Parameters:

Name Type Description Default
path str or Path

Previously registered directory.

required

Raises:

Type Description
ValueError

If path is not currently registered.

Source code in src/deckui/dui/repository.py
def remove_path(self, path: str | Path) -> None:
    """Unregister a directory.  Clears the cache.

    Parameters
    ----------
    path : str or Path
        Previously registered directory.

    Raises
    ------
    ValueError
        If *path* is not currently registered.
    """
    resolved = Path(path).expanduser().resolve()
    try:
        self._paths.remove(resolved)
    except ValueError:
        raise ValueError(f"Path not registered: {resolved}") from None
    self._cache.clear()
    logger.info("Removed DUI path: %s", resolved)

list_paths

list_paths() -> list[Path]

Return registered search paths in priority order (highest first).

The bundled directory is included at the end when include_bundled is True.

Returns:

Type Description
list[Path]

Ordered list of directories.

Source code in src/deckui/dui/repository.py
def list_paths(self) -> list[Path]:
    """Return registered search paths in priority order (highest first).

    The bundled directory is included at the end when
    ``include_bundled`` is ``True``.

    Returns
    -------
    list[Path]
        Ordered list of directories.
    """
    # Reverse so that last-added (highest priority) comes first
    result = list(reversed(self._paths))
    if self._include_bundled and _BUNDLED_DIR.is_dir():
        result.append(_BUNDLED_DIR)
    return result

resolve

resolve(name: str) -> PackageSpec

Look up a package by name and return a cached spec.

Search order: user paths (most recently added first), then bundled packages. The result is cached so that repeated calls with the same name return the identical :class:PackageSpec object without re-reading the filesystem.

Parameters:

Name Type Description Default
name str

Package name without the .dui suffix (e.g. "IconKey", not "IconKey.dui").

required

Returns:

Type Description
PackageSpec

A validated, frozen package specification.

Raises:

Type Description
PackageError

If no package with that name exists in any registered path.

Source code in src/deckui/dui/repository.py
def resolve(self, name: str) -> PackageSpec:
    """Look up a package by name and return a cached spec.

    Search order: user paths (most recently added first), then
    bundled packages.  The result is cached so that repeated calls
    with the same *name* return the identical :class:`PackageSpec`
    object without re-reading the filesystem.

    Parameters
    ----------
    name : str
        Package name **without** the ``.dui`` suffix
        (e.g. ``"IconKey"``, not ``"IconKey.dui"``).

    Returns
    -------
    PackageSpec
        A validated, frozen package specification.

    Raises
    ------
    PackageError
        If no package with that name exists in any registered path.
    """
    cached = self._cache.get(name)
    if cached is not None:
        return cached

    pkg_dir = self._find(name)
    if pkg_dir is None:
        paths_desc = ", ".join(str(p) for p in self.list_paths()) or "(none)"
        raise PackageError(
            f"DUI package '{name}' not found. "
            f"Searched paths: {paths_desc}"
        )

    spec = load_package(pkg_dir)
    self._cache[name] = spec
    logger.debug("Resolved DUI package '%s' from %s", name, pkg_dir)
    return spec

clear_cache

clear_cache() -> None

Drop all cached :class:PackageSpec objects.

Subsequent :meth:resolve calls will re-read packages from disk.

Source code in src/deckui/dui/repository.py
def clear_cache(self) -> None:
    """Drop all cached :class:`PackageSpec` objects.

    Subsequent :meth:`resolve` calls will re-read packages from
    disk.
    """
    count = len(self._cache)
    self._cache.clear()
    if count:
        logger.debug("Cleared DUI package cache (%d entries)", count)

invalidate

invalidate(name: str) -> None

Remove a single package from the cache.

Use this after editing a .dui package on disk to force :meth:resolve to reload it.

Parameters:

Name Type Description Default
name str

Package name to invalidate.

required
Source code in src/deckui/dui/repository.py
def invalidate(self, name: str) -> None:
    """Remove a single package from the cache.

    Use this after editing a ``.dui`` package on disk to force
    :meth:`resolve` to reload it.

    Parameters
    ----------
    name : str
        Package name to invalidate.
    """
    removed = self._cache.pop(name, None)
    if removed is not None:
        logger.debug("Invalidated cached DUI package '%s'", name)

list_packages

list_packages() -> list[str]

List all package names visible across all search paths.

Packages are returned in alphabetical order. If the same name exists in multiple paths only one entry is returned.

Returns:

Type Description
list[str]

Sorted package names (without the .dui suffix).

Source code in src/deckui/dui/repository.py
def list_packages(self) -> list[str]:
    """List all package names visible across all search paths.

    Packages are returned in alphabetical order.  If the same name
    exists in multiple paths only one entry is returned.

    Returns
    -------
    list[str]
        Sorted package names (without the ``.dui`` suffix).
    """
    names: set[str] = set()
    for base in self.list_paths():
        if base.is_dir():
            for entry in base.iterdir():
                if entry.is_dir() and entry.suffix == ".dui":
                    names.add(entry.stem)
    return sorted(names)

BindingType

Bases: Enum

Supported binding types for SVG node manipulation.

Source code in src/deckui/dui/schema.py
class BindingType(Enum):
    """Supported binding types for SVG node manipulation."""

    TEXT = "text"
    IMAGE = "image"
    VISIBILITY = "visibility"
    COLOR = "color"
    RANGE = "range"
    SLIDER = "slider"
    TOGGLE = "toggle"
    ICONIFY = "iconify"
    LIST = "list"
    TRANSFORM = "transform"

ColorBinding dataclass

Bind a colour value to an SVG element's fill, stroke, or color.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class ColorBinding:
    """Bind a colour value to an SVG element's fill, stroke, or color."""

    node: str
    attribute: str = "fill"
    default: str = "#ffffff"

    def __post_init__(self) -> None:
        if self.attribute not in _COLOR_ATTRIBUTES:
            msg = (
                f"Invalid color attribute {self.attribute!r}; "
                f"must be one of {sorted(_COLOR_ATTRIBUTES)}"
            )
            raise ValueError(msg)

EventMapping dataclass

Map a physical input to a named semantic event.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class EventMapping:
    """Map a physical input to a named semantic event."""

    name: str
    source: str
    direction: str | None = None
    max_duration_ms: int | None = None
    hold_ms: int | None = None
    accumulate: bool = False
    accumulate_delay: float | None = None
    accumulate_max_steps: int | None = None

IconifyBinding dataclass

Load an Iconify icon by name and embed it into an SVG <g> element.

The icon name follows the Iconify convention <prefix>:<name> (for example line-md:home). Icons are fetched from https://api.iconify.design on first use and cached in-process.

The resolved icon SVG is inserted as children of the target <g> node, scaled to a size × size square. Setting the binding value to None or an empty string removes any previously embedded icon from the node.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class IconifyBinding:
    """Load an Iconify icon by name and embed it into an SVG ``<g>`` element.

    The icon name follows the Iconify convention ``<prefix>:<name>`` (for
    example ``line-md:home``).  Icons are fetched from
    ``https://api.iconify.design`` on first use and cached in-process.

    The resolved icon SVG is inserted as children of the target ``<g>``
    node, scaled to a ``size`` × ``size`` square.  Setting the binding
    value to ``None`` or an empty string removes any previously embedded
    icon from the node.
    """

    node: str
    size: int
    default: str = ""

ImageBinding dataclass

Bind a PIL Image to an SVG element's href.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class ImageBinding:
    """Bind a PIL Image to an <image> SVG element's href."""

    node: str
    fit: ImageFit = ImageFit.COVER
    placeholder_node: str | None = None

ImageFit

Bases: Enum

Image scaling strategy within the target node dimensions.

Source code in src/deckui/dui/schema.py
class ImageFit(Enum):
    """Image scaling strategy within the target node dimensions."""

    COVER = "cover"
    CONTAIN = "contain"
    FILL = "fill"

OverflowMode

Bases: Enum

Text overflow handling strategy.

Source code in src/deckui/dui/schema.py
class OverflowMode(Enum):
    """Text overflow handling strategy."""

    ELLIPSIS = "ellipsis"
    CLIP = "clip"

PackageSpec dataclass

Fully validated, immutable representation of a loaded .dui package.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class PackageSpec:
    """Fully validated, immutable representation of a loaded .dui package."""

    name: str
    type: PackageType
    version: int
    svg_source: str
    bindings: dict[str, Binding] = field(default_factory=dict)
    events: tuple[EventMapping, ...] = ()
    regions: tuple[Region, ...] = ()
    assets: dict[str, bytes] = field(default_factory=dict)
    spinner: SpinnerSpec | None = None
    description: str | None = None
    author: str | None = None
    license: str | None = None
    tags: tuple[str, ...] = ()
    category: str | None = None
    url: str | None = None
    icon: str | None = None
    min_deckui: str | None = None
    device: tuple[str, ...] = ()

PackageType

Bases: Enum

Hardware target for a .dui package.

Source code in src/deckui/dui/schema.py
class PackageType(Enum):
    """Hardware target for a .dui package."""

    TOUCH_STRIP_CARD = "TouchStripCard"
    KEY = "Key"

RangeBinding dataclass

Scale an SVG element's width or height proportional to a 0–1 value.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class RangeBinding:
    """Scale an SVG element's width or height proportional to a 0–1 value."""

    node: str
    default: float = 0.0
    direction: RangeDirection = RangeDirection.HORIZONTAL

RangeDirection

Bases: Enum

Axis along which a range binding scales an SVG element.

Source code in src/deckui/dui/schema.py
class RangeDirection(Enum):
    """Axis along which a range binding scales an SVG element."""

    HORIZONTAL = "horizontal"
    VERTICAL = "vertical"

Region dataclass

A touchscreen hit-test region for touch events.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class Region:
    """A touchscreen hit-test region for touch events."""

    name: str
    x: int
    y: int
    width: int
    height: int
    events: tuple[str, ...] = ()

RotateTransform dataclass

Rotate an SVG element between two angles proportional to a 0–1 value.

The element is rotated by interpolating linearly between from_angle and to_angle based on the binding's current value.

Parameters:

Name Type Description Default
from_angle float

Rotation angle (degrees) when the value is 0.0.

0.0
to_angle float

Rotation angle (degrees) when the value is 1.0.

360.0
origin str

Rotation origin. "center" resolves to the element's bounding box center at render time. An explicit "x y" string sets a fixed origin in SVG user-space coordinates.

'center'
Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class RotateTransform:
    """Rotate an SVG element between two angles proportional to a 0–1 value.

    The element is rotated by interpolating linearly between *from_angle*
    and *to_angle* based on the binding's current value.

    Parameters
    ----------
    from_angle : float
        Rotation angle (degrees) when the value is 0.0.
    to_angle : float
        Rotation angle (degrees) when the value is 1.0.
    origin : str
        Rotation origin.  ``"center"`` resolves to the element's bounding
        box center at render time.  An explicit ``"x y"`` string sets a
        fixed origin in SVG user-space coordinates.
    """

    from_angle: float = 0.0
    to_angle: float = 360.0
    origin: str = "center"

SliderBinding dataclass

Translate an SVG element between two positions proportional to a 0–1 value.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class SliderBinding:
    """Translate an SVG element between two positions proportional to a 0–1 value."""

    node: str
    default: float = 0.0
    direction: RangeDirection = RangeDirection.HORIZONTAL
    min_pos: float = 0.0
    max_pos: float = 0.0

SpinnerSpec dataclass

Configuration for a spinner animation.

The spinner is started and stopped explicitly by the application via :meth:~deckui.dui.card.DuiCard.start_busy / :meth:~deckui.dui.card.DuiCard.finish_busy (and the equivalent methods on :class:~deckui.dui.key.DuiKey). It provides visual feedback by cycling pre-rendered animation frames on the key or card panel.

Parameters:

Name Type Description Default
type SpinnerType

Animation strategy: rotation (rotate an SVG node), pulse (fade opacity), or custom (user-provided frames).

required
node str | None

SVG element ID to animate (required for rotation and pulse; ignored for custom).

None
frames int

Number of frames per animation cycle.

DEFAULT_SPINNER_FRAMES
interval_ms int

Milliseconds between frames.

DEFAULT_SPINNER_INTERVAL_MS
background_node str | None

Optional SVG element ID shown behind the spinner during busy state. The node is made visible when the spinner is active and hidden at rest, but it is not animated (no rotation, pulse, or opacity changes are applied to it). Ignored for custom type spinners.

None
Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class SpinnerSpec:
    """Configuration for a spinner animation.

    The spinner is started and stopped explicitly by the application
    via :meth:`~deckui.dui.card.DuiCard.start_busy` /
    :meth:`~deckui.dui.card.DuiCard.finish_busy` (and the equivalent
    methods on :class:`~deckui.dui.key.DuiKey`).  It provides visual
    feedback by cycling pre-rendered animation frames on the key or
    card panel.

    Parameters
    ----------
    type
        Animation strategy: ``rotation`` (rotate an SVG node),
        ``pulse`` (fade opacity), or ``custom`` (user-provided frames).
    node
        SVG element ID to animate (required for ``rotation`` and
        ``pulse``; ignored for ``custom``).
    frames
        Number of frames per animation cycle.
    interval_ms
        Milliseconds between frames.
    background_node
        Optional SVG element ID shown behind the spinner during busy
        state.  The node is made visible when the spinner is active and
        hidden at rest, but it is **not** animated (no rotation, pulse,
        or opacity changes are applied to it).  Ignored for ``custom``
        type spinners.
    """

    type: SpinnerType
    node: str | None = None
    frames: int = DEFAULT_SPINNER_FRAMES
    interval_ms: int = DEFAULT_SPINNER_INTERVAL_MS
    background_node: str | None = None

SpinnerType

Bases: Enum

Animation strategy for the busy spinner.

Source code in src/deckui/dui/schema.py
class SpinnerType(Enum):
    """Animation strategy for the busy spinner."""

    ROTATION = "rotation"
    PULSE = "pulse"
    CUSTOM = "custom"

TextBinding dataclass

Bind a value to a <text> SVG element's content.

When wrap is True the renderer word-wraps the text into multiple <tspan> lines that each fit within max_width pixels. Font metrics are auto-detected from the SVG <text> element. If the wrapped text exceeds max_height, the last visible line is truncated according to overflow.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class TextBinding:
    """Bind a value to a ``<text>`` SVG element's content.

    When *wrap* is ``True`` the renderer word-wraps the text into
    multiple ``<tspan>`` lines that each fit within *max_width* pixels.
    Font metrics are auto-detected from the SVG ``<text>`` element.
    If the wrapped text exceeds *max_height*, the last visible line is
    truncated according to *overflow*.
    """

    node: str
    default: str = ""
    max_width: int | None = None
    overflow: OverflowMode = OverflowMode.ELLIPSIS
    wrap: bool = False
    max_height: int | None = None
    line_height: float | None = None

ToggleBinding dataclass

Switch between two SVG elements based on a boolean value.

When the value is truthy, node_on is visible and node_off is hidden. When falsy, the opposite applies.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class ToggleBinding:
    """Switch between two SVG elements based on a boolean value.

    When the value is truthy, ``node_on`` is visible and ``node_off``
    is hidden.  When falsy, the opposite applies.
    """

    node_on: str
    node_off: str
    default: bool = False

TransformBinding dataclass

Apply one or more SVG transforms to a node proportional to a 0–1 value.

Each entry in transforms produces an SVG transform attribute fragment; multiple transforms are composed (space-separated) in order.

Parameters:

Name Type Description Default
node str

ID of the SVG element to transform.

required
default float

Initial normalised value (0.0–1.0).

0.0
transforms tuple[TransformSpec, ...]

Ordered sequence of transform specifications applied to the node.

()
Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class TransformBinding:
    """Apply one or more SVG transforms to a node proportional to a 0–1 value.

    Each entry in *transforms* produces an SVG ``transform`` attribute
    fragment; multiple transforms are composed (space-separated) in order.

    Parameters
    ----------
    node : str
        ID of the SVG element to transform.
    default : float
        Initial normalised value (0.0–1.0).
    transforms : tuple[TransformSpec, ...]
        Ordered sequence of transform specifications applied to the node.
    """

    node: str
    default: float = 0.0
    transforms: tuple[TransformSpec, ...] = ()

TransformKind

Bases: Enum

Supported transform operations for SVG nodes.

Source code in src/deckui/dui/schema.py
class TransformKind(Enum):
    """Supported transform operations for SVG nodes."""

    ROTATE = "rotate"

VisibilityBinding dataclass

Toggle the display attribute of an SVG element.

Source code in src/deckui/dui/schema.py
@dataclass(frozen=True, slots=True)
class VisibilityBinding:
    """Toggle the display attribute of an SVG element."""

    node: str
    default: bool = True

SpinnerFrames

Pre-renders spinner animation frames from an SVG template.

Frames are generated lazily on first access and cached for the lifetime of the instance.

Parameters:

Name Type Description Default
spec PackageSpec

The package specification containing the SVG source and spinner config.

required
width int

Target image width in pixels.

required
height int

Target image height in pixels.

required
image_format str

Encoding format ("JPEG" or "BMP").

'JPEG'
Source code in src/deckui/dui/spinner.py
class SpinnerFrames:
    """Pre-renders spinner animation frames from an SVG template.

    Frames are generated lazily on first access and cached for the
    lifetime of the instance.

    Parameters
    ----------
    spec
        The package specification containing the SVG source and spinner config.
    width
        Target image width in pixels.
    height
        Target image height in pixels.
    image_format
        Encoding format (``"JPEG"`` or ``"BMP"``).
    """

    def __init__(
        self,
        spec: PackageSpec,
        width: int,
        height: int,
        image_format: str = "JPEG",
        rendered_svg: str | None = None,
    ) -> None:
        if spec.spinner is None:
            raise ValueError("PackageSpec has no spinner configuration")
        self._spec = spec
        self._spinner: SpinnerSpec = spec.spinner
        self._width = width
        self._height = height
        self._image_format = image_format
        self._rendered_svg = rendered_svg
        self._cached_frames: list[bytes] | None = None

    @property
    def frame_count(self) -> int:
        """Number of frames in the animation cycle."""
        return self._spinner.frames

    @property
    def interval_ms(self) -> int:
        """Milliseconds between frames."""
        return self._spinner.interval_ms

    @property
    def frames(self) -> list[bytes]:
        """Encoded animation frames, generated on first access."""
        if self._cached_frames is None:
            self._cached_frames = self._generate()
        return self._cached_frames

    def _generate(self) -> list[bytes]:
        """Generate all animation frames."""
        from .schema import SpinnerType

        if self._spinner.type == SpinnerType.ROTATION:
            return self._generate_rotation()
        if self._spinner.type == SpinnerType.PULSE:
            return self._generate_pulse()
        return self._generate_custom()

    def _generate_rotation(self) -> list[bytes]:
        """Generate frames by rotating the spinner node.

        Returns
        -------
        list[bytes]
            Encoded image frames, one per rotation step.
        """
        svg_source = self._rendered_svg or self._spec.svg_source
        base_root = ET.fromstring(svg_source)  # noqa: S314
        node = self._spinner.node
        assert node is not None

        # Find the element to determine its centre of rotation
        elem = _find_element_by_id(base_root, node)
        if elem is None:
            logger.warning("Spinner node '%s' not found; returning blank frames", node)
            return self._blank_frames()

        cx, cy = self._element_centre(elem)
        step = 360.0 / self._spinner.frames

        frames: list[bytes] = []
        for i in range(self._spinner.frames):
            root = copy.deepcopy(base_root)
            el = _find_element_by_id(root, node)
            if el is not None:
                # Make the spinner node visible
                el.attrib.pop("display", None)
                angle = step * i
                existing = el.get("transform", "")
                rotation = f"rotate({angle:.1f},{cx:.1f},{cy:.1f})"
                el.set("transform", f"{existing} {rotation}".strip())

            self._show_background_node(root)
            frames.append(self._rasterise(root))
        return frames

    def _generate_pulse(self) -> list[bytes]:
        """Generate frames by pulsing opacity on the spinner node.

        Returns
        -------
        list[bytes]
            Encoded image frames with a triangle-wave opacity cycle.
        """
        svg_source = self._rendered_svg or self._spec.svg_source
        base_root = ET.fromstring(svg_source)  # noqa: S314
        node = self._spinner.node
        assert node is not None

        n = self._spinner.frames
        frames: list[bytes] = []
        for i in range(n):
            root = copy.deepcopy(base_root)
            el = _find_element_by_id(root, node)
            if el is not None:
                el.attrib.pop("display", None)
                # Triangle wave: 0→1→0 over the cycle
                t = i / n
                opacity = 1.0 - 2.0 * abs(t - 0.5)
                opacity = max(0.2, min(1.0, 0.2 + 0.8 * opacity))
                el.set("opacity", f"{opacity:.2f}")

            self._show_background_node(root)
            frames.append(self._rasterise(root))
        return frames

    def _show_background_node(self, root: ET.Element) -> None:
        """Make the background node visible in the given SVG tree.

        If ``background_node`` is configured on the spinner spec, this
        removes ``display="none"`` from it so the background appears
        behind the animated spinner.  No transform or opacity changes
        are applied — the node is shown as-is.

        Parameters
        ----------
        root
            The parsed SVG element tree (will be mutated in place).
        """
        bg_id = self._spinner.background_node
        if bg_id is not None:
            bg_el = _find_element_by_id(root, bg_id)
            if bg_el is not None:
                bg_el.attrib.pop("display", None)

    def _generate_custom(self) -> list[bytes]:
        """Load custom frames from package assets.

        Looks for ``assets/spinner.gif`` first, then numbered PNGs in
        ``assets/spinner/``. Falls back to blank frames if neither is found.

        Returns
        -------
        list[bytes]
            Encoded image frames loaded from the package assets.
        """
        assets = self._spec.assets

        # Try animated GIF first
        if "spinner.gif" in assets:
            return self._load_gif_frames(assets["spinner.gif"])

        # Try numbered PNGs in spinner/ subdirectory
        frame_keys = sorted(k for k in assets if k.startswith("spinner/frame_"))
        if not frame_keys:
            logger.warning("No custom spinner frames found; returning blank frames")
            return self._blank_frames()

        frames: list[bytes] = []
        for key in frame_keys:
            img: Image.Image = Image.open(io.BytesIO(assets[key]))
            if img.size != (self._width, self._height):
                img = img.resize(
                    (self._width, self._height), Image.Resampling.LANCZOS
                )
            if img.mode != "RGB":
                img = img.convert("RGB")
            frames.append(_encode_image(img, self._image_format))
        return frames

    def _load_gif_frames(self, data: bytes) -> list[bytes]:
        """Extract and encode frames from an animated GIF.

        Parameters
        ----------
        data : bytes
            Raw GIF file bytes.

        Returns
        -------
        list[bytes]
            Encoded image frames; falls back to blank frames if the GIF
            contains no frames.
        """
        gif = Image.open(io.BytesIO(data))
        frames: list[bytes] = []
        try:
            while True:
                frame = gif.copy()
                if frame.size != (self._width, self._height):
                    frame = frame.resize(
                        (self._width, self._height), Image.Resampling.LANCZOS
                    )
                if frame.mode != "RGB":
                    frame = frame.convert("RGB")
                frames.append(_encode_image(frame, self._image_format))
                gif.seek(gif.tell() + 1)
        except EOFError:
            pass

        if not frames:
            logger.warning("Animated GIF has no frames; returning blank frames")
            return self._blank_frames()
        return frames

    def _blank_frames(self) -> list[bytes]:
        """Return a list of blank encoded frames as fallback.

        Returns
        -------
        list[bytes]
            Black frames, one per configured spinner frame count.
        """
        blank = Image.new("RGB", (self._width, self._height), "black")
        data = _encode_image(blank, self._image_format)
        return [data] * self._spinner.frames

    def _rasterise(self, root: ET.Element) -> bytes:
        """Rasterise an SVG element tree to encoded image bytes.

        Parameters
        ----------
        root : ET.Element
            The SVG document root to render.

        Returns
        -------
        bytes
            Image data encoded in the instance's configured format.
        """
        from ..render.svg_rasterize import _svg_to_png

        svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True)
        png_data = _svg_to_png(svg_bytes.encode("utf-8"), self._width, self._height)
        img = Image.open(io.BytesIO(png_data)).convert("RGB")
        return _encode_image(img, self._image_format)

    @staticmethod
    def _element_centre(elem: ET.Element) -> tuple[float, float]:
        """Compute the centre of an SVG element from its geometry attributes.

        Handles both rectangular elements (``x``, ``y``, ``width``, ``height``)
        and circle/ellipse elements (``cx``, ``cy``).

        Parameters
        ----------
        elem : ET.Element
            The SVG element to measure.

        Returns
        -------
        tuple[float, float]
            ``(cx, cy)`` centre coordinates.
        """
        x = float(elem.get("x", "0"))
        y = float(elem.get("y", "0"))
        w = float(elem.get("width", "0"))
        h = float(elem.get("height", "0"))

        # For circle/ellipse elements
        if w == 0 and h == 0:
            cx = float(elem.get("cx", str(x)))
            cy = float(elem.get("cy", str(y)))
            return cx, cy

        return x + w / 2, y + h / 2

frame_count property

frame_count: int

Number of frames in the animation cycle.

interval_ms property

interval_ms: int

Milliseconds between frames.

frames property

frames: list[bytes]

Encoded animation frames, generated on first access.

SvgRenderer

Render a .dui SVG layout with live data bindings.

The renderer holds a parsed copy of the SVG template. Each call to :meth:render clones the template, applies current binding values, inlines image assets, and rasterises to a PIL Image via CairoSVG.

Parameters:

Name Type Description Default
spec PackageSpec

The validated package specification.

required
Source code in src/deckui/dui/svg_renderer.py
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
class SvgRenderer:
    """Render a .dui SVG layout with live data bindings.

    The renderer holds a parsed copy of the SVG template.  Each call
    to :meth:`render` clones the template, applies current binding
    values, inlines image assets, and rasterises to a PIL Image via
    CairoSVG.

    Parameters
    ----------
    spec
        The validated package specification.
    """

    def __init__(self, spec: PackageSpec) -> None:
        self._spec = spec
        self._values: dict[str, Any] = {}
        self._base_root: ET.Element = ET.fromstring(spec.svg_source)  # noqa: S314
        self._range_extents: dict[str, float] = {}

        for name, binding in spec.bindings.items():
            if isinstance(binding, (TextBinding, VisibilityBinding, ColorBinding)):
                self._values[name] = binding.default
            elif isinstance(binding, RangeBinding):
                self._values[name] = binding.default
                elem = _find_element_by_id(self._base_root, binding.node)
                if elem is not None:
                    attr = (
                        "width"
                        if binding.direction == RangeDirection.HORIZONTAL
                        else "height"
                    )
                    self._range_extents[name] = float(elem.get(attr, "0"))
            elif isinstance(
                binding, (SliderBinding, ToggleBinding, IconifyBinding, TransformBinding)
            ):
                self._values[name] = binding.default
            elif isinstance(binding, ListBinding):
                self._values[name] = {
                    "items": list(binding.default_items),
                    "index": binding.default_index,
                }

    def set(self, name: str, value: Any) -> bool:
        """Set a binding value.

        Parameters
        ----------
        name
            Binding name as defined in the manifest.
        value
            The new value.  Type depends on the binding:
            text → ``str``, image → ``PIL.Image.Image`` or ``bytes``,
            visibility → ``bool``, color → ``str``.

        Returns
        -------
        bool
            ``True`` if the value actually changed (triggers dirty flag).

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        if name not in self._spec.bindings:
            raise KeyError(
                f"Unknown binding '{name}'. Available: {sorted(self._spec.bindings)}"
            )

        old = self._values.get(name)
        binding = self._spec.bindings[name]
        if isinstance(binding, ImageBinding):
            self._values[name] = value
            return True

        if isinstance(binding, ListBinding) and isinstance(value, dict):
            current = self._values.get(name, {"items": [], "index": None})
            merged = dict(current)
            if "items" in value:
                merged["items"] = list(value["items"])
            if "index" in value:
                merged["index"] = value["index"]
            # Clamp index when items changed but index wasn't explicitly set.
            if "items" in value and "index" not in value:
                items = merged["items"]
                idx = merged["index"]
                if idx is not None and idx != -1 and items and idx >= len(items):
                    merged["index"] = len(items) - 1
                elif not items:
                    merged["index"] = None
            # Normalise -1 → None.
            if merged.get("index") == -1:
                merged["index"] = None
            if old == merged:
                return False
            self._values[name] = merged
            return True

        if old == value:
            return False

        self._values[name] = value
        return True

    def set_many(self, **kwargs: Any) -> bool:
        """Set multiple binding values at once.

        Returns
        -------
        bool
            ``True`` if any value changed.
        """
        changed = False
        for name, value in kwargs.items():
            if self.set(name, value):
                changed = True
        return changed

    def get(self, name: str) -> Any:
        """Get the current value of a binding.

        Raises
        ------
        KeyError
            If *name* is not a known binding.
        """
        if name not in self._spec.bindings:
            raise KeyError(
                f"Unknown binding '{name}'. Available: {sorted(self._spec.bindings)}"
            )
        return self._values.get(name)

    def render(self) -> Image.Image:
        """Rasterise the SVG with current binding values to a PIL Image.

        Returns
        -------
        Image.Image
            An RGB :class:`~PIL.Image.Image` at the SVG's native dimensions.
        """
        root = copy.deepcopy(self._base_root)

        for name, binding in self._spec.bindings.items():
            value = self._values.get(name)
            self._apply_binding(root, name, binding, value)

        self._inline_assets(root)
        self._hide_spinner_node(root)

        svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True)
        logger.debug("Rendered SVG before rasterisation:\n%s", svg_bytes)
        return self._rasterise(svg_bytes.encode("utf-8"))

    def render_svg(self) -> str:
        """Return the SVG source with current bindings applied (not rasterised).

        This is used by the spinner system to generate frames that
        reflect the current binding state rather than the raw template.

        Returns
        -------
        str
            SVG markup as a Unicode string.
        """
        root = copy.deepcopy(self._base_root)

        for name, binding in self._spec.bindings.items():
            value = self._values.get(name)
            self._apply_binding(root, name, binding, value)

        self._inline_assets(root)
        self._hide_spinner_node(root)

        return ET.tostring(root, encoding="unicode", xml_declaration=True)

    def _hide_spinner_node(self, root: ET.Element) -> None:
        """Hide the spinner and its background node so they are invisible at rest.

        DUI package authors may not set ``display="none"`` on the spinner
        element or its background, which would make them visible in every
        non-busy render.  This method forces both nodes hidden; the spinner
        frame generators remove ``display="none"`` when producing animation
        frames.

        Parameters
        ----------
        root
            The parsed SVG element tree (will be mutated in place).
        """
        spinner = self._spec.spinner
        if spinner is not None and spinner.node is not None:
            elem = _find_element_by_id(root, spinner.node)
            if elem is not None:
                elem.set("display", "none")
        if spinner is not None and spinner.background_node is not None:
            bg_elem = _find_element_by_id(root, spinner.background_node)
            if bg_elem is not None:
                bg_elem.set("display", "none")

    def _apply_binding(
        self,
        root: ET.Element,
        name: str,
        binding: Binding,
        value: Any,
    ) -> None:
        """Apply a single binding to the SVG tree."""
        if isinstance(binding, ToggleBinding):
            elem_on = _find_element_by_id(root, binding.node_on)
            elem_off = _find_element_by_id(root, binding.node_off)
            if elem_on is None:
                logger.warning(
                    "Binding '%s': node_on '%s' not found in SVG",
                    name,
                    binding.node_on,
                )
            if elem_off is None:
                logger.warning(
                    "Binding '%s': node_off '%s' not found in SVG",
                    name,
                    binding.node_off,
                )
            self._apply_toggle(elem_on, elem_off, value)
            return

        elem = _find_element_by_id(root, binding.node)
        if elem is None:
            logger.warning(
                "Binding '%s': node '%s' not found in SVG", name, binding.node
            )
            return

        if isinstance(binding, TextBinding):
            self._apply_text(root, elem, binding, value)
        elif isinstance(binding, ImageBinding):
            self._apply_image(root, elem, binding, value)
        elif isinstance(binding, VisibilityBinding):
            self._apply_visibility(elem, value)
        elif isinstance(binding, ColorBinding):
            self._apply_color(elem, binding, value)
        elif isinstance(binding, RangeBinding):
            self._apply_range(elem, name, binding, value)
        elif isinstance(binding, SliderBinding):
            self._apply_slider(elem, binding, value)
        elif isinstance(binding, IconifyBinding):
            self._apply_iconify(elem, binding, value)
        elif isinstance(binding, ListBinding):
            self._apply_list(root, elem, binding, value)
        elif isinstance(binding, TransformBinding):
            self._apply_transform(elem, binding, value)

    def _apply_text(
        self,
        root: ET.Element,
        elem: ET.Element,
        binding: TextBinding,
        value: Any,
    ) -> None:
        """Set text content, applying wrapping or truncation if configured.

        Parameters
        ----------
        root : ET.Element
            The SVG document root (needed for font resolution during wrapping).
        elem : ET.Element
            The ``<text>`` element whose content is being set.
        binding : TextBinding
            The text binding definition from the manifest.
        value : Any
            The text value to render; coerced to ``str``.
        """
        text = str(value) if value is not None else binding.default

        if binding.wrap and binding.max_width is not None:
            self._apply_wrapped_text(root, elem, binding, text)
            return

        if binding.max_width is not None:
            family, size_f = _resolve_font_attrs(root, elem)
            font = _load_font(family, int(size_f))
            text = _truncate_text(text, binding.max_width, binding.overflow, font=font)
        elem.text = text

    def _apply_wrapped_text(
        self,
        root: ET.Element,
        elem: ET.Element,
        binding: TextBinding,
        text: str,
    ) -> None:
        """Word-wrap text into ``<tspan>`` children of *elem*.

        Each ``<tspan>`` carries the parent ``<text>`` element's ``x``
        attribute so that ``text-anchor`` alignment is respected on
        every line.
        """
        family, size_f = _resolve_font_attrs(root, elem)
        font = _load_font(family, int(size_f))

        line_height = binding.line_height or (size_f * _DEFAULT_LINE_HEIGHT_RATIO)

        lines = _wrap_text(
            text,
            binding.max_width,  # type: ignore[arg-type]  # validated non-None
            font,
            binding.overflow,
            max_height=binding.max_height,
            line_height=line_height,
        )

        elem.text = None
        for child in list(elem):
            elem.remove(child)

        x_attr = elem.get("x", "0")

        for i, line in enumerate(lines):
            tspan = ET.SubElement(elem, f"{{{_SVG_NS}}}tspan")
            tspan.set("x", x_attr)
            tspan.set("dy", "0" if i == 0 else str(line_height))
            tspan.text = line

    def _apply_image(
        self,
        root: ET.Element,
        elem: ET.Element,
        binding: ImageBinding,
        value: Any,
    ) -> None:
        """Set an image element's href to a data URI.

        Parameters
        ----------
        root : ET.Element
            The SVG document root (used to locate placeholder nodes).
        elem : ET.Element
            The ``<image>`` element to update.
        binding : ImageBinding
            The image binding definition from the manifest.
        value : Any
            A ``PIL.Image.Image``, ``bytes``, or ``None`` to clear the image.
        """
        if value is None:
            elem.set("href", "")
            elem.set("display", "none")
            if binding.placeholder_node:
                placeholder = _find_element_by_id(root, binding.placeholder_node)
                if placeholder is not None:
                    placeholder.attrib.pop("display", None)
            return

        elem.attrib.pop("display", None)
        if binding.placeholder_node:
            placeholder = _find_element_by_id(root, binding.placeholder_node)
            if placeholder is not None:
                placeholder.set("display", "none")

        target_w = int(float(elem.get("width", "0")))
        target_h = int(float(elem.get("height", "0")))

        if isinstance(value, Image.Image):
            img = value
        elif isinstance(value, bytes):
            img = Image.open(io.BytesIO(value))
        else:
            logger.warning("Image binding: unsupported value type %s", type(value))
            return

        if img.mode != "RGBA":
            img = img.convert("RGBA")

        if target_w > 0 and target_h > 0:
            img = _fit_image(img, target_w, target_h, binding.fit)

        data_uri = _image_to_data_uri(img)
        elem.set("href", data_uri)
        elem.set(f"{{{_XLINK_NS}}}href", data_uri)

    def _apply_visibility(self, elem: ET.Element, value: Any) -> None:
        """Toggle element visibility via the ``display`` attribute.

        Parameters
        ----------
        elem : ET.Element
            The SVG element to show or hide.
        value : Any
            Truthy to show, falsy to hide (sets ``display="none"``).
        """
        if value:
            elem.attrib.pop("display", None)
        else:
            elem.set("display", "none")

    def _apply_toggle(
        self,
        elem_on: ET.Element | None,
        elem_off: ET.Element | None,
        value: Any,
    ) -> None:
        """Toggle visibility between two elements based on a boolean value.

        Parameters
        ----------
        elem_on : ET.Element | None
            The element shown when *value* is truthy.
        elem_off : ET.Element | None
            The element shown when *value* is falsy.
        value : Any
            Boolean-like value controlling which element is visible.
        """
        if value:
            if elem_on is not None:
                elem_on.attrib.pop("display", None)
            if elem_off is not None:
                elem_off.set("display", "none")
        else:
            if elem_off is not None:
                elem_off.attrib.pop("display", None)
            if elem_on is not None:
                elem_on.set("display", "none")

    def _apply_color(self, elem: ET.Element, binding: ColorBinding, value: Any) -> None:
        """Set an element's fill, stroke, or color attribute.

        Parameters
        ----------
        elem : ET.Element
            The SVG element to modify.
        binding : ColorBinding
            The color binding definition specifying the target attribute.
        value : Any
            CSS color string (e.g. ``"#ff0000"``); falls back to *binding.default*.
        """
        color_val = str(value) if value is not None else binding.default
        elem.set(binding.attribute, color_val)

    def _apply_range(
        self,
        elem: ET.Element,
        name: str,
        binding: RangeBinding,
        value: Any,
    ) -> None:
        """Scale an element's width or height proportional to a 0--1 value.

        Parameters
        ----------
        elem : ET.Element
            The SVG element whose dimension is being scaled.
        name : str
            Binding name, used to look up the cached original extent.
        binding : RangeBinding
            The range binding definition from the manifest.
        value : Any
            A float in ``[0.0, 1.0]``; clamped if out of range.
        """
        ratio = max(
            0.0, min(1.0, float(value if value is not None else binding.default))
        )
        extent = self._range_extents.get(name, 0.0)
        attr = "width" if binding.direction == RangeDirection.HORIZONTAL else "height"
        elem.set(attr, str(ratio * extent))

    def _apply_slider(
        self,
        elem: ET.Element,
        binding: SliderBinding,
        value: Any,
    ) -> None:
        """Translate an element's x or y between *min_pos* and *max_pos*.

        Parameters
        ----------
        elem : ET.Element
            The SVG element to reposition.
        binding : SliderBinding
            The slider binding definition containing position limits.
        value : Any
            A float in ``[0.0, 1.0]`` interpolated between *min_pos* and *max_pos*.
        """
        ratio = max(
            0.0, min(1.0, float(value if value is not None else binding.default))
        )
        pos = binding.min_pos + ratio * (binding.max_pos - binding.min_pos)
        attr = "x" if binding.direction == RangeDirection.HORIZONTAL else "y"
        elem.set(attr, str(pos))

    def _apply_transform(
        self,
        elem: ET.Element,
        binding: TransformBinding,
        value: Any,
    ) -> None:
        """Apply composed SVG transforms to an element proportional to a 0–1 value.

        Parameters
        ----------
        elem : ET.Element
            The SVG element to transform.
        binding : TransformBinding
            The transform binding definition from the manifest.
        value : Any
            A float in ``[0.0, 1.0]``; clamped if out of range.
        """
        ratio = max(
            0.0, min(1.0, float(value if value is not None else binding.default))
        )

        parts: list[str] = []
        for spec in binding.transforms:
            if isinstance(spec, RotateTransform):
                angle = spec.from_angle + (spec.to_angle - spec.from_angle) * ratio
                cx, cy = self._resolve_transform_origin(elem, spec.origin)
                parts.append(f"rotate({angle:.4g},{cx:.4g},{cy:.4g})")

        if parts:
            elem.set("transform", " ".join(parts))

    @staticmethod
    def _resolve_transform_origin(
        elem: ET.Element, origin: str
    ) -> tuple[float, float]:
        """Resolve a transform origin string to x, y coordinates.

        Parameters
        ----------
        elem : ET.Element
            The target SVG element (used for ``"center"`` resolution).
        origin : str
            Either ``"center"`` (computes from element geometry) or an
            explicit ``"x y"`` coordinate pair.

        Returns
        -------
        tuple[float, float]
            The resolved (cx, cy) origin coordinates.
        """
        if origin == "center":
            x = float(elem.get("x", "0"))
            y = float(elem.get("y", "0"))
            w = float(elem.get("width", "0"))
            h = float(elem.get("height", "0"))
            return (x + w / 2.0, y + h / 2.0)

        # Explicit "x y" format.
        parts = origin.split()
        if len(parts) == 2:
            try:
                return (float(parts[0]), float(parts[1]))
            except ValueError:
                pass
        # Fallback to 0,0 if unparseable.
        return (0.0, 0.0)

    def _apply_iconify(
        self,
        elem: ET.Element,
        binding: IconifyBinding,
        value: Any,
    ) -> None:
        """Load an Iconify icon by name and embed it into a ``<g>`` node.

        The existing children of *elem* are replaced by a single
        ``<svg>`` child that contains the icon.  Passing ``None`` or an
        empty string clears the group.
        """
        elem.text = None
        for child in list(elem):
            elem.remove(child)

        if value is None or value == "":
            return
        name = str(value)

        try:
            svg_source = fetch_icon(str(name))
        except IconifyError as exc:
            logger.warning("Iconify binding '%s': %s", binding.node, exc)
            return

        try:
            icon_root = ET.fromstring(svg_source)  # noqa: S314 — trusted API
        except ET.ParseError as exc:
            logger.warning(
                "Iconify binding '%s': failed to parse icon SVG: %s",
                binding.node,
                exc,
            )
            return

        size = str(binding.size)
        icon_root.set("width", size)
        icon_root.set("height", size)

        elem.append(icon_root)

    def _apply_list(
        self,
        root: ET.Element,
        elem: ET.Element,
        binding: ListBinding,
        value: Any,
    ) -> None:
        """Render a list of items as repeated child elements.

        Each item becomes a ``<child_tag>`` child of *elem*.  The item
        at *index* receives *active_attrs*; all others receive
        *inactive_attrs*.  An index of ``None`` (or ``-1``, normalised
        earlier) means every item is styled as inactive.

        Items prefixed with ``icon:`` are rendered as inline Iconify
        icons via :meth:`_apply_list_icon`.

        Parameters
        ----------
        root : ET.Element
            The SVG document root (needed for font resolution on icon
            items).
        elem : ET.Element
            The parent SVG element whose children are rebuilt.
        binding : ListBinding
            The list binding definition from the manifest.
        value : Any
            A dict with ``"items"`` (list of str) and ``"index"``
            (int or None).
        """
        if not isinstance(value, dict):
            value = {"items": list(binding.default_items), "index": binding.default_index}

        items: list[str] = value.get("items", [])
        index: int | None = value.get("index")
        if index == -1:
            index = None

        # Clear existing children.
        elem.text = None
        for child in list(elem):
            elem.remove(child)

        for i, item in enumerate(items):
            # Separator between items.
            if binding.separator and i > 0:
                sep = ET.SubElement(elem, f"{{{_SVG_NS}}}{binding.child_tag}")
                sep.text = binding.separator
                for attr_k, attr_v in binding.inactive_attrs.items():
                    sep.set(attr_k, attr_v)

            is_active = index is not None and i == index
            attrs = binding.active_attrs if is_active else binding.inactive_attrs

            if item.startswith("icon:"):
                self._apply_list_icon(elem, binding, item[5:], attrs)
            else:
                child_elem = ET.SubElement(elem, f"{{{_SVG_NS}}}{binding.child_tag}")
                child_elem.text = item
                for attr_k, attr_v in attrs.items():
                    child_elem.set(attr_k, attr_v)

    def _apply_list_icon(
        self,
        parent: ET.Element,
        binding: ListBinding,
        icon_name: str,
        attrs: dict[str, str],
    ) -> None:
        """Render a single Iconify icon as a child of *parent*.

        Fetches the icon SVG via the Iconify API, wraps it in a
        ``<child_tag>`` element, and applies the given attributes.

        Parameters
        ----------
        parent : ET.Element
            The parent SVG element to append the icon child to.
        binding : ListBinding
            The list binding definition (provides ``child_tag`` and
            ``icon_size``).
        icon_name : str
            Iconify icon identifier (e.g. ``"mdi:home"``).
        attrs : dict[str, str]
            SVG attributes to apply to the wrapper element.
        """
        try:
            svg_source = fetch_icon(icon_name)
        except IconifyError as exc:
            logger.warning("List binding icon '%s': %s", icon_name, exc)
            return

        try:
            icon_root = ET.fromstring(svg_source)  # noqa: S314 — trusted API
        except ET.ParseError as exc:
            logger.warning(
                "List binding icon '%s': failed to parse SVG: %s", icon_name, exc
            )
            return

        size = str(binding.icon_size)
        icon_root.set("width", size)
        icon_root.set("height", size)

        wrapper = ET.SubElement(parent, f"{{{_SVG_NS}}}{binding.child_tag}")
        for attr_k, attr_v in attrs.items():
            wrapper.set(attr_k, attr_v)
        wrapper.append(icon_root)

    def _inline_assets(self, root: ET.Element) -> None:
        """Replace relative asset ``href`` references with data URIs.

        Parameters
        ----------
        root : ET.Element
            The SVG document root to scan for asset references.
        """
        if not self._spec.assets:
            return

        for elem in root.iter():
            href = elem.get("href") or elem.get(f"{{{_XLINK_NS}}}href")
            if not href:
                continue

            asset_name: str | None = None
            if href.startswith("assets/"):
                asset_name = href[len("assets/") :]
            elif "/" not in href and href in self._spec.assets:
                asset_name = href

            if asset_name and asset_name in self._spec.assets:
                img = Image.open(io.BytesIO(self._spec.assets[asset_name]))
                data_uri = _image_to_data_uri(img)
                elem.set("href", data_uri)
                elem.set(f"{{{_XLINK_NS}}}href", data_uri)

    def _rasterise(self, svg_data: bytes) -> Image.Image:
        """Rasterise SVG bytes to a PIL Image via CairoSVG.

        Parameters
        ----------
        svg_data : bytes
            UTF-8 encoded SVG markup.

        Returns
        -------
        Image.Image
            An RGB :class:`~PIL.Image.Image` at the SVG's native dimensions.
        """
        from ..render.svg_rasterize import _svg_to_png

        width = int(float(self._base_root.get("width", "197")))
        height = int(float(self._base_root.get("height", "98")))

        png_data = _svg_to_png(svg_data, width, height)
        return Image.open(io.BytesIO(png_data)).convert("RGB")

set

set(name: str, value: Any) -> bool

Set a binding value.

Parameters:

Name Type Description Default
name str

Binding name as defined in the manifest.

required
value Any

The new value. Type depends on the binding: text → str, image → PIL.Image.Image or bytes, visibility → bool, color → str.

required

Returns:

Type Description
bool

True if the value actually changed (triggers dirty flag).

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/svg_renderer.py
def set(self, name: str, value: Any) -> bool:
    """Set a binding value.

    Parameters
    ----------
    name
        Binding name as defined in the manifest.
    value
        The new value.  Type depends on the binding:
        text → ``str``, image → ``PIL.Image.Image`` or ``bytes``,
        visibility → ``bool``, color → ``str``.

    Returns
    -------
    bool
        ``True`` if the value actually changed (triggers dirty flag).

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    if name not in self._spec.bindings:
        raise KeyError(
            f"Unknown binding '{name}'. Available: {sorted(self._spec.bindings)}"
        )

    old = self._values.get(name)
    binding = self._spec.bindings[name]
    if isinstance(binding, ImageBinding):
        self._values[name] = value
        return True

    if isinstance(binding, ListBinding) and isinstance(value, dict):
        current = self._values.get(name, {"items": [], "index": None})
        merged = dict(current)
        if "items" in value:
            merged["items"] = list(value["items"])
        if "index" in value:
            merged["index"] = value["index"]
        # Clamp index when items changed but index wasn't explicitly set.
        if "items" in value and "index" not in value:
            items = merged["items"]
            idx = merged["index"]
            if idx is not None and idx != -1 and items and idx >= len(items):
                merged["index"] = len(items) - 1
            elif not items:
                merged["index"] = None
        # Normalise -1 → None.
        if merged.get("index") == -1:
            merged["index"] = None
        if old == merged:
            return False
        self._values[name] = merged
        return True

    if old == value:
        return False

    self._values[name] = value
    return True

set_many

set_many(**kwargs: Any) -> bool

Set multiple binding values at once.

Returns:

Type Description
bool

True if any value changed.

Source code in src/deckui/dui/svg_renderer.py
def set_many(self, **kwargs: Any) -> bool:
    """Set multiple binding values at once.

    Returns
    -------
    bool
        ``True`` if any value changed.
    """
    changed = False
    for name, value in kwargs.items():
        if self.set(name, value):
            changed = True
    return changed

get

get(name: str) -> Any

Get the current value of a binding.

Raises:

Type Description
KeyError

If name is not a known binding.

Source code in src/deckui/dui/svg_renderer.py
def get(self, name: str) -> Any:
    """Get the current value of a binding.

    Raises
    ------
    KeyError
        If *name* is not a known binding.
    """
    if name not in self._spec.bindings:
        raise KeyError(
            f"Unknown binding '{name}'. Available: {sorted(self._spec.bindings)}"
        )
    return self._values.get(name)

render

render() -> Image.Image

Rasterise the SVG with current binding values to a PIL Image.

Returns:

Type Description
Image

An RGB :class:~PIL.Image.Image at the SVG's native dimensions.

Source code in src/deckui/dui/svg_renderer.py
def render(self) -> Image.Image:
    """Rasterise the SVG with current binding values to a PIL Image.

    Returns
    -------
    Image.Image
        An RGB :class:`~PIL.Image.Image` at the SVG's native dimensions.
    """
    root = copy.deepcopy(self._base_root)

    for name, binding in self._spec.bindings.items():
        value = self._values.get(name)
        self._apply_binding(root, name, binding, value)

    self._inline_assets(root)
    self._hide_spinner_node(root)

    svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True)
    logger.debug("Rendered SVG before rasterisation:\n%s", svg_bytes)
    return self._rasterise(svg_bytes.encode("utf-8"))

render_svg

render_svg() -> str

Return the SVG source with current bindings applied (not rasterised).

This is used by the spinner system to generate frames that reflect the current binding state rather than the raw template.

Returns:

Type Description
str

SVG markup as a Unicode string.

Source code in src/deckui/dui/svg_renderer.py
def render_svg(self) -> str:
    """Return the SVG source with current bindings applied (not rasterised).

    This is used by the spinner system to generate frames that
    reflect the current binding state rather than the raw template.

    Returns
    -------
    str
        SVG markup as a Unicode string.
    """
    root = copy.deepcopy(self._base_root)

    for name, binding in self._spec.bindings.items():
        value = self._values.get(name)
        self._apply_binding(root, name, binding, value)

    self._inline_assets(root)
    self._hide_spinner_node(root)

    return ET.tostring(root, encoding="unicode", xml_declaration=True)

fetch_icon

fetch_icon(name: str) -> str

Fetch an Iconify icon by prefix:icon name.

The lookup order is: in-memory cache, disk cache, network. Successful fetches are stored in both caches. Negative lookups (404 / network failure) are cached in memory only so a restart retries previously-failed icons.

Parameters:

Name Type Description Default
name str

Icon identifier in "prefix:icon" form, e.g. "line-md:home".

required

Returns:

Type Description
str

The raw SVG source string as served by the Iconify API.

Raises:

Type Description
IconifyError

If the name is malformed, the icon does not exist, or the network request fails.

Source code in src/deckui/dui/iconify.py
def fetch_icon(name: str) -> str:
    """Fetch an Iconify icon by ``prefix:icon`` name.

    The lookup order is: in-memory cache, disk cache, network.
    Successful fetches are stored in both caches.  Negative lookups
    (404 / network failure) are cached in memory only so a restart
    retries previously-failed icons.

    Parameters
    ----------
    name : str
        Icon identifier in ``"prefix:icon"`` form, e.g.
        ``"line-md:home"``.

    Returns
    -------
    str
        The raw SVG source string as served by the Iconify API.

    Raises
    ------
    IconifyError
        If the name is malformed, the icon does not
        exist, or the network request fails.
    """
    prefix, icon = _parse_name(name)
    key = f"{prefix}:{icon}"

    # 1. In-memory cache
    with _cache_lock:
        if key in _cache:
            cached = _cache[key]
            if cached is None:
                raise IconifyError(f"Iconify icon '{key}' previously failed to load")
            return cached

    # 2. Disk cache
    disk_hit = _read_disk_cache(prefix, icon)
    if disk_hit is not None:
        with _cache_lock:
            _cache[key] = disk_hit
        logger.debug("Loaded Iconify icon '%s' from disk cache", key)
        return disk_hit

    # 3. Network fetch
    url = f"{ICONIFY_API_URL}/{prefix}/{icon}.svg"
    try:
        body = _http_get(url)
    except (urllib.error.URLError, OSError) as exc:
        with _cache_lock:
            _cache[key] = None
        raise IconifyError(f"Failed to fetch Iconify icon '{key}': {exc}") from exc

    stripped = body.strip()
    if stripped == "404" or not stripped.startswith("<"):
        with _cache_lock:
            _cache[key] = None
        raise IconifyError(f"Iconify icon '{key}' not found")

    with _cache_lock:
        _cache[key] = body

    _write_disk_cache(prefix, icon, body)

    logger.debug("Fetched Iconify icon '%s' (%d bytes)", key, len(body))
    return body

clear_iconify_cache

clear_iconify_cache(*, persistent: bool = False) -> None

Drop all cached Iconify icons.

Parameters:

Name Type Description Default
persistent bool

When True, also remove the on-disk cache directory. By default only the in-memory cache is cleared.

False
Source code in src/deckui/dui/iconify.py
def clear_cache(*, persistent: bool = False) -> None:
    """Drop all cached Iconify icons.

    Parameters
    ----------
    persistent : bool, default=False
        When ``True``, also remove the on-disk cache directory.
        By default only the in-memory cache is cleared.
    """
    global _disk_cache_dir  # noqa: PLW0603
    with _cache_lock:
        _cache.clear()
    if persistent:
        with _disk_cache_dir_lock:
            if _disk_cache_dir is not None:
                shutil.rmtree(_disk_cache_dir, ignore_errors=True)
                _disk_cache_dir = None

load_all_packages

load_all_packages(directory: str | Path) -> dict[str, PackageSpec]

Load all .dui packages from a directory.

Parameters:

Name Type Description Default
directory str | Path

Path to scan for .dui subdirectories.

required

Returns:

Type Description
dict[str, PackageSpec]

A dict mapping package names to their specs.

Raises:

Type Description
PackageError

If any package fails validation.

Source code in src/deckui/dui/loader.py
def load_all_packages(directory: str | Path) -> dict[str, PackageSpec]:
    """Load all .dui packages from a directory.

    Parameters
    ----------
    directory
        Path to scan for ``.dui`` subdirectories.

    Returns
    -------
    dict[str, PackageSpec]
        A dict mapping package names to their specs.

    Raises
    ------
    PackageError
        If any package fails validation.
    """
    base = Path(directory)
    if not base.is_dir():
        raise PackageError(f"Not a directory: {base}")

    packages: dict[str, PackageSpec] = {}
    for entry in sorted(base.iterdir()):
        if entry.is_dir() and entry.suffix == ".dui":
            spec = load_package(entry)
            packages[spec.name] = spec

    logger.info("Loaded %d .dui packages from %s", len(packages), base)
    return packages

load_package

load_package(path: str | Path) -> PackageSpec

Load a .dui package directory into a validated PackageSpec.

Parameters:

Name Type Description Default
path str | Path

Path to the .dui directory.

required

Returns:

Type Description
PackageSpec

A frozen :class:PackageSpec ready to be used by :class:~deckui.dui.card.DuiCard or :class:~deckui.dui.key.DuiKey.

Raises:

Type Description
PackageError

If the package is invalid or incomplete.

Source code in src/deckui/dui/loader.py
def load_package(path: str | Path) -> PackageSpec:
    """Load a .dui package directory into a validated PackageSpec.

    Parameters
    ----------
    path
        Path to the ``.dui`` directory.

    Returns
    -------
    PackageSpec
        A frozen :class:`PackageSpec` ready to be used by
        :class:`~deckui.dui.card.DuiCard` or
        :class:`~deckui.dui.key.DuiKey`.

    Raises
    ------
    PackageError
        If the package is invalid or incomplete.
    """
    pkg_dir = Path(path)
    if not pkg_dir.is_dir():
        raise PackageError(f"Package path is not a directory: {pkg_dir}")

    manifest_path = pkg_dir / "manifest.yaml"
    if not manifest_path.exists():
        raise PackageError(f"Missing manifest.yaml in {pkg_dir}")

    try:
        with manifest_path.open("r", encoding="utf-8") as f:
            manifest: dict[str, Any] = yaml.safe_load(f)
    except yaml.YAMLError as exc:
        raise PackageError(f"Invalid YAML in manifest: {exc}") from exc

    if not isinstance(manifest, dict):
        raise PackageError("manifest.yaml must be a YAML mapping")

    name = manifest.get("name")
    if not name or not isinstance(name, str):
        raise PackageError("manifest.yaml missing or invalid 'name'")

    raw_type = manifest.get("type")
    if raw_type is None:
        raise PackageError("manifest.yaml missing 'type'")
    try:
        pkg_type = PackageType(raw_type)
    except ValueError:
        valid = [t.value for t in PackageType]
        raise PackageError(
            f"Invalid package type '{raw_type}'. Valid types: {valid}"
        ) from None

    version = manifest.get("version")
    if version is None:
        raise PackageError("manifest.yaml missing 'version'")
    if not isinstance(version, int) or version < 1:
        raise PackageError("'version' must be a positive integer")

    layout_file = manifest.get("layout")
    if not layout_file or not isinstance(layout_file, str):
        raise PackageError("manifest.yaml missing or invalid 'layout'")

    layout_path = pkg_dir / layout_file
    if not layout_path.exists():
        raise PackageError(f"Layout file not found: {layout_path}")

    svg_source = layout_path.read_text(encoding="utf-8")
    svg_ids = _find_svg_ids(svg_source)

    # ── Optional metadata fields ──────────────────────────────────────
    unknown_keys = set(manifest.keys()) - KNOWN_MANIFEST_KEYS
    if unknown_keys:
        logger.warning(
            "Package '%s': unknown manifest keys: %s", name, sorted(unknown_keys)
        )

    description = manifest.get("description")
    if description is not None and not isinstance(description, str):
        raise PackageError("'description' must be a string")

    author = manifest.get("author")
    if author is not None and not isinstance(author, str):
        raise PackageError("'author' must be a string")

    pkg_license = manifest.get("license")
    if pkg_license is not None and not isinstance(pkg_license, str):
        raise PackageError("'license' must be a string")

    tags: tuple[str, ...] = ()
    raw_tags = manifest.get("tags")
    if raw_tags is not None:
        if not isinstance(raw_tags, list):
            raise PackageError("'tags' must be a list")
        for tag in raw_tags:
            if not isinstance(tag, str) or not tag.strip():
                raise PackageError(f"Each tag must be a non-empty string, got {tag!r}")
        tags = tuple(raw_tags)

    category = manifest.get("category")
    if category is not None:
        if not isinstance(category, str):
            raise PackageError("'category' must be a string")
        if category not in VALID_CATEGORIES:
            raise PackageError(
                f"Invalid category '{category}'. "
                f"Valid categories: {sorted(VALID_CATEGORIES)}"
            )

    url = manifest.get("url")
    if url is not None and not isinstance(url, str):
        raise PackageError("'url' must be a string")

    icon = manifest.get("icon")
    if icon is not None and not isinstance(icon, str):
        raise PackageError("'icon' must be a string")

    min_deckui = manifest.get("min_deckui")
    if min_deckui is not None and not isinstance(min_deckui, str):
        raise PackageError("'min_deckui' must be a string")

    device: tuple[str, ...] = ()
    raw_device = manifest.get("device")
    if raw_device is not None:
        if not isinstance(raw_device, list):
            raise PackageError("'device' must be a list")
        for d in raw_device:
            if not isinstance(d, str) or not d.strip():
                raise PackageError(
                    f"Each device must be a non-empty string, got {d!r}"
                )
        device = tuple(raw_device)

    bindings: dict[str, Binding] = {}
    raw_bindings = manifest.get("bindings", {})
    if raw_bindings and not isinstance(raw_bindings, dict):
        raise PackageError("'bindings' must be a mapping")
    for binding_name, binding_raw in (raw_bindings or {}).items():
        if not isinstance(binding_raw, dict):
            raise PackageError(f"Binding '{binding_name}' must be a mapping")
        binding = _parse_binding(binding_name, binding_raw)
        if isinstance(binding, ToggleBinding):
            if binding.node_on not in svg_ids:
                raise PackageError(
                    f"Binding '{binding_name}' references node_on '{binding.node_on}' "
                    f"which does not exist in the SVG. "
                    f"Available ids: {sorted(svg_ids)}"
                )
            if binding.node_off not in svg_ids:
                raise PackageError(
                    f"Binding '{binding_name}' references node_off '{binding.node_off}' "
                    f"which does not exist in the SVG. "
                    f"Available ids: {sorted(svg_ids)}"
                )
        else:
            if binding.node not in svg_ids:
                raise PackageError(
                    f"Binding '{binding_name}' references node '{binding.node}' "
                    f"which does not exist in the SVG. "
                    f"Available ids: {sorted(svg_ids)}"
                )
            if (
                isinstance(binding, ImageBinding)
                and binding.placeholder_node
                and binding.placeholder_node not in svg_ids
            ):
                raise PackageError(
                    f"Binding '{binding_name}' references placeholder_node "
                    f"'{binding.placeholder_node}' which does not exist in the SVG"
                )
        bindings[binding_name] = binding

    events: list[EventMapping] = []
    raw_events = manifest.get("events", [])
    if raw_events and not isinstance(raw_events, list):
        raise PackageError("'events' must be a list")
    seen_names: set[str] = set()
    for i, event_raw in enumerate(raw_events or []):
        if not isinstance(event_raw, dict):
            raise PackageError(f"Event at index {i} must be a mapping")
        event = _parse_event(event_raw, i)
        if event.name in seen_names:
            raise PackageError(f"Duplicate event name '{event.name}'")
        seen_names.add(event.name)
        events.append(event)

    regions: list[Region] = []
    raw_regions = manifest.get("regions", {})
    if raw_regions and not isinstance(raw_regions, dict):
        raise PackageError("'regions' must be a mapping")
    for region_name, region_raw in (raw_regions or {}).items():
        if not isinstance(region_raw, dict):
            raise PackageError(f"Region '{region_name}' must be a mapping")
        region = _parse_region(region_name, region_raw)
        regions.append(region)

    # ── Spinner ───────────────────────────────────────────────────────
    spinner: SpinnerSpec | None = None
    raw_spinner = manifest.get("spinner")
    if raw_spinner is not None:
        if not isinstance(raw_spinner, dict):
            raise PackageError("'spinner' must be a mapping")
        spinner = _parse_spinner(raw_spinner)

        # Validate spinner node exists in SVG (for rotation/pulse)
        if spinner.node is not None and spinner.node not in svg_ids:
            raise PackageError(
                f"Spinner references node '{spinner.node}' "
                f"which does not exist in the SVG. "
                f"Available ids: {sorted(svg_ids)}"
            )

        # Validate background_node exists in SVG if specified
        if spinner.background_node is not None and spinner.background_node not in svg_ids:
            raise PackageError(
                f"Spinner references background_node '{spinner.background_node}' "
                f"which does not exist in the SVG. "
                f"Available ids: {sorted(svg_ids)}"
            )

    assets: dict[str, bytes] = {}
    assets_dir = pkg_dir / "assets"
    if assets_dir.is_dir():
        for asset_file in sorted(assets_dir.rglob("*")):
            if asset_file.is_file():
                rel = str(asset_file.relative_to(assets_dir))
                assets[rel] = asset_file.read_bytes()

    # Validate custom spinner frames exist in assets
    if spinner is not None and spinner.type == SpinnerType.CUSTOM:
        has_gif = "spinner.gif" in assets
        frame_keys = sorted(k for k in assets if k.startswith("spinner/frame_"))
        if not has_gif and not frame_keys:
            raise PackageError(
                "Spinner type 'custom' requires either 'assets/spinner.gif' "
                "or frame images in 'assets/spinner/' "
                "(e.g. 'spinner/frame_00.png', 'spinner/frame_01.png', ...)"
            )

    logger.info(
        "Loaded .dui package '%s' (%s, %d bindings, %d events)",
        name,
        pkg_type.value,
        len(bindings),
        len(events),
    )

    return PackageSpec(
        name=name,
        type=pkg_type,
        version=version,
        svg_source=svg_source,
        bindings=bindings,
        events=tuple(events),
        regions=tuple(regions),
        assets=assets,
        spinner=spinner,
        description=description,
        author=author,
        license=pkg_license,
        tags=tags,
        category=category,
        url=url,
        icon=icon,
        min_deckui=min_deckui,
        device=device,
    )

add_dui_path

add_dui_path(path: str | Path) -> None

Register a directory as a DUI package source.

The directory is inserted at the highest priority position. Packages in this directory will override bundled packages and packages from previously added directories when names collide.

Parameters:

Name Type Description Default
path str or Path

Directory containing .dui package subdirectories.

required

Raises:

Type Description
PackageError

If path does not exist or is not a directory.

Examples:

::

import deckui

deckui.add_dui_path("/home/user/my-packages")
card = deckui.DuiCard("MyCustomCard")
Source code in src/deckui/dui/repository.py
def add_dui_path(path: str | Path) -> None:
    """Register a directory as a DUI package source.

    The directory is inserted at the highest priority position.
    Packages in this directory will override bundled packages and
    packages from previously added directories when names collide.

    Parameters
    ----------
    path : str or Path
        Directory containing ``.dui`` package subdirectories.

    Raises
    ------
    PackageError
        If *path* does not exist or is not a directory.

    Examples
    --------
    ::

        import deckui

        deckui.add_dui_path("/home/user/my-packages")
        card = deckui.DuiCard("MyCustomCard")
    """
    _get_repository().add_path(path)

clear_dui_cache

clear_dui_cache() -> None

Drop all cached package specs from the default repository.

Subsequent DuiCard("name") / DuiKey("name") calls will re-read packages from disk.

Source code in src/deckui/dui/repository.py
def clear_dui_cache() -> None:
    """Drop all cached package specs from the default repository.

    Subsequent ``DuiCard("name")`` / ``DuiKey("name")`` calls will
    re-read packages from disk.
    """
    _get_repository().clear_cache()

list_dui_packages

list_dui_packages() -> list[str]

List all DUI package names visible across all search paths.

Returns:

Type Description
list[str]

Sorted package names (without the .dui suffix).

Source code in src/deckui/dui/repository.py
def list_dui_packages() -> list[str]:
    """List all DUI package names visible across all search paths.

    Returns
    -------
    list[str]
        Sorted package names (without the ``.dui`` suffix).
    """
    return _get_repository().list_packages()

remove_dui_path

remove_dui_path(path: str | Path) -> None

Unregister a previously added DUI package directory.

Parameters:

Name Type Description Default
path str or Path

Previously registered directory.

required

Raises:

Type Description
ValueError

If path is not currently registered.

Source code in src/deckui/dui/repository.py
def remove_dui_path(path: str | Path) -> None:
    """Unregister a previously added DUI package directory.

    Parameters
    ----------
    path : str or Path
        Previously registered directory.

    Raises
    ------
    ValueError
        If *path* is not currently registered.
    """
    _get_repository().remove_path(path)

resolve_dui

resolve_dui(name: str) -> PackageSpec

Look up a DUI package by name and return its spec.

This is the function called internally when DuiCard("name") or DuiKey("name") receive a string argument.

Parameters:

Name Type Description Default
name str

Package name without the .dui suffix.

required

Returns:

Type Description
PackageSpec

A validated, frozen package specification.

Raises:

Type Description
PackageError

If no package with that name exists in any registered path.

Source code in src/deckui/dui/repository.py
def resolve_dui(name: str) -> PackageSpec:
    """Look up a DUI package by name and return its spec.

    This is the function called internally when ``DuiCard("name")``
    or ``DuiKey("name")`` receive a string argument.

    Parameters
    ----------
    name : str
        Package name without the ``.dui`` suffix.

    Returns
    -------
    PackageSpec
        A validated, frozen package specification.

    Raises
    ------
    PackageError
        If no package with that name exists in any registered path.
    """
    return _get_repository().resolve(name)