Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 19 additions & 85 deletions docs/guides/creating-dui-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,80 +496,22 @@ When an event handler takes time to complete (e.g. making an API call), users ge

### Defining a Spinner

Add a `spinner` section to your manifest:
The spinner is a single, library-owned 360° rotation animation (8 frames at 100 ms intervals, fixed by DeUX). Your package only declares **where** the spinner appears.

```yaml
spinner:
type: rotation # "rotation", "pulse", or "custom"
node: spinner_icon # SVG element ID to animate (required for rotation/pulse)
frames: 12 # frames per cycle (default 12, minimum 2)
interval_ms: 80 # ms between frames (default 80, minimum 10)
```

Your SVG must include an element with the spinner node ID. It's typically hidden by default and made visible during animation:

```xml
<rect id="spinner_icon" x="80" y="30" width="30" height="30"
display="none" fill="#ffffff"/>
```

### Spinner Types

#### `rotation`

Rotates the SVG node by `360/frames` degrees each frame around its centre. Good for loading spinners.

```yaml
spinner:
type: rotation
node: spinner_icon
frames: 12
interval_ms: 80
```

#### `pulse`

Cycles the node's opacity between 0.2 and 1.0 in a triangle wave. Good for subtle "working" indicators.
Add a `spinner` section to your manifest with a single `node` key:

```yaml
spinner:
type: pulse
node: spinner_glow
frames: 8
interval_ms: 100
node: spinner # ID of the placeholder <g> element in your SVG
```

#### `custom`
Your layout SVG must contain an empty group with that ID, positioned via its `transform` attribute. DeUX injects the canonical spinner geometry (background tile + 8 radial bars) into this placeholder at runtime:

Use your own pre-rendered frames. Place them in one of two formats:

**Numbered PNGs** in `assets/spinner/`:

```
MyPackage.dui/
assets/
spinner/
frame_00.png
frame_01.png
frame_02.png
...
```

**Animated GIF** at `assets/spinner.gif`:

