diff --git a/docs/guides/creating-dui-packages.md b/docs/guides/creating-dui-packages.md
index 28c6224..f575f25 100644
--- a/docs/guides/creating-dui-packages.md
+++ b/docs/guides/creating-dui-packages.md
@@ -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
-
-```
-
-### 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 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
+
```
-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
@@ -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")
@@ -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
@@ -637,10 +574,7 @@ version: 1
layout: layout.svg
spinner:
- type: rotation
- node: loading_ring
- frames: 8
- interval_ms: 100
+ node: spinner
bindings:
label:
@@ -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.
diff --git a/src/deux/dui/__init__.py b/src/deux/dui/__init__.py
index 99bbae2..eb950a4 100644
--- a/src/deux/dui/__init__.py
+++ b/src/deux/dui/__init__.py
@@ -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__ = [
@@ -92,10 +100,10 @@ async def handle():
"Region",
"RotateTransform",
"SliderBinding",
+ "SPINNER_FRAME_COUNT",
+ "SPINNER_INTERVAL_MS",
"SpinnerAnimator",
- "SpinnerFrames",
"SpinnerSpec",
- "SpinnerType",
"SvgRenderer",
"TextBinding",
"ToggleBinding",
@@ -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",
diff --git a/src/deux/dui/card.py b/src/deux/dui/card.py
index ad3c124..bb4e144 100644
--- a/src/deux/dui/card.py
+++ b/src/deux/dui/card.py
@@ -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:
@@ -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
@@ -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()
diff --git a/src/deux/dui/key.py b/src/deux/dui/key.py
index 9be333c..95c4d8a 100644
--- a/src/deux/dui/key.py
+++ b/src/deux/dui/key.py
@@ -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:
@@ -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
@@ -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()
diff --git a/src/deux/dui/loader.py b/src/deux/dui/loader.py
index ade0c6d..fa290d5 100644
--- a/src/deux/dui/loader.py
+++ b/src/deux/dui/loader.py
@@ -13,8 +13,6 @@
from .schema import (
DEFAULT_HOLD_MS,
DEFAULT_MAX_DURATION_MS,
- DEFAULT_SPINNER_FRAMES,
- DEFAULT_SPINNER_INTERVAL_MS,
HOLD_SOURCES,
KNOWN_MANIFEST_KEYS,
TURN_SOURCES,
@@ -40,7 +38,6 @@
RotateTransform,
SliderBinding,
SpinnerSpec,
- SpinnerType,
TextBinding,
ToggleBinding,
TransformBinding,
@@ -696,9 +693,16 @@ def _parse_event(raw: dict[str, Any], index: int) -> EventMapping:
)
+_SPINNER_REMOVED_KEYS = ("type", "frames", "interval_ms", "background_node")
+
+
def _parse_spinner(raw: dict[str, Any]) -> SpinnerSpec:
"""Parse a spinner configuration from the manifest.
+ The spinner schema is intentionally minimal: only ``node`` is
+ accepted. All animation parameters (frame count, interval,
+ background) are owned by the library and are not configurable.
+
Parameters
----------
raw : dict[str, Any]
@@ -712,47 +716,29 @@ def _parse_spinner(raw: dict[str, Any]) -> SpinnerSpec:
Raises
------
PackageError
- If the spinner type is missing/invalid or constraints are violated.
+ If ``node`` is missing/invalid, or if any removed legacy key
+ (``type``, ``frames``, ``interval_ms``, ``background_node``)
+ is present.
"""
- raw_type = raw.get("type")
- if raw_type is None:
- raise PackageError("Spinner missing 'type'")
- try:
- spinner_type = SpinnerType(raw_type)
- except ValueError:
- valid = [t.value for t in SpinnerType]
- raise PackageError(
- f"Invalid spinner type '{raw_type}'. Valid types: {valid}"
- ) from None
-
- node = raw.get("node")
- if spinner_type in (SpinnerType.ROTATION, SpinnerType.PULSE):
- if not node or not isinstance(node, str):
+ for legacy in _SPINNER_REMOVED_KEYS:
+ if legacy in raw:
raise PackageError(
- f"Spinner type '{raw_type}' requires a 'node' (SVG element ID)"
+ f"Spinner key '{legacy}' is no longer supported. "
+ f"The spinner is now a single library-owned rotation "
+ f"animation; only 'node' may be configured."
)
- elif node is not None and not isinstance(node, str):
- raise PackageError("Spinner 'node' must be a string if provided")
-
- frames = raw.get("frames", DEFAULT_SPINNER_FRAMES)
- if not isinstance(frames, int) or isinstance(frames, bool) or frames < 2:
- raise PackageError("Spinner 'frames' must be an integer >= 2")
- interval_ms = raw.get("interval_ms", DEFAULT_SPINNER_INTERVAL_MS)
- if not isinstance(interval_ms, int) or isinstance(interval_ms, bool) or interval_ms < 10:
- raise PackageError("Spinner 'interval_ms' must be an integer >= 10")
+ node = raw.get("node")
+ if not node or not isinstance(node, str):
+ raise PackageError("Spinner requires a 'node' (SVG element ID)")
- background_node = raw.get("background_node")
- if background_node is not None and not isinstance(background_node, str):
- raise PackageError("Spinner 'background_node' must be a string if provided")
+ unknown = set(raw) - {"node"}
+ if unknown:
+ raise PackageError(
+ f"Unknown spinner keys: {sorted(unknown)}. Only 'node' is allowed."
+ )
- return SpinnerSpec(
- type=spinner_type,
- node=node,
- frames=frames,
- interval_ms=interval_ms,
- background_node=background_node,
- )
+ return SpinnerSpec(node=node)
def _parse_region(name: str, raw: dict[str, Any]) -> Region:
@@ -1149,9 +1135,9 @@ def _load_assets(pkg_dir: Path) -> dict[str, bytes]:
def _parse_and_validate_spinner(
- manifest: dict[str, Any], svg_ids: set[str], assets: dict[str, bytes]
+ manifest: dict[str, Any], svg_ids: set[str]
) -> SpinnerSpec | None:
- """Parse the ``spinner`` section and validate referenced nodes/assets.
+ """Parse the ``spinner`` section and validate the referenced node.
Parameters
----------
@@ -1159,8 +1145,6 @@ def _parse_and_validate_spinner(
Parsed manifest mapping.
svg_ids : set[str]
Set of element IDs available in the layout SVG.
- assets : dict[str, bytes]
- Loaded asset catalog, used to verify custom spinner frames.
Returns
-------
@@ -1171,8 +1155,7 @@ def _parse_and_validate_spinner(
Raises
------
PackageError
- If the spinner is malformed, references missing SVG ids, or its
- ``custom`` type lacks the required asset files.
+ If the spinner is malformed or references a missing SVG id.
"""
raw_spinner = manifest.get("spinner")
if raw_spinner is None:
@@ -1181,30 +1164,13 @@ def _parse_and_validate_spinner(
raise PackageError("'spinner' must be a mapping")
spinner = _parse_spinner(raw_spinner)
- if spinner.node is not None and spinner.node not in svg_ids:
+ if 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)}"
)
- 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)}"
- )
-
- if 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', ...)"
- )
-
return spinner
@@ -1280,7 +1246,7 @@ def load_package(path: str | Path, *, strict: bool = True) -> PackageSpec:
events = _parse_events(manifest)
regions = _parse_regions(manifest)
assets = _load_assets(pkg_dir)
- spinner = _parse_and_validate_spinner(manifest, svg_ids, assets)
+ spinner = _parse_and_validate_spinner(manifest, svg_ids)
logger.info(
"Loaded .dui package '%s' (%s, %d bindings, %d events)",
diff --git a/src/deux/dui/packages/IconKey.dui/layout.svg b/src/deux/dui/packages/IconKey.dui/layout.svg
index fd44f99..3dc628a 100644
--- a/src/deux/dui/packages/IconKey.dui/layout.svg
+++ b/src/deux/dui/packages/IconKey.dui/layout.svg
@@ -21,18 +21,8 @@
Icon Key
-
-
-
+
+
diff --git a/src/deux/dui/packages/IconKey.dui/manifest.yaml b/src/deux/dui/packages/IconKey.dui/manifest.yaml
index 672d2ce..1966893 100644
--- a/src/deux/dui/packages/IconKey.dui/manifest.yaml
+++ b/src/deux/dui/packages/IconKey.dui/manifest.yaml
@@ -41,11 +41,7 @@ bindings:
default: "1"
spinner:
- type: rotation
node: spinner
- frames: 8
- interval_ms: 100
- background_node: spinner_background
events:
- name: click
diff --git a/src/deux/dui/packages/PictureKey.dui/layout.svg b/src/deux/dui/packages/PictureKey.dui/layout.svg
index 873e9c6..e7c7d41 100644
--- a/src/deux/dui/packages/PictureKey.dui/layout.svg
+++ b/src/deux/dui/packages/PictureKey.dui/layout.svg
@@ -12,17 +12,7 @@
Picture Key
-
-
-
+
+
\ No newline at end of file
diff --git a/src/deux/dui/packages/PictureKey.dui/manifest.yaml b/src/deux/dui/packages/PictureKey.dui/manifest.yaml
index fca41c6..1f327f9 100644
--- a/src/deux/dui/packages/PictureKey.dui/manifest.yaml
+++ b/src/deux/dui/packages/PictureKey.dui/manifest.yaml
@@ -38,11 +38,7 @@ bindings:
default: false
spinner:
- type: rotation
node: spinner
- frames: 8
- interval_ms: 100
- background_node: spinner_background
events:
- name: click
diff --git a/src/deux/dui/schema.py b/src/deux/dui/schema.py
index a72d1fc..7d90a02 100644
--- a/src/deux/dui/schema.py
+++ b/src/deux/dui/schema.py
@@ -14,14 +14,6 @@ class PackageType(Enum):
KEY = "Key"
-class SpinnerType(Enum):
- """Animation strategy for the busy spinner."""
-
- ROTATION = "rotation"
- PULSE = "pulse"
- CUSTOM = "custom"
-
-
class BindingType(Enum):
"""Supported binding types for SVG node manipulation."""
@@ -459,49 +451,30 @@ class Region:
events: tuple[str, ...] = ()
-DEFAULT_SPINNER_FRAMES: int = 12
-"""Default number of frames in a spinner animation cycle."""
-
-DEFAULT_SPINNER_INTERVAL_MS: int = 80
-"""Default interval (ms) between spinner animation frames."""
-
-
@dataclass(frozen=True, slots=True)
class SpinnerSpec:
"""Configuration for a spinner animation.
- The spinner is started and stopped explicitly by the application
- via :meth:`~deux.dui.card.DuiCard.start_busy` /
- :meth:`~deux.dui.card.DuiCard.finish_busy` (and the equivalent
- methods on :class:`~deux.dui.key.DuiKey`). It provides visual
- feedback by cycling pre-rendered animation frames on the key or
- card panel.
+ The spinner is a single, library-owned rotation animation that
+ indicates a busy state. Packages declare an empty
+ ```` placeholder in their layout SVG;
+ the library injects a canonical 8-frame rotation animation into
+ that placeholder when :meth:`~deux.dui.card.DuiCard.start_busy`
+ (or the equivalent on :class:`~deux.dui.key.DuiKey`) is called.
+
+ The placeholder's ``transform`` attribute (typically
+ ``translate(cx cy)``) is honored, so authors control where the
+ spinner appears. Inner geometry (background tile, 8 radial bars,
+ rotation step, frame interval) is fixed by the library.
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.
+ SVG element ID of the placeholder group that the library
+ replaces with the canonical spinner content.
"""
- type: SpinnerType
- node: str | None = None
- frames: int = DEFAULT_SPINNER_FRAMES
- interval_ms: int = DEFAULT_SPINNER_INTERVAL_MS
- background_node: str | None = None
+ node: str
VALID_CATEGORIES = frozenset(
diff --git a/src/deux/dui/spinner.py b/src/deux/dui/spinner.py
index 3cf93c7..f90955d 100644
--- a/src/deux/dui/spinner.py
+++ b/src/deux/dui/spinner.py
@@ -1,390 +1,385 @@
-"""Spinner frame generation for busy-state animations."""
+"""Library-owned spinner frame generation for busy-state animations.
+
+The spinner is a fixed 8-frame, 360°-rotation animation rendered into
+a caller-provided placeholder ```` element in a package's
+layout SVG. All geometry (background tile + 8 radial bars) is owned
+by the library; package authors only choose where the placeholder is
+positioned via its own ``transform`` attribute.
+
+Frames are cached process-wide in an LRU keyed by
+``(rendered_svg, spinner_node_id, width, height, image_format,
+bg_signature)`` so that repeated busy cycles on the same panel reuse
+the same frame bytes.
+"""
from __future__ import annotations
import copy
+import hashlib
import io
import logging
import xml.etree.ElementTree as ET
+from collections import OrderedDict
from typing import TYPE_CHECKING
from PIL import Image
from .._xml import safe_fromstring
-from .schema import SpinnerType
from .svg_renderer import _find_element_by_id
if TYPE_CHECKING:
- from .schema import PackageSpec, SpinnerSpec
+ pass
logger = logging.getLogger(__name__)
_SVG_NS = "http://www.w3.org/2000/svg"
+#: Number of frames in the canonical spinner animation (one per 45°).
+SPINNER_FRAME_COUNT: int = 8
+
+#: Milliseconds between frames in the canonical spinner animation.
+SPINNER_INTERVAL_MS: int = 100
+
+#: Maximum number of frame-lists held in the process-wide LRU cache.
+_CACHE_MAX_ENTRIES: int = 64
+
+#: Canonical spinner content as an SVG group fragment. Coordinates are
+#: centred around (0, 0) so the placeholder's ``transform`` attribute
+#: positions it. One bar is tinted ``#dedede`` (the "head" of the
+#: rotation); the remaining seven are ``#5c5b5b``. An opaque rounded
+#: rectangle behind the bars provides contrast against arbitrary panel
+#: backgrounds.
+_SPINNER_TEMPLATE_SVG: str = (
+ f''
+ ''
+ ''
+ f'"
+)
+
+
+_CacheKey = tuple[str, str, int, int, str, str]
+_cache: OrderedDict[_CacheKey, list[bytes]] = OrderedDict()
+
+
+def _bg_signature(bg_tile: bytes | Image.Image | None) -> str:
+ """Compute a stable digest for a background tile.
+
+ Parameters
+ ----------
+ bg_tile : bytes, PIL.Image.Image, or None
+ The background image data (or ``None`` if no tile is set).
+
+ Returns
+ -------
+ str
+ Hex digest uniquely identifying the tile, or ``"none"`` when
+ no tile is provided.
+ """
+ if bg_tile is None:
+ return "none"
+ if isinstance(bg_tile, bytes):
+ return hashlib.sha1(bg_tile, usedforsecurity=False).hexdigest()
+ buf = io.BytesIO()
+ bg_tile.save(buf, format="PNG")
+ return hashlib.sha1(buf.getvalue(), usedforsecurity=False).hexdigest()
+
-class SpinnerFrames:
- """Pre-renders spinner animation frames from an SVG template.
+def clear_cache() -> None:
+ """Drop all cached spinner frames.
+
+ Intended for use by tests and by application code that wants to
+ release memory held by the spinner LRU.
+ """
+ _cache.clear()
- Frames are generated lazily on first access and cached for the
- lifetime of the instance.
- When a *bg_tile* is provided, each frame is composited onto the
- background tile before encoding so that transparent regions of
- the spinner reveal the touchstrip background underneath.
+def get_frames(
+ *,
+ rendered_svg: str,
+ spinner_node_id: str,
+ width: int,
+ height: int,
+ image_format: str = "JPEG",
+ bg_tile: bytes | Image.Image | None = None,
+) -> list[bytes]:
+ """Return the 8 encoded frames of the canonical spinner animation.
+
+ The result is cached process-wide in an LRU keyed by the rendered
+ SVG, target dimensions, image format, and background-tile
+ signature. Cache hits return the same ``list`` object that was
+ cached, so callers must not mutate it.
Parameters
----------
- spec
- The package specification containing the SVG source and spinner config.
- width
+ rendered_svg : str
+ Layout SVG with bindings already applied (the same string that
+ would be rasterised for a static render). Must contain an
+ element whose ``id`` matches ``spinner_node_id``.
+ spinner_node_id : str
+ ID of the placeholder group that the library injects spinner
+ content into.
+ width : int
Target image width in pixels.
- height
+ height : int
Target image height in pixels.
- image_format
+ image_format : str, default="JPEG"
+ Output encoding; passed through to the image encoder.
+ bg_tile : bytes, PIL.Image.Image, or None, optional
+ Optional background tile composited beneath each frame to make
+ the spinner blend with the surrounding panel.
+
+ Returns
+ -------
+ list[bytes]
+ ``SPINNER_FRAME_COUNT`` encoded frames, one per 45° rotation
+ step. When the placeholder node is missing from
+ ``rendered_svg``, a list of blank fallback frames is returned.
+ """
+ key: _CacheKey = (
+ hashlib.sha1(rendered_svg.encode("utf-8"), usedforsecurity=False).hexdigest(),
+ spinner_node_id,
+ width,
+ height,
+ image_format.upper(),
+ _bg_signature(bg_tile),
+ )
+ cached = _cache.get(key)
+ if cached is not None:
+ _cache.move_to_end(key)
+ return cached
+
+ frames = _render_frames(
+ rendered_svg=rendered_svg,
+ spinner_node_id=spinner_node_id,
+ width=width,
+ height=height,
+ image_format=image_format,
+ bg_tile=bg_tile,
+ )
+
+ _cache[key] = frames
+ _cache.move_to_end(key)
+ while len(_cache) > _CACHE_MAX_ENTRIES:
+ _cache.popitem(last=False)
+ return frames
+
+
+def _render_frames(
+ *,
+ rendered_svg: str,
+ spinner_node_id: str,
+ width: int,
+ height: int,
+ image_format: str,
+ bg_tile: bytes | Image.Image | None,
+) -> list[bytes]:
+ """Generate the 8 spinner frames without consulting the cache.
+
+ Parameters mirror :func:`get_frames`; see that function for
+ semantics.
+
+ Returns
+ -------
+ list[bytes]
+ Encoded frames, or a fallback list of blank frames when the
+ placeholder node cannot be located.
+ """
+ base_root = safe_fromstring(rendered_svg) # untrusted: .dui package source
+ placeholder = _find_element_by_id(base_root, spinner_node_id)
+ if placeholder is None:
+ logger.warning(
+ "Spinner placeholder '%s' not found in rendered SVG; "
+ "returning blank frames",
+ spinner_node_id,
+ )
+ return _blank_frames(width, height, image_format, bg_tile)
+
+ step = 360.0 / SPINNER_FRAME_COUNT
+ frames: list[bytes] = []
+ for i in range(SPINNER_FRAME_COUNT):
+ angle = step * i
+ root = copy.deepcopy(base_root)
+ target = _find_element_by_id(root, spinner_node_id)
+ if target is None: # defensive; deep-copied tree
+ return _blank_frames(width, height, image_format, bg_tile)
+ target.attrib.pop("display", None)
+ _replace_children(target, _build_spinner_fragment(angle))
+ frames.append(_rasterise(root, width, height, image_format, bg_tile))
+ return frames
+
+
+def _build_spinner_fragment(angle: float) -> ET.Element:
+ """Parse the canonical spinner template for a given rotation angle.
+
+ Parameters
+ ----------
+ angle : float
+ Rotation in degrees applied to the inner rotor group.
+
+ Returns
+ -------
+ xml.etree.ElementTree.Element
+ Root ```` element of the canonical spinner content.
+ """
+ return safe_fromstring(_SPINNER_TEMPLATE_SVG.format(angle=f"{angle:.1f}"))
+
+
+def _replace_children(parent: ET.Element, replacement: ET.Element) -> None:
+ """Replace ``parent``'s children with the children of ``replacement``.
+
+ The canonical spinner template wraps its content in an outer ````
+ purely to satisfy XML parsing; only the inner elements are grafted
+ into the placeholder so that the placeholder's own ``transform``
+ (and any other attributes) remain authoritative.
+
+ Parameters
+ ----------
+ parent : xml.etree.ElementTree.Element
+ Placeholder element whose children are discarded.
+ replacement : xml.etree.ElementTree.Element
+ Container whose children become the new children of ``parent``.
+ """
+ for child in list(parent):
+ parent.remove(child)
+ for child in list(replacement):
+ parent.append(child)
+
+
+def _rasterise(
+ root: ET.Element,
+ width: int,
+ height: int,
+ image_format: str,
+ bg_tile: bytes | Image.Image | None,
+) -> bytes:
+ """Rasterise an SVG tree and composite it onto an optional tile.
+
+ Parameters
+ ----------
+ root : xml.etree.ElementTree.Element
+ SVG document root to render.
+ width : int
+ Output width in pixels.
+ height : int
+ Output height in pixels.
+ image_format : str
Encoding format (``"JPEG"`` or ``"BMP"``).
- rendered_svg
- Optional pre-rendered SVG source with bindings already applied.
- bg_tile
- Optional RGB background tile to composite frames onto.
+ bg_tile : bytes, PIL.Image.Image, or None
+ Optional background tile used to fill transparent regions.
+
+ Returns
+ -------
+ bytes
+ Encoded image bytes in ``image_format``.
"""
+ # Inline import: tests patch ``deux.render.svg_rasterize._svg_to_image``.
+ from ..render.svg_rasterize import _svg_to_image # noqa: PLC0415
- def __init__(
- self,
- spec: PackageSpec,
- width: int,
- height: int,
- image_format: str = "JPEG",
- rendered_svg: str | None = None,
- bg_tile: bytes | Image.Image | 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._bg_tile = bg_tile
- 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."""
- 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 = safe_fromstring(svg_source) # untrusted: .dui package
- 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 = safe_fromstring(svg_source) # untrusted: .dui package
- 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:
- frame_img: Image.Image = Image.open(io.BytesIO(assets[key]))
- if frame_img.size != (self._width, self._height):
- frame_img = frame_img.resize(
- (self._width, self._height), Image.Resampling.LANCZOS
- )
- frames.append(self._composite_on_bg(frame_img.convert("RGBA")))
- 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))
- n_frames = getattr(gif, "n_frames", 1)
-
- frames: list[bytes] = []
- for i in range(n_frames):
- gif.seek(i)
- frame = gif.convert("RGBA")
- if frame.size != (self._width, self._height):
- frame = frame.resize(
- (self._width, self._height), Image.Resampling.LANCZOS
- )
- frames.append(self._composite_on_bg(frame))
-
- 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.
-
- When a background tile is set, blank frames show the background
- tile instead of solid black.
-
- Returns
- -------
- list[bytes]
- Blank or background-filled frames, one per configured spinner
- frame count.
- """
- if self._bg_tile is not None:
- data = self._encode_tile(self._bg_tile)
- else:
- data = self._encode_blank()
- return [data] * self._spinner.frames
-
- def _composite_on_bg(self, frame: Image.Image) -> bytes:
- """Composite a frame onto the background tile if available.
-
- Parameters
- ----------
- frame : Image.Image
- RGBA frame image.
-
- Returns
- -------
- bytes
- Encoded image bytes in the instance's configured format.
- """
- if self._bg_tile is not None:
- if isinstance(self._bg_tile, bytes):
- base = Image.open(io.BytesIO(self._bg_tile)).convert("RGBA")
- else:
- base = self._bg_tile.convert("RGBA")
- result = Image.alpha_composite(base, frame)
+ svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True)
+ frame = _svg_to_image(svg_bytes.encode("utf-8"), width, height, mode="RGBA")
+ return _composite_on_bg(frame, bg_tile, image_format)
+
+
+def _composite_on_bg(
+ frame: Image.Image,
+ bg_tile: bytes | Image.Image | None,
+ image_format: str,
+) -> bytes:
+ """Composite an RGBA frame onto an optional tile and encode it.
+
+ Parameters
+ ----------
+ frame : PIL.Image.Image
+ RGBA frame produced by the SVG rasteriser.
+ bg_tile : bytes, PIL.Image.Image, or None
+ Optional tile composited beneath ``frame``.
+ image_format : str
+ Output encoding.
+
+ Returns
+ -------
+ bytes
+ Encoded image bytes.
+ """
+ if bg_tile is not None:
+ if isinstance(bg_tile, bytes):
+ base = Image.open(io.BytesIO(bg_tile)).convert("RGBA")
else:
- result = frame
-
- return self._encode_pil(result)
-
- def _encode_pil(self, img: Image.Image) -> bytes:
- """Encode a PIL image in the configured format.
-
- Parameters
- ----------
- img
- A ``PIL.Image.Image`` instance.
-
- Returns
- -------
- bytes
- Encoded image bytes.
- """
- # Inline import: tests patch ``deux.render.key_renderer._encode_image_bytes``;
- # importing it lazily keeps that patching point effective.
- from ..render.key_renderer import _encode_image_bytes # noqa: PLC0415
-
- return _encode_image_bytes(img, self._image_format)
-
- def _encode_tile(self, tile: bytes | Image.Image) -> bytes:
- """Re-encode a tile in the configured output format.
-
- Parameters
- ----------
- tile : bytes or Image.Image
- PNG-encoded tile bytes or a PIL Image.
-
- Returns
- -------
- bytes
- Image bytes in the configured format.
- """
- if isinstance(tile, bytes):
- img = Image.open(io.BytesIO(tile)).convert("RGB")
+ base = bg_tile.convert("RGBA")
+ result = Image.alpha_composite(base, frame)
+ else:
+ result = frame
+ return _encode(result, image_format)
+
+
+def _encode(img: Image.Image, image_format: str) -> bytes:
+ """Encode a PIL image in the requested format.
+
+ Parameters
+ ----------
+ img : PIL.Image.Image
+ Image to encode.
+ image_format : str
+ Output format (``"JPEG"`` or ``"BMP"``).
+
+ Returns
+ -------
+ bytes
+ Encoded image bytes.
+ """
+ # Inline import: tests patch ``deux.render.key_renderer._encode_image_bytes``.
+ from ..render.key_renderer import _encode_image_bytes # noqa: PLC0415
+
+ return _encode_image_bytes(img, image_format)
+
+
+def _blank_frames(
+ width: int,
+ height: int,
+ image_format: str,
+ bg_tile: bytes | Image.Image | None,
+) -> list[bytes]:
+ """Return ``SPINNER_FRAME_COUNT`` blank frames as a fallback.
+
+ When a background tile is provided, the blank frames show that
+ tile; otherwise they are solid black.
+
+ Parameters
+ ----------
+ width, height : int
+ Output dimensions.
+ image_format : str
+ Output encoding.
+ bg_tile : bytes, PIL.Image.Image, or None
+ Optional background tile.
+
+ Returns
+ -------
+ list[bytes]
+ ``SPINNER_FRAME_COUNT`` copies of a single blank frame.
+ """
+ if bg_tile is not None:
+ if isinstance(bg_tile, bytes):
+ img = Image.open(io.BytesIO(bg_tile)).convert("RGB")
else:
- img = tile.convert("RGB")
- return self._encode_pil(img)
-
- def _encode_blank(self) -> bytes:
- """Encode a solid black frame.
-
- Returns
- -------
- bytes
- Black frame in the configured format.
- """
- img = Image.new("RGB", (self._width, self._height), (0, 0, 0))
- return self._encode_pil(img)
-
- 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.
- """
- # Inline import: tests patch ``deux.render.svg_rasterize._svg_to_image``;
- # importing it lazily keeps that patching point effective.
- from ..render.svg_rasterize import _svg_to_image # noqa: PLC0415
-
- svg_bytes = ET.tostring(root, encoding="unicode", xml_declaration=True)
- frame = _svg_to_image(
- svg_bytes.encode("utf-8"), self._width, self._height, mode="RGBA"
- )
- return self._composite_on_bg(frame)
-
- @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
+ img = bg_tile.convert("RGB")
+ else:
+ img = Image.new("RGB", (width, height), (0, 0, 0))
+ data = _encode(img, image_format)
+ return [data] * SPINNER_FRAME_COUNT
diff --git a/src/deux/dui/svg_renderer.py b/src/deux/dui/svg_renderer.py
index 2984f5d..ba4a818 100644
--- a/src/deux/dui/svg_renderer.py
+++ b/src/deux/dui/svg_renderer.py
@@ -912,13 +912,13 @@ def render_svg(self) -> str:
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.
+ """Hide the spinner placeholder so it is 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.
+ DUI package authors may not set ``display="none"`` on the
+ spinner placeholder, which would make it visible in every
+ non-busy render. This method forces the node hidden; the
+ spinner frame generator removes ``display="none"`` when
+ producing animation frames.
Parameters
----------
@@ -926,14 +926,10 @@ def _hide_spinner_node(self, root: ET.Element) -> None:
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:
+ if spinner 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")
#: Dispatch table mapping binding types to handler methods.
#: Populated per-instance in :meth:`__init__` (bound to ``self``) so that
diff --git a/tests/test_busy_guard.py b/tests/test_busy_guard.py
index ec02a04..3626389 100644
--- a/tests/test_busy_guard.py
+++ b/tests/test_busy_guard.py
@@ -17,15 +17,15 @@
PackageSpec,
PackageType,
SpinnerSpec,
- SpinnerType,
TextBinding,
)
+from deux.dui.spinner import clear_cache as clear_spinner_cache
_CARD_SVG = (
'"
)
@@ -33,11 +33,18 @@
'"
)
+@pytest.fixture(autouse=True)
+def _reset_spinner_cache():
+ clear_spinner_cache()
+ yield
+ clear_spinner_cache()
+
+
def _fake_png(width: int = 120, height: int = 120) -> bytes:
img = Image.new("RGB", (width, height), "black")
buf = io.BytesIO()
@@ -148,7 +155,7 @@ async def test_card_finish_busy_noop_when_not_busy(self):
side_effect=_fake_image,
)
async def test_card_spinner_starts_and_stops(self, mock_raster):
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_card_spec(spinner=spinner)
card = DuiCard(spec)
@@ -170,7 +177,7 @@ async def test_card_spinner_uses_rendered_svg(self, mock_raster):
bindings: dict[str, Binding] = {
"title": TextBinding(node="title", default="Default"),
}
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_card_spec(spinner=spinner, bindings=bindings)
card = DuiCard(spec)
card.set("title", "Updated")
@@ -255,7 +262,7 @@ async def test_key_finish_busy_noop_when_not_busy(self):
side_effect=_fake_image,
)
async def test_key_spinner_starts_and_stops(self, mock_raster):
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_key_spec(spinner=spinner)
key = DuiKey(spec)
@@ -277,7 +284,7 @@ async def test_key_spinner_uses_rendered_svg(self, mock_raster):
bindings: dict[str, Binding] = {
"label": TextBinding(node="label", default="Key"),
}
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_key_spec(spinner=spinner, bindings=bindings)
key = DuiKey(spec)
key.set("label", "Playing")
@@ -309,39 +316,34 @@ async def test_key_events_dispatch_without_busy(self):
class TestSpinnerManifestValidation:
- def test_invalid_spinner_type(self):
- with pytest.raises(PackageError, match="Invalid spinner type"):
- _parse_spinner({"type": "wobble"})
-
- def test_frames_less_than_2(self):
- with pytest.raises(PackageError, match="frames.*must be an integer >= 2"):
- _parse_spinner({"type": "rotation", "node": "spinner", "frames": 1})
-
- def test_interval_ms_less_than_10(self):
- with pytest.raises(PackageError, match="interval_ms.*must be an integer >= 10"):
- _parse_spinner({"type": "rotation", "node": "spinner", "interval_ms": 5})
-
- def test_rotation_without_node_raises(self):
+ def test_missing_node_raises(self):
with pytest.raises(PackageError, match="requires a 'node'"):
- _parse_spinner({"type": "rotation"})
+ _parse_spinner({})
- def test_pulse_without_node_raises(self):
+ def test_node_not_string_raises(self):
with pytest.raises(PackageError, match="requires a 'node'"):
- _parse_spinner({"type": "pulse"})
-
- def test_custom_without_frames_raises(self, tmp_path):
- """Custom spinner with no asset frames should fail."""
- pkg_dir = tmp_path / "Test.dui"
- pkg_dir.mkdir()
- (pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8")
- manifest = (
- "name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n"
- "spinner:\n type: custom\n"
- )
- (pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8")
+ _parse_spinner({"node": 123})
+
+ def test_valid_node(self):
+ spec = _parse_spinner({"node": "spinner"})
+ assert spec.node == "spinner"
+
+ @pytest.mark.parametrize(
+ "legacy_key,legacy_value",
+ [
+ ("type", "rotation"),
+ ("frames", 8),
+ ("interval_ms", 100),
+ ("background_node", "spinner_bg"),
+ ],
+ )
+ def test_legacy_keys_rejected(self, legacy_key, legacy_value):
+ with pytest.raises(PackageError, match="no longer supported"):
+ _parse_spinner({"node": "spinner", legacy_key: legacy_value})
- with pytest.raises(PackageError, match="custom.*requires"):
- load_package(pkg_dir)
+ def test_unknown_key_rejected(self):
+ with pytest.raises(PackageError, match="Unknown spinner keys"):
+ _parse_spinner({"node": "spinner", "wobble_speed": 5})
def test_spinner_node_not_in_svg_raises(self, tmp_path):
"""Spinner referencing a node not present in SVG should fail."""
@@ -350,51 +352,13 @@ def test_spinner_node_not_in_svg_raises(self, tmp_path):
(pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8")
manifest = (
"name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n"
- "spinner:\n type: rotation\n node: nonexistent\n"
+ "spinner:\n node: nonexistent\n"
)
(pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8")
with pytest.raises(PackageError, match="does not exist in the SVG"):
load_package(pkg_dir)
- def test_background_node_not_string_raises(self):
- """background_node must be a string if provided."""
- with pytest.raises(PackageError, match="background_node.*must be a string"):
- _parse_spinner({
- "type": "rotation",
- "node": "spinner",
- "background_node": 123,
- })
-
- def test_background_node_valid_string(self):
- """background_node as a valid string should parse without error."""
- spec = _parse_spinner({
- "type": "rotation",
- "node": "spinner",
- "background_node": "spinner_bg",
- })
- assert spec.background_node == "spinner_bg"
-
- def test_background_node_none_by_default(self):
- """background_node defaults to None when not specified."""
- spec = _parse_spinner({"type": "rotation", "node": "spinner"})
- assert spec.background_node is None
-
- def test_background_node_not_in_svg_raises(self, tmp_path):
- """Spinner referencing a background_node not present in SVG should fail."""
- pkg_dir = tmp_path / "Test.dui"
- pkg_dir.mkdir()
- (pkg_dir / "layout.svg").write_text(_CARD_SVG, encoding="utf-8")
- manifest = (
- "name: Test\ntype: TouchStripCard\nversion: 1\nlayout: layout.svg\n"
- "spinner:\n type: rotation\n node: spinner\n"
- " background_node: nonexistent_bg\n"
- )
- (pkg_dir / "manifest.yaml").write_text(manifest, encoding="utf-8")
-
- with pytest.raises(PackageError, match="background_node.*does not exist in the SVG"):
- load_package(pkg_dir)
-
# ── Cancellation during in-flight rasterisation ─────────────────────
@@ -410,7 +374,7 @@ async def test_card_cancel_during_spinner_start(self, mock_raster):
"""Cancelling the task that runs start_busy should not leave broken state."""
import asyncio
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_card_spec(spinner=spinner)
card = DuiCard(spec)
push_fn = AsyncMock()
@@ -435,7 +399,7 @@ async def test_key_cancel_during_spinner_start(self, mock_raster):
"""Cancelling the task that runs start_busy on a key should not leave broken state."""
import asyncio
- spinner = SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
+ spinner = SpinnerSpec(node="spinner")
spec = _make_key_spec(spinner=spinner)
key = DuiKey(spec)
push_fn = AsyncMock()
diff --git a/tests/test_dui_svg_renderer.py b/tests/test_dui_svg_renderer.py
index 5106899..0a766b0 100644
--- a/tests/test_dui_svg_renderer.py
+++ b/tests/test_dui_svg_renderer.py
@@ -21,7 +21,6 @@
RangeDirection,
SliderBinding,
SpinnerSpec,
- SpinnerType,
TextBinding,
ToggleBinding,
VisibilityBinding,
@@ -2070,7 +2069,7 @@ def test_render_hides_visible_spinner_node(self):
type=PackageType.KEY,
version=1,
svg_source=_SPINNER_VISIBLE_SVG,
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8),
+ spinner=SpinnerSpec(node="spinner"),
)
renderer = SvgRenderer(spec)
svg = renderer.render_svg()
@@ -2257,63 +2256,3 @@ def test_separator_gets_inactive_attrs(self):
# Separator should have inactive fill
assert svg.count('fill="#888888"') == 2 # B + separator
-
-class TestSpinnerBackgroundNodeHidden:
- """Spinner background_node must be hidden in non-busy renders."""
-
- def test_render_hides_visible_background_node(self):
- """render_svg() sets display='none' on the background_node."""
- spec = PackageSpec(
- name="Test",
- type=PackageType.KEY,
- version=1,
- svg_source=_SPINNER_VISIBLE_SVG,
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION,
- node="spinner",
- frames=8,
- background_node="spinner_bg",
- ),
- )
- renderer = SvgRenderer(spec)
- svg = renderer.render_svg()
- import xml.etree.ElementTree as ET
-
- root = ET.fromstring(svg) # noqa: S314
- ns = {"svg": "http://www.w3.org/2000/svg"}
- bg = root.find('.//svg:rect[@id="spinner_bg"]', ns)
- if bg is None:
- bg = root.find('.//rect[@id="spinner_bg"]')
- assert bg is not None
- assert bg.get("display") == "none"
-
- def test_no_background_node_no_error(self):
- """Without background_node, render proceeds normally."""
- spec = PackageSpec(
- name="Test",
- type=PackageType.KEY,
- version=1,
- svg_source=_SPINNER_VISIBLE_SVG,
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8),
- )
- renderer = SvgRenderer(spec)
- svg = renderer.render_svg()
- assert svg
-
- def test_missing_background_node_no_error(self):
- """If background_node references a missing element, no crash."""
- spec = PackageSpec(
- name="Test",
- type=PackageType.KEY,
- version=1,
- svg_source=_SPINNER_VISIBLE_SVG,
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION,
- node="spinner",
- frames=8,
- background_node="nonexistent_bg",
- ),
- )
- renderer = SvgRenderer(spec)
- svg = renderer.render_svg()
- assert svg
diff --git a/tests/test_spinner.py b/tests/test_spinner.py
index 45d1bbf..c23192a 100644
--- a/tests/test_spinner.py
+++ b/tests/test_spinner.py
@@ -1,4 +1,4 @@
-"""Tests for deux.dui.spinner — SpinnerFrames class."""
+"""Tests for ``deux.dui.spinner`` — library-owned spinner frame generation."""
from __future__ import annotations
@@ -9,36 +9,24 @@
import pytest
from PIL import Image
-from deux.dui.schema import (
- PackageSpec,
- PackageType,
- SpinnerSpec,
- SpinnerType,
+from deux.dui.spinner import (
+ SPINNER_FRAME_COUNT,
+ SPINNER_INTERVAL_MS,
+ _bg_signature,
+ _build_spinner_fragment,
+ clear_cache,
+ get_frames,
)
-from deux.dui.spinner import SpinnerFrames
_SPINNER_SVG = (
'"
)
-def _fake_png(width: int = 120, height: int = 120) -> bytes:
- """Return a minimal valid PNG."""
- img = Image.new("RGB", (width, height), "black")
- buf = io.BytesIO()
- img.save(buf, format="PNG")
- return buf.getvalue()
-
-
def _fake_image(
svg_data: bytes,
width: int = 120,
@@ -51,349 +39,145 @@ def _fake_image(
return Image.new(mode, (width, height), "black")
-def _make_spec(
- spinner: SpinnerSpec | None = None,
- assets: dict[str, bytes] | None = None,
- svg: str = _SPINNER_SVG,
-) -> PackageSpec:
- return PackageSpec(
- name="TestSpinner",
- type=PackageType.KEY,
- version=1,
- svg_source=svg,
- spinner=spinner,
- assets=assets or {},
- )
-
-
-class TestRotation:
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_rotation_generates_correct_frame_count(self, mock_raster):
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=8)
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 8
+@pytest.fixture(autouse=True)
+def _reset_cache():
+ clear_cache()
+ yield
+ clear_cache()
- def test_element_centre_calculation_rect(self):
- """Rect at x=80,y=30,w=30,h=30 → centre (95, 45)."""
- elem = ET.fromstring(
- ''
- )
- cx, cy = SpinnerFrames._element_centre(elem)
- assert cx == pytest.approx(95.0)
- assert cy == pytest.approx(45.0)
- def test_element_centre_calculation_circle(self):
- """Circle with cx=60, cy=60 → centre (60, 60)."""
- elem = ET.fromstring(
- ''
- )
- cx, cy = SpinnerFrames._element_centre(elem)
- assert cx == pytest.approx(60.0)
- assert cy == pytest.approx(60.0)
+class TestGetFrames:
+ """Behaviour of the ``get_frames`` entry point."""
-
-class TestPulse:
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_pulse_generates_correct_frame_count(self, mock_raster):
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=6)
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 6
-
-
-class TestCustom:
- def test_custom_from_png_files(self):
- """Custom spinner loads numbered PNGs from assets."""
- assets = {}
- for i in range(4):
- assets[f"spinner/frame_{i:02d}.png"] = _fake_png(120, 120)
-
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=4),
- assets=assets,
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- frames = sf.frames
- assert len(frames) == 4
- for f in frames:
- assert isinstance(f, bytes)
- assert len(f) > 0
-
- def test_custom_from_animated_gif(self):
- """Custom spinner loads frames from an animated GIF."""
- # Create a 3-frame animated GIF
- imgs = [Image.new("RGB", (120, 120), color) for color in ["red", "green", "blue"]]
- buf = io.BytesIO()
- imgs[0].save(buf, format="GIF", save_all=True, append_images=imgs[1:], loop=0)
- gif_bytes = buf.getvalue()
-
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=3),
- assets={"spinner.gif": gif_bytes},
+ def test_returns_eight_frames(self, mock_raster):
+ frames = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=120,
+ height=120,
)
- sf = SpinnerFrames(spec, width=120, height=120)
- frames = sf.frames
- assert len(frames) == 3
+ assert len(frames) == SPINNER_FRAME_COUNT == 8
+ assert all(isinstance(f, bytes) and f for f in frames)
-
-class TestCaching:
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_frames_are_cached_after_first_access(self, mock_raster):
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- first = sf.frames
- second = sf.frames
- assert first is second
- # _generate called only once
- assert mock_raster.call_count == 4 # once per frame, not 8
+ def test_constants(self, mock_raster):
+ assert SPINNER_FRAME_COUNT == 8
+ assert SPINNER_INTERVAL_MS == 100
-
-class TestRenderedSvg:
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_rotation_uses_rendered_svg_when_provided(self, mock_raster):
- """When rendered_svg is passed, spinner frames use it instead of raw svg_source."""
- rendered = (
- '"
- )
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2)
- )
- sf = SpinnerFrames(spec, width=120, height=120, rendered_svg=rendered)
- assert len(sf.frames) == 2
-
- # Verify the rendered SVG was used — it should contain "Updated Title"
- first_call_svg: bytes = mock_raster.call_args_list[0][0][0]
- assert b"Updated Title" in first_call_svg
+ def test_each_frame_has_distinct_rotation(self, mock_raster):
+ get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=120,
+ height=120,
+ )
+ assert mock_raster.call_count == SPINNER_FRAME_COUNT
+ rotations: list[str] = []
+ for call in mock_raster.call_args_list:
+ svg_bytes: bytes = call.args[0]
+ root = ET.fromstring(svg_bytes) # noqa: S314
+ target = root.find('.//{http://www.w3.org/2000/svg}g[@id="spinner"]')
+ assert target is not None
+ rotor = target.find('.//{http://www.w3.org/2000/svg}g[@class="deux-spinner-rotor"]')
+ assert rotor is not None
+ transform = rotor.get("transform", "")
+ rotations.append(transform)
+ assert len(set(rotations)) == SPINNER_FRAME_COUNT
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_pulse_uses_rendered_svg_when_provided(self, mock_raster):
- rendered = (
- '"
- )
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=2)
- )
- sf = SpinnerFrames(spec, width=120, height=120, rendered_svg=rendered)
- assert len(sf.frames) == 2
-
- first_call_svg: bytes = mock_raster.call_args_list[0][0][0]
- assert b"Rendered Content" in first_call_svg
+ def test_spinner_is_made_visible_in_frames(self, mock_raster):
+ svg_with_hidden = _SPINNER_SVG.replace(
+ '',
+ '',
+ )
+ get_frames(
+ rendered_svg=svg_with_hidden,
+ spinner_node_id="spinner",
+ width=120,
+ height=120,
+ )
+ for call in mock_raster.call_args_list:
+ svg_bytes: bytes = call.args[0]
+ root = ET.fromstring(svg_bytes) # noqa: S314
+ target = root.find('.//{http://www.w3.org/2000/svg}g[@id="spinner"]')
+ assert target is not None
+ assert target.get("display") is None
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_falls_back_to_svg_source_without_rendered(self, mock_raster):
- """Without rendered_svg, spinner uses the raw svg_source."""
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=2)
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 2
- # Raw SVG should NOT contain "Updated Title"
- first_call_svg: bytes = mock_raster.call_args_list[0][0][0]
- assert b"Updated Title" not in first_call_svg
-
-
-class TestErrors:
- def test_no_spinner_spec_raises(self):
- spec = _make_spec(spinner=None)
- with pytest.raises(ValueError, match="no spinner configuration"):
- SpinnerFrames(spec, width=120, height=120)
+ def test_missing_node_returns_blank_frames(self, mock_raster):
+ frames = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="nonexistent",
+ width=120,
+ height=120,
+ )
+ assert len(frames) == SPINNER_FRAME_COUNT
+ # All blank frames are identical
+ assert len(set(frames)) == 1
+ # Rasteriser is not called when placeholder is missing
+ mock_raster.assert_not_called()
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_blank_frames_fallback_for_missing_node(self, mock_raster):
- """When the spinner node ID doesn't exist in the SVG, return blank frames."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION, node="nonexistent", frames=4
- )
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- frames = sf.frames
- assert len(frames) == 4
- # All frames should be identical (blank)
- assert all(f == frames[0] for f in frames)
-
-
-class TestBackgroundNode:
- """Background node is shown (not animated) during spinner frames."""
-
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_rotation_shows_background_node(self, mock_raster):
- """Rotation frames should unhide the background_node."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION,
- node="spinner",
- frames=2,
- background_node="spinner_background",
- )
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 2
-
- # Inspect the SVG passed to rasteriser — background should be visible
- first_svg: bytes = mock_raster.call_args_list[0][0][0]
- assert b'id="spinner_background"' in first_svg
- # display="none" should have been removed from the background node
+ def test_canonical_geometry_is_injected(self, mock_raster):
+ get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=120,
+ height=120,
+ )
+ first_svg: bytes = mock_raster.call_args_list[0].args[0]
+ # Background rect (from library canonical template)
+ assert b'fill-opacity="0.8"' in first_svg
+ # 8 rotation bars
root = ET.fromstring(first_svg) # noqa: S314
- bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]')
- if bg is None:
- bg = root.find('.//rect[@id="spinner_background"]')
- assert bg is not None
- assert bg.get("display") is None
-
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_rotation_background_node_not_rotated(self, mock_raster):
- """Background node must not receive a rotation transform."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION,
- node="spinner",
- frames=4,
- background_node="spinner_background",
- )
+ bars = root.findall(
+ './/{http://www.w3.org/2000/svg}g[@id="spinner"]'
+ "//{http://www.w3.org/2000/svg}rect"
)
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 4
-
- # Check last frame (non-zero rotation) — background should have no transform
- last_svg: bytes = mock_raster.call_args_list[3][0][0]
- root = ET.fromstring(last_svg) # noqa: S314
- bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]')
- if bg is None:
- bg = root.find('.//rect[@id="spinner_background"]')
- assert bg is not None
- assert "rotate" not in (bg.get("transform") or "")
-
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_pulse_shows_background_node(self, mock_raster):
- """Pulse frames should unhide the background_node."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.PULSE,
- node="spinner",
- frames=2,
- background_node="spinner_background",
- )
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 2
-
- first_svg: bytes = mock_raster.call_args_list[0][0][0]
- root = ET.fromstring(first_svg) # noqa: S314
- bg = root.find('.//{http://www.w3.org/2000/svg}rect[@id="spinner_background"]')
- if bg is None:
- bg = root.find('.//rect[@id="spinner_background"]')
- assert bg is not None
- assert bg.get("display") is None
- # Background should not have opacity set
- assert bg.get("opacity") is None
+ # 1 background + 8 bars in the canonical template
+ assert len(bars) >= 8
@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_missing_background_node_no_error(self, mock_raster):
- """If background_node references a missing element, no crash occurs."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION,
- node="spinner",
- frames=2,
- background_node="nonexistent_bg",
- )
- )
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 2
+ def test_blank_frames_use_bg_tile_when_provided(self, mock_raster):
+ tile = Image.new("RGB", (120, 120), (50, 50, 50))
+ buf = io.BytesIO()
+ tile.save(buf, format="PNG")
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_no_background_node_works(self, mock_raster):
- """Spinner without background_node still works normally."""
- spec = _make_spec(
- spinner=SpinnerSpec(
- type=SpinnerType.ROTATION, node="spinner", frames=2
- )
+ frames = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="missing",
+ width=120,
+ height=120,
+ bg_tile=buf.getvalue(),
)
- sf = SpinnerFrames(spec, width=120, height=120)
- assert len(sf.frames) == 2
-
+ assert len(frames) == SPINNER_FRAME_COUNT
+ assert all(f == frames[0] for f in frames)
-class TestSpinnerWithBgTile:
- """Spinner frames composited onto a background tile."""
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_rotation_with_bg_tile(self, mock_raster):
- tile = Image.new("RGB", (120, 120), (255, 0, 0))
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="spinner", frames=4)
- )
- sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile)
- assert len(sf.frames) == 4
- for frame_bytes in sf.frames:
- img = Image.open(io.BytesIO(frame_bytes))
- assert img.size == (120, 120)
+class TestBuildSpinnerFragment:
+ def test_fragment_parses_with_angle(self):
+ elem = _build_spinner_fragment(45.0)
+ assert elem.tag == "{http://www.w3.org/2000/svg}g"
+ rotor = elem.find('.//{http://www.w3.org/2000/svg}g[@class="deux-spinner-rotor"]')
+ assert rotor is not None
+ assert "rotate(45.0)" in (rotor.get("transform") or "")
- @patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
- def test_pulse_with_bg_tile(self, mock_raster):
- tile = Image.new("RGB", (120, 120), (0, 255, 0))
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.PULSE, node="spinner", frames=4)
- )
- sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile)
- assert len(sf.frames) == 4
- def test_blank_frames_with_bg_tile(self):
- """Blank fallback frames use the background tile instead of black."""
- tile = Image.new("RGB", (120, 120), (0, 0, 255))
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.ROTATION, node="nonexistent", frames=2)
- )
- sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile)
- for frame_bytes in sf.frames:
- img = Image.open(io.BytesIO(frame_bytes))
- r, g, b = img.getpixel((60, 60))
- assert b > 200
- assert r < 50
+class TestBgSignature:
+ def test_none_returns_constant(self):
+ assert _bg_signature(None) == "none"
- def test_custom_frames_with_bg_tile(self):
- """Custom PNG frames are composited onto the background tile."""
- tile = Image.new("RGB", (120, 120), (255, 0, 0))
- frame_img = Image.new("RGBA", (120, 120), (0, 255, 0, 128))
- buf = io.BytesIO()
- frame_img.save(buf, format="PNG")
- frame_png = buf.getvalue()
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=1),
- assets={"spinner/frame_00.png": frame_png},
- )
- sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile)
- assert len(sf.frames) == 1
+ def test_bytes_returns_digest(self):
+ sig = _bg_signature(b"abc")
+ assert sig != "none"
+ assert _bg_signature(b"abc") == sig
- def test_gif_frames_with_bg_tile(self):
- """GIF frames are composited onto the background tile."""
- tile = Image.new("RGB", (120, 120), (255, 0, 0))
- f1 = Image.new("RGB", (120, 120), (0, 255, 0))
- f2 = Image.new("RGB", (120, 120), (0, 0, 255))
- buf = io.BytesIO()
- f1.save(buf, format="GIF", save_all=True, append_images=[f2], loop=0)
- gif_data = buf.getvalue()
- spec = _make_spec(
- spinner=SpinnerSpec(type=SpinnerType.CUSTOM, frames=2),
- assets={"spinner.gif": gif_data},
- )
- sf = SpinnerFrames(spec, width=120, height=120, bg_tile=tile)
- assert len(sf.frames) == 2
+ def test_image_returns_digest(self):
+ img = Image.new("RGB", (5, 5), (1, 2, 3))
+ sig = _bg_signature(img)
+ assert sig != "none"
+ # Same image bytes → same signature
+ img2 = Image.new("RGB", (5, 5), (1, 2, 3))
+ assert _bg_signature(img2) == sig
diff --git a/tests/test_spinner_cache.py b/tests/test_spinner_cache.py
new file mode 100644
index 0000000..b45ab1b
--- /dev/null
+++ b/tests/test_spinner_cache.py
@@ -0,0 +1,188 @@
+"""Tests for the process-wide spinner frame LRU cache."""
+
+from __future__ import annotations
+
+import io
+from unittest.mock import patch
+
+import pytest
+from PIL import Image
+
+from deux.dui import spinner as spinner_mod
+from deux.dui.spinner import (
+ SPINNER_FRAME_COUNT,
+ _bg_signature,
+ clear_cache,
+ get_frames,
+)
+
+_SPINNER_SVG = (
+ '"
+)
+
+_SPINNER_SVG_ALT = (
+ '"
+)
+
+
+def _fake_image(svg_data, width, height, *, mode="RGBA", ctx=None):
+ return Image.new(mode, (width, height), "black")
+
+
+@pytest.fixture(autouse=True)
+def _reset_cache():
+ clear_cache()
+ yield
+ clear_cache()
+
+
+@patch("deux.render.svg_rasterize._svg_to_image", side_effect=_fake_image)
+class TestCacheBehaviour:
+ """LRU cache identity, eviction, and key composition."""
+
+ def test_repeat_call_reuses_cached_frames(self, mock_raster):
+ first = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ calls = mock_raster.call_count
+ second = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ assert second is first
+ assert mock_raster.call_count == calls # no extra rasterisations
+
+ def test_different_svg_creates_distinct_entry(self, mock_raster):
+ a = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ b = get_frames(
+ rendered_svg=_SPINNER_SVG_ALT,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ assert b is not a
+ assert mock_raster.call_count == 2 * SPINNER_FRAME_COUNT
+
+ def test_different_size_creates_distinct_entry(self, mock_raster):
+ a = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ b = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=128,
+ height=128,
+ )
+ assert b is not a
+
+ def test_different_format_creates_distinct_entry(self, mock_raster):
+ a = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ image_format="JPEG",
+ )
+ b = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ image_format="BMP",
+ )
+ assert b is not a
+
+ def test_different_bg_tile_creates_distinct_entry(self, mock_raster):
+ tile1 = Image.new("RGB", (64, 64), (0, 0, 0))
+ buf1 = io.BytesIO()
+ tile1.save(buf1, format="PNG")
+ tile2 = Image.new("RGB", (64, 64), (200, 200, 200))
+ buf2 = io.BytesIO()
+ tile2.save(buf2, format="PNG")
+
+ a = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ bg_tile=buf1.getvalue(),
+ )
+ b = get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ bg_tile=buf2.getvalue(),
+ )
+ assert a is not b
+
+ def test_clear_cache_evicts_all(self, mock_raster):
+ get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ clear_cache()
+ before = mock_raster.call_count
+ get_frames(
+ rendered_svg=_SPINNER_SVG,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ assert mock_raster.call_count == before + SPINNER_FRAME_COUNT
+
+ def test_lru_eviction(self, mock_raster, monkeypatch):
+ monkeypatch.setattr(spinner_mod, "_CACHE_MAX_ENTRIES", 2)
+
+ svgs = [
+ _SPINNER_SVG,
+ _SPINNER_SVG_ALT,
+ _SPINNER_SVG.replace("100", "101"),
+ ]
+ for svg in svgs:
+ get_frames(
+ rendered_svg=svg,
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ assert len(spinner_mod._cache) == 2
+
+ # Oldest (svgs[0]) was evicted → re-fetching forces re-render.
+ before = mock_raster.call_count
+ get_frames(
+ rendered_svg=svgs[0],
+ spinner_node_id="spinner",
+ width=64,
+ height=64,
+ )
+ assert mock_raster.call_count == before + SPINNER_FRAME_COUNT
+
+
+class TestBgSignatureStability:
+ def test_same_bytes_same_signature(self):
+ a = b"\x00\x01\x02"
+ assert _bg_signature(a) == _bg_signature(a)
+
+ def test_different_bytes_different_signature(self):
+ assert _bg_signature(b"a") != _bg_signature(b"b")