```
MyPackage.dui/
assets/
spinner.gif
```xml
<g id="spinner" transform="translate(60 47.5)"/>
```

For custom spinners, the `node` field is optional (ignored).

```yaml
spinner:
type: custom
interval_ms: 60
```
The placeholder's `transform` (typically `translate(cx cy)`) controls where the spinner is centred. All other attributes — frame count, interval, bar geometry, background colour — are owned by the library and are not configurable.

### How It Works

Expand All @@ -580,20 +522,16 @@ The busy state is **entirely controlled by your application** via two methods:

When busy:

1. Spinner frames are rendered **on top of the current UI state** — all bindings (text, colors, images, etc.) are preserved. Only the spinner node is animated; the rest of the key/card looks exactly as it did before.
2. For touchscreen cards, only the affected panel region is updated (not the entire strip)
3. The normal refresh cycle skips animating slots to avoid overwriting frames
4. Frames are re-generated each time the spinner starts so they always reflect the latest binding values
1. Spinner frames are rendered **on top of the current UI state** — all bindings (text, colors, images, etc.) are preserved. Only the spinner placeholder is animated; the rest of the key/card looks exactly as it did before.
2. For touchscreen cards, only the affected panel region is updated (not the entire strip).
3. The normal refresh cycle skips animating slots to avoid overwriting frames.
4. Frames are cached process-wide by `(rendered SVG, placeholder id, size, image format, background tile)`, so repeated busy cycles on the same panel reuse the same encoded bytes.

Duplicate `start_busy()` calls while already busy are no-ops.
`finish_busy()` when not busy is also a no-op.
Duplicate `start_busy()` calls while already busy are no-ops. `finish_busy()` when not busy is also a no-op.

### Controlling the Busy Lifecycle

Your application decides when to start and stop the spinner. This
decouples the spinner from the event handler — the handler can return
immediately while the spinner keeps running until an external signal
arrives.
Your application decides when to start and stop the spinner. This decouples the spinner from the event handler — the handler can return immediately while the spinner keeps running until an external signal arrives.

```python
@key.on("toggle")
Expand All @@ -620,13 +558,12 @@ async def handle():
await key.finish_busy()
```

Sometimes you don't need a spinner at all — the task resolves instantly.
Since the application controls the busy state, you simply don't call
`start_busy()` for fast operations.
Sometimes you don't need a spinner at all — the task resolves instantly. Since the application controls the busy state, you simply don't call `start_busy()` for fast operations.

**Validation rules:**
- For `rotation` and `pulse` types, the `node` must exist in the SVG
- For `custom` type, either `assets/spinner.gif` or `assets/spinner/frame_*.png` files must exist

- `spinner.node` is required and must match an element ID present in the layout SVG.
- The legacy keys `type`, `frames`, `interval_ms`, and `background_node` are no longer accepted; package loading fails if they are present.

### Complete Example

Expand All @@ -637,10 +574,7 @@ version: 1
layout: layout.svg

spinner:
type: rotation
node: loading_ring
frames: 8
interval_ms: 100
node: spinner

bindings:
label:
Expand Down Expand Up @@ -669,7 +603,7 @@ key = DuiKey(spec)
async def handle():
await key.start_busy()
# Spinner starts — the key still shows the current
# label and status_color while the spinner animates in the corner.
# label and status_color while the spinner animates in the centre.
await smart_home_api.toggle_light()
# Don't call finish_busy() here — wait for the state update callback.

Expand Down
18 changes: 14 additions & 4 deletions src/deux/dui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,22 @@ async def handle():
RotateTransform,
SliderBinding,
SpinnerSpec,
SpinnerType,
TextBinding,
ToggleBinding,
TransformBinding,
TransformKind,
VisibilityBinding,
)
from .spinner import SpinnerFrames
from .spinner import (
SPINNER_FRAME_COUNT,
SPINNER_INTERVAL_MS,
)
from .spinner import (
clear_cache as clear_spinner_cache,
)
from .spinner import (
get_frames as get_spinner_frames,
)
from .svg_renderer import SvgRenderer

__all__ = [
Expand All @@ -92,10 +100,10 @@ async def handle():
"Region",
"RotateTransform",
"SliderBinding",
"SPINNER_FRAME_COUNT",
"SPINNER_INTERVAL_MS",
"SpinnerAnimator",
"SpinnerFrames",
"SpinnerSpec",
"SpinnerType",
"SvgRenderer",
"TextBinding",
"ToggleBinding",
Expand All @@ -106,7 +114,9 @@ async def handle():
"add_dui_path",
"clear_dui_cache",
"clear_iconify_cache",
"clear_spinner_cache",
"fetch_icon",
"get_spinner_frames",
"list_dui_packages",
"load_all_packages",
"load_package",
Expand Down
16 changes: 8 additions & 8 deletions src/deux/dui/card.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .binding_mixin import BindingMixin
from .event_map import EventMap
from .schema import PackageSpec
from .spinner import SpinnerFrames
from .spinner import SPINNER_INTERVAL_MS, get_frames
from .svg_renderer import SvgRenderer

if TYPE_CHECKING:
Expand Down Expand Up @@ -101,7 +101,6 @@ def __init__(self, spec: PackageSpec | str) -> None:
self._subscriptions: list[tuple[AsyncEvent, AsyncHandler]] = []
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
self._bg_tile: bytes | None = None
Expand Down Expand Up @@ -537,18 +536,19 @@ async def _start_spinner(self) -> None:

width, height = self._panel_size
rendered_svg = self._renderer.render_svg()
spinner_frames = SpinnerFrames(
self._spec,
spinner_node_id = self._spec.spinner.node
frames = await asyncio.to_thread(
get_frames,
rendered_svg=rendered_svg,
spinner_node_id=spinner_node_id,
width=width,
height=height,
rendered_svg=rendered_svg,
bg_tile=self._bg_tile,
)
self._spinner_frames = spinner_frames

self._animator = SpinnerAnimator(
frames=await asyncio.to_thread(lambda: spinner_frames.frames),
interval_ms=spinner_frames.interval_ms,
frames=frames,
interval_ms=SPINNER_INTERVAL_MS,
push_fn=self._push_fn,
)
await self._animator.start()
Expand Down
16 changes: 8 additions & 8 deletions src/deux/dui/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from .binding_mixin import BindingMixin
from .event_map import EventMap
from .schema import PackageSpec
from .spinner import SpinnerFrames
from .spinner import SPINNER_INTERVAL_MS, get_frames
from .svg_renderer import SvgRenderer

if TYPE_CHECKING:
Expand Down Expand Up @@ -79,7 +79,6 @@ def __init__(self, spec: PackageSpec | str) -> None:
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

Expand Down Expand Up @@ -383,17 +382,18 @@ async def _start_spinner(self) -> None:

width, height = self._key_size
rendered_svg = self._renderer.render_svg()
spinner_frames = SpinnerFrames(
self._spec,
spinner_node_id = self._spec.spinner.node
frames = await asyncio.to_thread(
get_frames,
rendered_svg=rendered_svg,
spinner_node_id=spinner_node_id,
width=width,
height=height,
rendered_svg=rendered_svg,
)
self._spinner_frames = spinner_frames

self._animator = SpinnerAnimator(
frames=await asyncio.to_thread(lambda: spinner_frames.frames),
interval_ms=spinner_frames.interval_ms,
frames=frames,
interval_ms=SPINNER_INTERVAL_MS,
push_fn=self._push_fn,
)
await self._animator.start()
Expand Down
Loading
Loading