Skip to content

Ui

ui

UI-layer exports for screens, slots, cards, and controls.

BlankCard

Bases: Card

A card that renders nothing, letting the touchstrip background show through.

Used as the default placeholder in :class:~deux.ui.touch_strip.TouchStrip before the user assigns real cards. Returns None from :meth:render so the compositor skips the slot and the :attr:~deux.ui.touch_strip.TouchStrip.background_color is visible.

Source code in src/deux/ui/cards/blank.py
class BlankCard(Card):
    """A card that renders nothing, letting the touchstrip background show through.

    Used as the default placeholder in :class:`~deux.ui.touch_strip.TouchStrip`
    before the user assigns real cards.  Returns ``None`` from :meth:`render`
    so the compositor skips the slot and the
    :attr:`~deux.ui.touch_strip.TouchStrip.background_color` is visible.
    """

    def render(self) -> None:
        return None

Card

Bases: ABC

Abstract base for a single touch-strip zone under an encoder.

The touchscreen is divided into zones aligned with the device's encoders. Cards are tiled edge-to-edge — the library imposes no margins or gaps. Each zone is touchscreen_width // panel_count wide and touchscreen_height tall (e.g. 200x100 per zone on Stream Deck+).

Subclass this to build custom widgets. At minimum, implement :meth:render. Override the handle_encoder_* and check_selection_timeout hooks to react to encoder events.

Examples:

::

class MyCard(Card):
    def render(self) -> Image.Image:
        img = Image.new("RGB", (panel_width, panel_height), "black")
        # ... draw custom content ...
        return img

Event handlers are registered with decorators::

@card.on_tap
async def handle():
    print("Card tapped!")
Source code in src/deux/ui/cards/base.py
class Card(ABC):
    """Abstract base for a single touch-strip zone under an encoder.

    The touchscreen is divided into zones aligned with the device's
    encoders.  Cards are tiled edge-to-edge — the library imposes no
    margins or gaps. Each zone is ``touchscreen_width // panel_count``
    wide and ``touchscreen_height`` tall (e.g. 200x100 per zone on
    Stream Deck+).

    Subclass this to build custom widgets.  At minimum, implement
    :meth:`render`.  Override the ``handle_encoder_*`` and
    ``check_selection_timeout`` hooks to react to encoder events.

    Examples
    --------
    ::

        class MyCard(Card):
            def render(self) -> Image.Image:
                img = Image.new("RGB", (panel_width, panel_height), "black")
                # ... draw custom content ...
                return img

    Event handlers are registered with decorators::

        @card.on_tap
        async def handle():
            print("Card tapped!")
    """

    def __init__(self) -> None:
        self._tap_handler: AsyncHandler | None = None
        self._long_press_handler: AsyncHandler | None = None
        self._drag_handler: AsyncHandler | None = None
        self._encoder_turn_handler: AsyncHandler | None = None
        self._encoder_press_handler: AsyncHandler | None = None
        self._encoder_release_handler: AsyncHandler | None = None
        self._pending_callbacks: list[tuple[AsyncHandler, tuple[object, ...]]] = []
        self._rendered: Image.Image | None = None
        self._dirty = True
        self._refresh_callbacks: list[AsyncHandler] = []

    def on_tap(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for short tap events in this zone.

        Examples
        --------
        ::

            @widget.on_tap
            async def handle():
                ...
        """
        self._tap_handler = handler
        return handler

    def on_long_press(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for long press events in this zone.

        Examples
        --------
        ::

            @widget.on_long_press
            async def handle():
                ...
        """
        self._long_press_handler = handler
        return handler

    def on_drag(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for drag/swipe events in this zone.

        The handler receives ``x``, ``y``, ``x_out``, ``y_out`` arguments
        describing the start and end coordinates of the drag gesture.

        Examples
        --------
        ::

            @widget.on_drag
            async def handle(x: int, y: int, x_out: int, y_out: int):
                ...
        """
        self._drag_handler = handler
        return handler

    def on_encoder_turn(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder turn events on this widget.

        The handler receives a single ``direction`` argument:
        positive = clockwise, negative = counter-clockwise.

        Examples
        --------
        ::

            @widget.on_encoder_turn
            async def handle(direction: int):
                ...
        """
        self._encoder_turn_handler = handler
        return handler

    def on_encoder_press(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder press events on this widget.

        Examples
        --------
        ::

            @widget.on_encoder_press
            async def handle():
                ...
        """
        self._encoder_press_handler = handler
        return handler

    def on_encoder_release(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder release events on this widget.

        Examples
        --------
        ::

            @widget.on_encoder_release
            async def handle():
                ...
        """
        self._encoder_release_handler = handler
        return handler

    def set_refresh_callback(self, callback: AsyncHandler) -> None:
        """Register an async callback the card can invoke to request a refresh.

        Multiple callbacks may be registered (e.g. when the same card is
        installed on screens belonging to different decks).  Each call
        adds *callback* to the list — duplicates are silently ignored so
        that re-wiring the same deck does not accumulate entries.

        This is set automatically by :class:`~deux.runtime.deck.Deck`
        when dispatching events so that cards with internal timers (e.g.
        long-press detection) can trigger a re-render without a direct
        reference to the deck.

        Parameters
        ----------
        callback
            Async callable that triggers a deck refresh.
        """
        if callback not in self._refresh_callbacks:
            self._refresh_callbacks.append(callback)

    def remove_refresh_callback(self, callback: AsyncHandler) -> None:
        """Remove a previously registered refresh callback.

        No-op if *callback* is not in the list.

        Parameters
        ----------
        callback
            The callback to remove.
        """
        with contextlib.suppress(ValueError):
            self._refresh_callbacks.remove(callback)

    async def request_refresh(self) -> None:
        """Ask all registered decks to re-render this card.

        No-op if no refresh callbacks have been registered.
        """
        for cb in list(self._refresh_callbacks):
            await cb()

    def queue_pending_callback(
        self, handler: AsyncHandler, args: tuple[object, ...]
    ) -> None:
        """Enqueue a callback for deferred async invocation.

        Called by child elements (e.g. sliders) when their value changes
        synchronously.  The queued callbacks are drained and awaited by
        :class:`~deux.runtime.deck.Deck` during event dispatch or refresh.

        Parameters
        ----------
        handler
            The async callback to invoke.
        args
            Positional arguments to pass to the callback.
        """
        self._pending_callbacks.append((handler, args))

    def drain_pending_callbacks(self) -> list[tuple[AsyncHandler, tuple[object, ...]]]:
        """Remove and return all pending callbacks.

        Returns
        -------
        list[tuple[AsyncHandler, tuple[object, ...]]]
            A list of ``(handler, args)`` tuples.  The list is empty if
            no callbacks are pending.
        """
        callbacks = self._pending_callbacks
        self._pending_callbacks = []
        return callbacks

    @property
    def has_pending_callbacks(self) -> bool:
        """Whether the card has callbacks queued for the next drain.

        Returns
        -------
        bool
            ``True`` if at least one callback has been queued via
            :meth:`queue_pending_callback` and has not yet been drained
            by :meth:`drain_pending_callbacks`.
        """
        return bool(self._pending_callbacks)

    @property
    def is_dirty(self) -> bool:
        """Whether the card needs re-rendering."""
        return self._dirty

    def mark_clean(self) -> None:
        """Clear the dirty flag after the card has been rendered."""
        self._dirty = False

    def mark_dirty(self) -> None:
        """Flag this card for re-rendering on the next refresh."""
        self._dirty = True

    @property
    def rendered(self) -> Image.Image | None:
        """The last rendered image, or ``None`` if not yet rendered."""
        return self._rendered

    def set_rendered(self, img: Image.Image | None) -> None:
        """Store the rendered image and clear the dirty flag.

        Parameters
        ----------
        img
            The rendered PIL image, or ``None`` to clear.
        """
        self._rendered = img
        self._dirty = False

    async def prepare_assets(self) -> None:
        """Prepare external assets needed for rendering this card."""
        return None

    def render_panel_bytes(
        self,
        *,
        metrics: RenderMetrics,
        card_index: int,
        bg_tile: bytes | None,
        background: str = "black",
        image_format: str = "JPEG",
    ) -> bytes:
        """Render the card to encoded image bytes for a touchscreen panel.

        The base implementation uses Pillow compositing: it calls
        :meth:`render` and composites the result onto the background tile.
        Subclasses (e.g. :class:`~deux.dui.card.DuiCard`) override this
        to use their native rendering pipeline.

        Parameters
        ----------
        metrics : RenderMetrics
            Device metrics (panel dimensions, etc.).
        card_index : int
            The zero-based position of this card on the touch strip.
        bg_tile : bytes or None
            PNG-encoded background tile for this panel, or ``None``.
        background : str, default="black"
            Fallback background colour.
        image_format : str, default="JPEG"
            Image encoding format (``"JPEG"`` or ``"BMP"``).

        Returns
        -------
        bytes
            Encoded image bytes ready to send to the device.
        """
        rendered = self.render()
        self.set_rendered(rendered)

        # Convert PIL Image to PNG bytes for compositing.
        card_bytes: bytes | None = None
        if rendered is not None:
            buf = io.BytesIO()
            rendered.save(buf, format="PNG")
            card_bytes = buf.getvalue()

        return compose_card_with_background(
            card_bytes,
            bg_tile_bytes=bg_tile,
            background=background,
            panel_width=metrics.panel_width,
            panel_height=metrics.panel_height,
            image_format=image_format,
        )

    async def dispatch_encoder_turn(self, direction: int) -> None:
        """Dispatch an encoder-turn event to the card."""
        if self._encoder_turn_handler is not None:
            await self._encoder_turn_handler(direction)
        self.handle_encoder_turn(direction)

    async def dispatch_encoder_press(self) -> None:
        """Dispatch an encoder-press event to the card."""
        if self._encoder_press_handler is not None:
            await self._encoder_press_handler()
        self.handle_encoder_press()

    async def dispatch_encoder_release(self) -> None:
        """Dispatch an encoder-release event to the card."""
        if self._encoder_release_handler is not None:
            await self._encoder_release_handler()
        self.handle_encoder_release()

    async def dispatch_touch(self, event: TouchEvent) -> None:
        """Dispatch a touch gesture to the card."""
        if event.event_type == EventType.TOUCH_SHORT:
            if self._tap_handler is not None:
                await self._tap_handler()
        elif event.event_type == EventType.TOUCH_LONG:
            if self._long_press_handler is not None:
                await self._long_press_handler()
        elif (
            event.event_type == EventType.TOUCH_DRAG and self._drag_handler is not None
        ):
            await self._drag_handler(event.x, event.y, event.x_out, event.y_out)

    @abstractmethod
    def render(self) -> Image.Image | None:
        """Render this card as a panel-sized PIL Image.

        Return ``None`` to let the touchstrip background colour show
        through (used by :class:`~deux.ui.cards.blank.BlankCard`).

        Returns
        -------
        Image.Image or None
            A panel-sized RGB :class:`~PIL.Image.Image` (the panel size
            depends on the connected device), or ``None`` for an empty
            slot.
        """

    def handle_encoder_turn(self, direction: int) -> None:
        """Called when the encoder above this widget is turned.

        Override to handle encoder rotation.  The default is a no-op.
        """
        _ = direction
        return None

    def handle_encoder_press(self) -> None:
        """Called when the encoder above this widget is pressed.

        Override to handle encoder presses.  The default is a no-op.
        """
        return None

    def handle_encoder_release(self) -> None:
        """Called when the encoder above this widget is released.

        Override to handle encoder releases.  The default is a no-op.
        """
        return None

    def check_selection_timeout(self) -> bool:
        """Check whether an internal selection timeout has elapsed.

        Override to implement timeout logic (e.g. for slider cycling).
        The default always returns ``False``.

        Returns
        -------
        bool
            ``True`` if the widget state changed and needs re-rendering.
        """
        return False

has_pending_callbacks property

has_pending_callbacks: bool

Whether the card has callbacks queued for the next drain.

Returns:

Type Description
bool

True if at least one callback has been queued via :meth:queue_pending_callback and has not yet been drained by :meth:drain_pending_callbacks.

is_dirty property

is_dirty: bool

Whether the card needs re-rendering.

rendered property

rendered: Image | None

The last rendered image, or None if not yet rendered.

on_tap

on_tap(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for short tap events in this zone.

Examples:

::

@widget.on_tap
async def handle():
    ...
Source code in src/deux/ui/cards/base.py
def on_tap(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for short tap events in this zone.

    Examples
    --------
    ::

        @widget.on_tap
        async def handle():
            ...
    """
    self._tap_handler = handler
    return handler

on_long_press

on_long_press(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for long press events in this zone.

Examples:

::

@widget.on_long_press
async def handle():
    ...
Source code in src/deux/ui/cards/base.py
def on_long_press(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for long press events in this zone.

    Examples
    --------
    ::

        @widget.on_long_press
        async def handle():
            ...
    """
    self._long_press_handler = handler
    return handler

on_drag

on_drag(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for drag/swipe events in this zone.

The handler receives x, y, x_out, y_out arguments describing the start and end coordinates of the drag gesture.

Examples:

::

@widget.on_drag
async def handle(x: int, y: int, x_out: int, y_out: int):
    ...
Source code in src/deux/ui/cards/base.py
def on_drag(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for drag/swipe events in this zone.

    The handler receives ``x``, ``y``, ``x_out``, ``y_out`` arguments
    describing the start and end coordinates of the drag gesture.

    Examples
    --------
    ::

        @widget.on_drag
        async def handle(x: int, y: int, x_out: int, y_out: int):
            ...
    """
    self._drag_handler = handler
    return handler

on_encoder_turn

on_encoder_turn(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder turn events on this widget.

The handler receives a single direction argument: positive = clockwise, negative = counter-clockwise.

Examples:

::

@widget.on_encoder_turn
async def handle(direction: int):
    ...
Source code in src/deux/ui/cards/base.py
def on_encoder_turn(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder turn events on this widget.

    The handler receives a single ``direction`` argument:
    positive = clockwise, negative = counter-clockwise.

    Examples
    --------
    ::

        @widget.on_encoder_turn
        async def handle(direction: int):
            ...
    """
    self._encoder_turn_handler = handler
    return handler

on_encoder_press

on_encoder_press(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder press events on this widget.

Examples:

::

@widget.on_encoder_press
async def handle():
    ...
Source code in src/deux/ui/cards/base.py
def on_encoder_press(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder press events on this widget.

    Examples
    --------
    ::

        @widget.on_encoder_press
        async def handle():
            ...
    """
    self._encoder_press_handler = handler
    return handler

on_encoder_release

on_encoder_release(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder release events on this widget.

Examples:

::

@widget.on_encoder_release
async def handle():
    ...
Source code in src/deux/ui/cards/base.py
def on_encoder_release(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder release events on this widget.

    Examples
    --------
    ::

        @widget.on_encoder_release
        async def handle():
            ...
    """
    self._encoder_release_handler = handler
    return handler

set_refresh_callback

set_refresh_callback(callback: AsyncHandler) -> None

Register an async callback the card can invoke to request a refresh.

Multiple callbacks may be registered (e.g. when the same card is installed on screens belonging to different decks). Each call adds callback to the list — duplicates are silently ignored so that re-wiring the same deck does not accumulate entries.

This is set automatically by :class:~deux.runtime.deck.Deck when dispatching events so that cards with internal timers (e.g. long-press detection) can trigger a re-render without a direct reference to the deck.

Parameters:

Name Type Description Default
callback AsyncHandler

Async callable that triggers a deck refresh.

required
Source code in src/deux/ui/cards/base.py
def set_refresh_callback(self, callback: AsyncHandler) -> None:
    """Register an async callback the card can invoke to request a refresh.

    Multiple callbacks may be registered (e.g. when the same card is
    installed on screens belonging to different decks).  Each call
    adds *callback* to the list — duplicates are silently ignored so
    that re-wiring the same deck does not accumulate entries.

    This is set automatically by :class:`~deux.runtime.deck.Deck`
    when dispatching events so that cards with internal timers (e.g.
    long-press detection) can trigger a re-render without a direct
    reference to the deck.

    Parameters
    ----------
    callback
        Async callable that triggers a deck refresh.
    """
    if callback not in self._refresh_callbacks:
        self._refresh_callbacks.append(callback)

remove_refresh_callback

remove_refresh_callback(callback: AsyncHandler) -> None

Remove a previously registered refresh callback.

No-op if callback is not in the list.

Parameters:

Name Type Description Default
callback AsyncHandler

The callback to remove.

required
Source code in src/deux/ui/cards/base.py
def remove_refresh_callback(self, callback: AsyncHandler) -> None:
    """Remove a previously registered refresh callback.

    No-op if *callback* is not in the list.

    Parameters
    ----------
    callback
        The callback to remove.
    """
    with contextlib.suppress(ValueError):
        self._refresh_callbacks.remove(callback)

request_refresh async

request_refresh() -> None

Ask all registered decks to re-render this card.

No-op if no refresh callbacks have been registered.

Source code in src/deux/ui/cards/base.py
async def request_refresh(self) -> None:
    """Ask all registered decks to re-render this card.

    No-op if no refresh callbacks have been registered.
    """
    for cb in list(self._refresh_callbacks):
        await cb()

queue_pending_callback

queue_pending_callback(handler: AsyncHandler, args: tuple[object, ...]) -> None

Enqueue a callback for deferred async invocation.

Called by child elements (e.g. sliders) when their value changes synchronously. The queued callbacks are drained and awaited by :class:~deux.runtime.deck.Deck during event dispatch or refresh.

Parameters:

Name Type Description Default
handler AsyncHandler

The async callback to invoke.

required
args tuple[object, ...]

Positional arguments to pass to the callback.

required
Source code in src/deux/ui/cards/base.py
def queue_pending_callback(
    self, handler: AsyncHandler, args: tuple[object, ...]
) -> None:
    """Enqueue a callback for deferred async invocation.

    Called by child elements (e.g. sliders) when their value changes
    synchronously.  The queued callbacks are drained and awaited by
    :class:`~deux.runtime.deck.Deck` during event dispatch or refresh.

    Parameters
    ----------
    handler
        The async callback to invoke.
    args
        Positional arguments to pass to the callback.
    """
    self._pending_callbacks.append((handler, args))

drain_pending_callbacks

drain_pending_callbacks() -> list[tuple[AsyncHandler, tuple[object, ...]]]

Remove and return all pending callbacks.

Returns:

Type Description
list[tuple[AsyncHandler, tuple[object, ...]]]

A list of (handler, args) tuples. The list is empty if no callbacks are pending.

Source code in src/deux/ui/cards/base.py
def drain_pending_callbacks(self) -> list[tuple[AsyncHandler, tuple[object, ...]]]:
    """Remove and return all pending callbacks.

    Returns
    -------
    list[tuple[AsyncHandler, tuple[object, ...]]]
        A list of ``(handler, args)`` tuples.  The list is empty if
        no callbacks are pending.
    """
    callbacks = self._pending_callbacks
    self._pending_callbacks = []
    return callbacks

mark_clean

mark_clean() -> None

Clear the dirty flag after the card has been rendered.

Source code in src/deux/ui/cards/base.py
def mark_clean(self) -> None:
    """Clear the dirty flag after the card has been rendered."""
    self._dirty = False

mark_dirty

mark_dirty() -> None

Flag this card for re-rendering on the next refresh.

Source code in src/deux/ui/cards/base.py
def mark_dirty(self) -> None:
    """Flag this card for re-rendering on the next refresh."""
    self._dirty = True

set_rendered

set_rendered(img: Image | None) -> None

Store the rendered image and clear the dirty flag.

Parameters:

Name Type Description Default
img Image | None

The rendered PIL image, or None to clear.

required
Source code in src/deux/ui/cards/base.py
def set_rendered(self, img: Image.Image | None) -> None:
    """Store the rendered image and clear the dirty flag.

    Parameters
    ----------
    img
        The rendered PIL image, or ``None`` to clear.
    """
    self._rendered = img
    self._dirty = False

prepare_assets async

prepare_assets() -> None

Prepare external assets needed for rendering this card.

Source code in src/deux/ui/cards/base.py
async def prepare_assets(self) -> None:
    """Prepare external assets needed for rendering this card."""
    return None

render_panel_bytes

render_panel_bytes(*, metrics: RenderMetrics, card_index: int, bg_tile: bytes | None, background: str = 'black', image_format: str = 'JPEG') -> bytes

Render the card to encoded image bytes for a touchscreen panel.

The base implementation uses Pillow compositing: it calls :meth:render and composites the result onto the background tile. Subclasses (e.g. :class:~deux.dui.card.DuiCard) override this to use their native rendering pipeline.

Parameters:

Name Type Description Default
metrics RenderMetrics

Device metrics (panel dimensions, etc.).

required
card_index int

The zero-based position of this card on the touch strip.

required
bg_tile bytes or None

PNG-encoded background tile for this panel, or None.

required
background str

Fallback background colour.

"black"
image_format str

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

"JPEG"

Returns:

Type Description
bytes

Encoded image bytes ready to send to the device.

Source code in src/deux/ui/cards/base.py
def render_panel_bytes(
    self,
    *,
    metrics: RenderMetrics,
    card_index: int,
    bg_tile: bytes | None,
    background: str = "black",
    image_format: str = "JPEG",
) -> bytes:
    """Render the card to encoded image bytes for a touchscreen panel.

    The base implementation uses Pillow compositing: it calls
    :meth:`render` and composites the result onto the background tile.
    Subclasses (e.g. :class:`~deux.dui.card.DuiCard`) override this
    to use their native rendering pipeline.

    Parameters
    ----------
    metrics : RenderMetrics
        Device metrics (panel dimensions, etc.).
    card_index : int
        The zero-based position of this card on the touch strip.
    bg_tile : bytes or None
        PNG-encoded background tile for this panel, or ``None``.
    background : str, default="black"
        Fallback background colour.
    image_format : str, default="JPEG"
        Image encoding format (``"JPEG"`` or ``"BMP"``).

    Returns
    -------
    bytes
        Encoded image bytes ready to send to the device.
    """
    rendered = self.render()
    self.set_rendered(rendered)

    # Convert PIL Image to PNG bytes for compositing.
    card_bytes: bytes | None = None
    if rendered is not None:
        buf = io.BytesIO()
        rendered.save(buf, format="PNG")
        card_bytes = buf.getvalue()

    return compose_card_with_background(
        card_bytes,
        bg_tile_bytes=bg_tile,
        background=background,
        panel_width=metrics.panel_width,
        panel_height=metrics.panel_height,
        image_format=image_format,
    )

dispatch_encoder_turn async

dispatch_encoder_turn(direction: int) -> None

Dispatch an encoder-turn event to the card.

Source code in src/deux/ui/cards/base.py
async def dispatch_encoder_turn(self, direction: int) -> None:
    """Dispatch an encoder-turn event to the card."""
    if self._encoder_turn_handler is not None:
        await self._encoder_turn_handler(direction)
    self.handle_encoder_turn(direction)

dispatch_encoder_press async

dispatch_encoder_press() -> None

Dispatch an encoder-press event to the card.

Source code in src/deux/ui/cards/base.py
async def dispatch_encoder_press(self) -> None:
    """Dispatch an encoder-press event to the card."""
    if self._encoder_press_handler is not None:
        await self._encoder_press_handler()
    self.handle_encoder_press()

dispatch_encoder_release async

dispatch_encoder_release() -> None

Dispatch an encoder-release event to the card.

Source code in src/deux/ui/cards/base.py
async def dispatch_encoder_release(self) -> None:
    """Dispatch an encoder-release event to the card."""
    if self._encoder_release_handler is not None:
        await self._encoder_release_handler()
    self.handle_encoder_release()

dispatch_touch async

dispatch_touch(event: TouchEvent) -> None

Dispatch a touch gesture to the card.

Source code in src/deux/ui/cards/base.py
async def dispatch_touch(self, event: TouchEvent) -> None:
    """Dispatch a touch gesture to the card."""
    if event.event_type == EventType.TOUCH_SHORT:
        if self._tap_handler is not None:
            await self._tap_handler()
    elif event.event_type == EventType.TOUCH_LONG:
        if self._long_press_handler is not None:
            await self._long_press_handler()
    elif (
        event.event_type == EventType.TOUCH_DRAG and self._drag_handler is not None
    ):
        await self._drag_handler(event.x, event.y, event.x_out, event.y_out)

render abstractmethod

render() -> Image.Image | None

Render this card as a panel-sized PIL Image.

Return None to let the touchstrip background colour show through (used by :class:~deux.ui.cards.blank.BlankCard).

Returns:

Type Description
Image or None

A panel-sized RGB :class:~PIL.Image.Image (the panel size depends on the connected device), or None for an empty slot.

Source code in src/deux/ui/cards/base.py
@abstractmethod
def render(self) -> Image.Image | None:
    """Render this card as a panel-sized PIL Image.

    Return ``None`` to let the touchstrip background colour show
    through (used by :class:`~deux.ui.cards.blank.BlankCard`).

    Returns
    -------
    Image.Image or None
        A panel-sized RGB :class:`~PIL.Image.Image` (the panel size
        depends on the connected device), or ``None`` for an empty
        slot.
    """

handle_encoder_turn

handle_encoder_turn(direction: int) -> None

Called when the encoder above this widget is turned.

Override to handle encoder rotation. The default is a no-op.

Source code in src/deux/ui/cards/base.py
def handle_encoder_turn(self, direction: int) -> None:
    """Called when the encoder above this widget is turned.

    Override to handle encoder rotation.  The default is a no-op.
    """
    _ = direction
    return None

handle_encoder_press

handle_encoder_press() -> None

Called when the encoder above this widget is pressed.

Override to handle encoder presses. The default is a no-op.

Source code in src/deux/ui/cards/base.py
def handle_encoder_press(self) -> None:
    """Called when the encoder above this widget is pressed.

    Override to handle encoder presses.  The default is a no-op.
    """
    return None

handle_encoder_release

handle_encoder_release() -> None

Called when the encoder above this widget is released.

Override to handle encoder releases. The default is a no-op.

Source code in src/deux/ui/cards/base.py
def handle_encoder_release(self) -> None:
    """Called when the encoder above this widget is released.

    Override to handle encoder releases.  The default is a no-op.
    """
    return None

check_selection_timeout

check_selection_timeout() -> bool

Check whether an internal selection timeout has elapsed.

Override to implement timeout logic (e.g. for slider cycling). The default always returns False.

Returns:

Type Description
bool

True if the widget state changed and needs re-rendering.

Source code in src/deux/ui/cards/base.py
def check_selection_timeout(self) -> bool:
    """Check whether an internal selection timeout has elapsed.

    Override to implement timeout logic (e.g. for slider cycling).
    The default always returns ``False``.

    Returns
    -------
    bool
        ``True`` if the widget state changed and needs re-rendering.
    """
    return False

CardController

Base class for service-backed touch-strip card controllers.

Subclasses are expected to:

  1. Load a .dui package and assign the resulting :class:~deux.DuiCard to :attr:card in __init__.
  2. Wire service-event subscriptions and DUI-event forwards (typically via :meth:~deux.DuiCard.bind, :meth:~deux.DuiCard.bind_range, :meth:~deux.DuiCard.bind_many, :meth:~deux.DuiCard.forward).
  3. Optionally override :meth:on_attach / :meth:on_detach for deck-linked subscriptions or background task lifecycle.

The base class itself stores no state and performs no wiring. It exists purely to give every controller a uniform lifecycle the application can drive in a loop on connect/disconnect.

Attributes:

Name Type Description
card DuiCard

The card the controller drives. Subclasses must assign this.

Source code in src/deux/ui/controller.py
class CardController:
    """Base class for service-backed touch-strip card controllers.

    Subclasses are expected to:

    1. Load a ``.dui`` package and assign the resulting
       :class:`~deux.DuiCard` to :attr:`card` in ``__init__``.
    2. Wire service-event subscriptions and DUI-event forwards (typically
       via :meth:`~deux.DuiCard.bind`,
       :meth:`~deux.DuiCard.bind_range`,
       :meth:`~deux.DuiCard.bind_many`,
       :meth:`~deux.DuiCard.forward`).
    3. Optionally override :meth:`on_attach` / :meth:`on_detach` for
       deck-linked subscriptions or background task lifecycle.

    The base class itself stores no state and performs no wiring.  It
    exists purely to give every controller a uniform lifecycle the
    application can drive in a loop on connect/disconnect.

    Attributes
    ----------
    card : DuiCard
        The card the controller drives.  Subclasses must assign this.
    """

    card: DuiCard

    async def on_attach(self, deck: Deck) -> None:
        """Hook invoked from the app's ``on_connect`` callback.

        Default implementation is a no-op.  Override to subscribe to
        deck-owned events (e.g. ``deck.on_brightness_changed``), replay
        last-known values to the freshly-connected hardware, or start
        background tasks that depend on a refresh callback being wired
        up.

        Parameters
        ----------
        deck
            The freshly-connected :class:`~deux.Deck` instance.
        """

    async def on_detach(self) -> None:
        """Hook invoked from the app's ``on_disconnect`` callback.

        The default implementation is a no-op.  Override to cancel
        background tasks or perform additional teardown.

        .. note::

           Deck-owned event subscriptions (e.g. ``on_brightness_changed``)
           are cleaned up automatically by :meth:`Deck.stop` via
           :meth:`~deux.DuiCard.detach_events`.  Service-owned bindings
           established in ``__init__`` are deliberately preserved so that
           they survive reconnect cycles without re-wiring.
        """

on_attach async

on_attach(deck: Deck) -> None

Hook invoked from the app's on_connect callback.

Default implementation is a no-op. Override to subscribe to deck-owned events (e.g. deck.on_brightness_changed), replay last-known values to the freshly-connected hardware, or start background tasks that depend on a refresh callback being wired up.

Parameters:

Name Type Description Default
deck Deck

The freshly-connected :class:~deux.Deck instance.

required
Source code in src/deux/ui/controller.py
async def on_attach(self, deck: Deck) -> None:
    """Hook invoked from the app's ``on_connect`` callback.

    Default implementation is a no-op.  Override to subscribe to
    deck-owned events (e.g. ``deck.on_brightness_changed``), replay
    last-known values to the freshly-connected hardware, or start
    background tasks that depend on a refresh callback being wired
    up.

    Parameters
    ----------
    deck
        The freshly-connected :class:`~deux.Deck` instance.
    """

on_detach async

on_detach() -> None

Hook invoked from the app's on_disconnect callback.

The default implementation is a no-op. Override to cancel background tasks or perform additional teardown.

.. note::

Deck-owned event subscriptions (e.g. on_brightness_changed) are cleaned up automatically by :meth:Deck.stop via :meth:~deux.DuiCard.detach_events. Service-owned bindings established in __init__ are deliberately preserved so that they survive reconnect cycles without re-wiring.

Source code in src/deux/ui/controller.py
async def on_detach(self) -> None:
    """Hook invoked from the app's ``on_disconnect`` callback.

    The default implementation is a no-op.  Override to cancel
    background tasks or perform additional teardown.

    .. note::

       Deck-owned event subscriptions (e.g. ``on_brightness_changed``)
       are cleaned up automatically by :meth:`Deck.stop` via
       :meth:`~deux.DuiCard.detach_events`.  Service-owned bindings
       established in ``__init__`` are deliberately preserved so that
       they survive reconnect cycles without re-wiring.
    """

KeyController

Base class for service-backed key controllers.

Mirror of :class:CardController for :class:~deux.DuiKey. Subclasses construct the key and wire bindings in __init__; override :meth:on_attach / :meth:on_detach when deck access or background tasks are required.

Attributes:

Name Type Description
key DuiKey

The key the controller drives. Subclasses must assign this.

Source code in src/deux/ui/controller.py
class KeyController:
    """Base class for service-backed key controllers.

    Mirror of :class:`CardController` for :class:`~deux.DuiKey`.
    Subclasses construct the key and wire bindings in ``__init__``;
    override :meth:`on_attach` / :meth:`on_detach` when deck access
    or background tasks are required.

    Attributes
    ----------
    key : DuiKey
        The key the controller drives.  Subclasses must assign this.
    """

    key: DuiKey

    async def on_attach(self, deck: Deck) -> None:
        """Hook invoked from the app's ``on_connect`` callback.

        Default implementation is a no-op.  See
        :meth:`CardController.on_attach` for guidance.

        Parameters
        ----------
        deck
            The freshly-connected :class:`~deux.Deck` instance.
        """

    async def on_detach(self) -> None:
        """Hook invoked from the app's ``on_disconnect`` callback.

        The default implementation calls :meth:`~deux.DuiKey.detach`
        to unsubscribe all ``AsyncEvent`` handlers.  Override to add
        additional teardown logic, but call ``await super().on_detach()``
        to preserve the unsubscription behaviour.
        """
        if hasattr(self, "key"):
            self.key.detach()

on_attach async

on_attach(deck: Deck) -> None

Hook invoked from the app's on_connect callback.

Default implementation is a no-op. See :meth:CardController.on_attach for guidance.

Parameters:

Name Type Description Default
deck Deck

The freshly-connected :class:~deux.Deck instance.

required
Source code in src/deux/ui/controller.py
async def on_attach(self, deck: Deck) -> None:
    """Hook invoked from the app's ``on_connect`` callback.

    Default implementation is a no-op.  See
    :meth:`CardController.on_attach` for guidance.

    Parameters
    ----------
    deck
        The freshly-connected :class:`~deux.Deck` instance.
    """

on_detach async

on_detach() -> None

Hook invoked from the app's on_disconnect callback.

The default implementation calls :meth:~deux.DuiKey.detach to unsubscribe all AsyncEvent handlers. Override to add additional teardown logic, but call await super().on_detach() to preserve the unsubscription behaviour.

Source code in src/deux/ui/controller.py
async def on_detach(self) -> None:
    """Hook invoked from the app's ``on_disconnect`` callback.

    The default implementation calls :meth:`~deux.DuiKey.detach`
    to unsubscribe all ``AsyncEvent`` handlers.  Override to add
    additional teardown logic, but call ``await super().on_detach()``
    to preserve the unsubscription behaviour.
    """
    if hasattr(self, "key"):
        self.key.detach()

DialAccumulator

Debounce rapid dial/encoder ticks and flush them with a single callback.

callback - async def callback(steps: int) called once per flush with the net accumulated tick count (signed). delay - seconds to wait after the last tick before flushing. max_steps - cap on how many ticks can accumulate (positive number). Use max_steps=1 to collapse any number of ticks into a single +1 / -1 event (useful for next/previous).

Examples:

::

acc = DialAccumulator(my_handler, delay=0.2, max_steps=5)

@encoder.on_turn
async def on_turn(direction: int):
    acc.tick(direction)
Source code in src/deux/ui/controls/dial_accumulator.py
class DialAccumulator:
    """Debounce rapid dial/encoder ticks and flush them with a single callback.

    *callback*   - ``async def callback(steps: int)`` called once per flush
                   with the net accumulated tick count (signed).
    *delay*      - seconds to wait after the last tick before flushing.
    *max_steps*  - cap on how many ticks can accumulate (positive number).
                   Use ``max_steps=1`` to collapse any number of ticks into
                   a single +1 / -1 event (useful for next/previous).

    Examples
    --------
    ::

        acc = DialAccumulator(my_handler, delay=0.2, max_steps=5)

        @encoder.on_turn
        async def on_turn(direction: int):
            acc.tick(direction)
    """

    def __init__(
        self,
        callback: Callable[[int], Awaitable[None]],
        *,
        delay: float = 0.25,
        max_steps: int = 10,
    ) -> None:
        if delay <= 0:
            msg = "delay must be positive"
            raise ValueError(msg)
        if max_steps < 1:
            msg = "max_steps must be >= 1"
            raise ValueError(msg)
        logger.debug("DialAccumulator.__init__: delay=%.2f max_steps=%d", delay, max_steps)
        self._callback = callback
        self._delay = delay
        self._max_steps = max_steps
        self._pending: int = 0
        self._flush_task: asyncio.Task[None] | None = None

    def tick(self, direction: int) -> None:
        """Add *direction* (+1 or -1). Clamps to ±max_steps.

        Cancels and awaits any previously scheduled flush task before
        creating a new one, preventing orphaned task accumulation.
        """
        logger.debug("DialAccumulator.tick: direction=%+d pending=%d", direction, self._pending)
        self._pending = max(-self._max_steps, min(self._max_steps, self._pending + direction))
        old_task = self._flush_task
        self._flush_task = asyncio.create_task(self._schedule_flush(old_task))

    async def _schedule_flush(self, old_task: asyncio.Task[None] | None) -> None:
        """Wait for the debounce delay, then flush accumulated ticks.

        Parameters
        ----------
        old_task : asyncio.Task[None] | None
            The previously scheduled flush task to cancel and await
            before starting the new debounce timer.
        """
        if old_task is not None:
            old_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await old_task
        try:
            logger.debug("DialAccumulator._schedule_flush: waiting %.2fs", self._delay)
            await asyncio.sleep(self._delay)
            await self._flush()
        except asyncio.CancelledError:
            pass

    async def _flush(self) -> None:
        """Invoke the callback with the net accumulated steps and reset."""
        steps = self._pending
        self._pending = 0
        self._flush_task = None
        logger.debug("DialAccumulator._flush: steps=%+d", steps)
        if steps == 0:
            return
        await self._callback(steps)

    async def cancel(self) -> None:
        """Cancel any pending flush and reset the accumulated count.

        Awaits the task's cancellation to ensure the flush handler has
        fully stopped before returning.
        """
        if self._flush_task is not None:
            self._flush_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._flush_task
            self._flush_task = None
        self._pending = 0
        logger.debug("DialAccumulator.cancel: reset")

tick

tick(direction: int) -> None

Add direction (+1 or -1). Clamps to ±max_steps.

Cancels and awaits any previously scheduled flush task before creating a new one, preventing orphaned task accumulation.

Source code in src/deux/ui/controls/dial_accumulator.py
def tick(self, direction: int) -> None:
    """Add *direction* (+1 or -1). Clamps to ±max_steps.

    Cancels and awaits any previously scheduled flush task before
    creating a new one, preventing orphaned task accumulation.
    """
    logger.debug("DialAccumulator.tick: direction=%+d pending=%d", direction, self._pending)
    self._pending = max(-self._max_steps, min(self._max_steps, self._pending + direction))
    old_task = self._flush_task
    self._flush_task = asyncio.create_task(self._schedule_flush(old_task))

cancel async

cancel() -> None

Cancel any pending flush and reset the accumulated count.

Awaits the task's cancellation to ensure the flush handler has fully stopped before returning.

Source code in src/deux/ui/controls/dial_accumulator.py
async def cancel(self) -> None:
    """Cancel any pending flush and reset the accumulated count.

    Awaits the task's cancellation to ensure the flush handler has
    fully stopped before returning.
    """
    if self._flush_task is not None:
        self._flush_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await self._flush_task
        self._flush_task = None
    self._pending = 0
    logger.debug("DialAccumulator.cancel: reset")

EncoderSlot

Represents a single physical rotary encoder on the Stream Deck+.

Examples:

Use decorators to register event handlers::

@encoder.on_turn
async def handle(direction: int):
    print(f"Turned by {direction}")
Source code in src/deux/ui/controls/encoder_slot.py
class EncoderSlot:
    """Represents a single physical rotary encoder on the Stream Deck+.

    Examples
    --------
    Use decorators to register event handlers::

        @encoder.on_turn
        async def handle(direction: int):
            print(f"Turned by {direction}")
    """

    def __init__(self) -> None:
        self._turn_handler: AsyncHandler | None = None
        self._press_handler: AsyncHandler | None = None
        self._release_handler: AsyncHandler | None = None
        self._press_turn_handler: AsyncHandler | None = None
        self._accumulator: DialAccumulator | None = None
        self._press_turn_accumulator: DialAccumulator | None = None
        self._pressed: bool = False

    # -- Turn handlers -------------------------------------------------------

    def on_turn(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder turn events.

        The handler receives a single ``direction`` argument:
        positive = clockwise, negative = counter-clockwise.

        When a ``press_turn`` handler is also registered, ``on_turn`` only
        fires for turns while the encoder is **not** pressed.

        Examples
        --------
        ::

            @encoder.on_turn
            async def handle(direction: int):
                ...
        """
        self._turn_handler = handler
        return handler

    def on_turn_accumulated(
        self,
        callback: Callable[[int], Awaitable[None]] | None = None,
        *,
        delay: float = 0.25,
        max_steps: int = 10,
    ) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]:
        """Register an accumulated turn handler backed by a :class:`DialAccumulator`.

        Can be used as a plain decorator or called with keyword arguments::

            # Plain decorator — default delay/max_steps
            @encoder.on_turn_accumulated
            async def handle(steps: int):
                ...

            # With options
            @encoder.on_turn_accumulated(delay=0.1, max_steps=5)
            async def handle(steps: int):
                ...

        Returns the :class:`DialAccumulator` instance (which replaces the
        decorated function reference).
        """

        def _wrap(cb: Callable[[int], Awaitable[None]]) -> DialAccumulator:
            acc = DialAccumulator(cb, delay=delay, max_steps=max_steps)
            self._accumulator = acc

            async def _tick(direction: int) -> None:
                acc.tick(direction)

            self._turn_handler = _tick
            return acc

        if callback is not None:
            return _wrap(callback)
        return _wrap

    # -- Press-turn handlers -------------------------------------------------

    def on_press_turn(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for turning while pressed.

        The handler receives a single ``direction`` argument, just like
        :meth:`on_turn`.  When registered, ``on_turn`` will **not** fire
        for turns that happen while the encoder is held down.

        Examples
        --------
        ::

            @encoder.on_press_turn
            async def handle(direction: int):
                ...
        """
        self._press_turn_handler = handler
        return handler

    def on_press_turn_accumulated(
        self,
        callback: Callable[[int], Awaitable[None]] | None = None,
        *,
        delay: float = 0.25,
        max_steps: int = 10,
    ) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]:
        """Register an accumulated press-turn handler backed by a :class:`DialAccumulator`.

        Same API as :meth:`on_turn_accumulated` but only fires while the
        encoder is held down.

        Examples
        --------
        ::

            @encoder.on_press_turn_accumulated(delay=0.1, max_steps=5)
            async def handle(steps: int):
                ...
        """

        def _wrap(cb: Callable[[int], Awaitable[None]]) -> DialAccumulator:
            acc = DialAccumulator(cb, delay=delay, max_steps=max_steps)
            self._press_turn_accumulator = acc

            async def _tick(direction: int) -> None:
                acc.tick(direction)

            self._press_turn_handler = _tick
            return acc

        if callback is not None:
            return _wrap(callback)
        return _wrap

    # -- Press / release handlers --------------------------------------------

    def on_press(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder press events.

        Examples
        --------
        ::

            @encoder.on_press
            async def handle():
                ...
        """
        self._press_handler = handler
        return handler

    def on_release(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for encoder release events.

        Examples
        --------
        ::

            @encoder.on_release
            async def handle():
                ...
        """
        self._release_handler = handler
        return handler

    # -- Dispatch ------------------------------------------------------------

    async def dispatch_turn(self, direction: int) -> None:
        """Dispatch an encoder turn event through the registered handler.

        When a ``press_turn`` handler is registered, turns while pressed
        are routed to it instead of the regular ``turn`` handler (matching
        the priority logic of :class:`~deux.dui.event_map.EventMap`).
        """
        if self._pressed and self._press_turn_handler is not None:
            await self._press_turn_handler(direction)
            return
        if self._turn_handler is not None:
            await self._turn_handler(direction)

    async def dispatch_press(self, pressed: bool) -> None:
        """Dispatch an encoder press or release event."""
        self._pressed = pressed
        handler = self._press_handler if pressed else self._release_handler
        if handler is not None:
            await handler()

on_turn

on_turn(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder turn events.

The handler receives a single direction argument: positive = clockwise, negative = counter-clockwise.

When a press_turn handler is also registered, on_turn only fires for turns while the encoder is not pressed.

Examples:

::

@encoder.on_turn
async def handle(direction: int):
    ...
Source code in src/deux/ui/controls/encoder_slot.py
def on_turn(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder turn events.

    The handler receives a single ``direction`` argument:
    positive = clockwise, negative = counter-clockwise.

    When a ``press_turn`` handler is also registered, ``on_turn`` only
    fires for turns while the encoder is **not** pressed.

    Examples
    --------
    ::

        @encoder.on_turn
        async def handle(direction: int):
            ...
    """
    self._turn_handler = handler
    return handler

on_turn_accumulated

on_turn_accumulated(callback: Callable[[int], Awaitable[None]] | None = None, *, delay: float = 0.25, max_steps: int = 10) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]

Register an accumulated turn handler backed by a :class:DialAccumulator.

Can be used as a plain decorator or called with keyword arguments::

# Plain decorator — default delay/max_steps
@encoder.on_turn_accumulated
async def handle(steps: int):
    ...

# With options
@encoder.on_turn_accumulated(delay=0.1, max_steps=5)
async def handle(steps: int):
    ...

Returns the :class:DialAccumulator instance (which replaces the decorated function reference).

Source code in src/deux/ui/controls/encoder_slot.py
def on_turn_accumulated(
    self,
    callback: Callable[[int], Awaitable[None]] | None = None,
    *,
    delay: float = 0.25,
    max_steps: int = 10,
) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]:
    """Register an accumulated turn handler backed by a :class:`DialAccumulator`.

    Can be used as a plain decorator or called with keyword arguments::

        # Plain decorator — default delay/max_steps
        @encoder.on_turn_accumulated
        async def handle(steps: int):
            ...

        # With options
        @encoder.on_turn_accumulated(delay=0.1, max_steps=5)
        async def handle(steps: int):
            ...

    Returns the :class:`DialAccumulator` instance (which replaces the
    decorated function reference).
    """

    def _wrap(cb: Callable[[int], Awaitable[None]]) -> DialAccumulator:
        acc = DialAccumulator(cb, delay=delay, max_steps=max_steps)
        self._accumulator = acc

        async def _tick(direction: int) -> None:
            acc.tick(direction)

        self._turn_handler = _tick
        return acc

    if callback is not None:
        return _wrap(callback)
    return _wrap

on_press_turn

on_press_turn(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for turning while pressed.

The handler receives a single direction argument, just like :meth:on_turn. When registered, on_turn will not fire for turns that happen while the encoder is held down.

Examples:

::

@encoder.on_press_turn
async def handle(direction: int):
    ...
Source code in src/deux/ui/controls/encoder_slot.py
def on_press_turn(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for turning while pressed.

    The handler receives a single ``direction`` argument, just like
    :meth:`on_turn`.  When registered, ``on_turn`` will **not** fire
    for turns that happen while the encoder is held down.

    Examples
    --------
    ::

        @encoder.on_press_turn
        async def handle(direction: int):
            ...
    """
    self._press_turn_handler = handler
    return handler

on_press_turn_accumulated

on_press_turn_accumulated(callback: Callable[[int], Awaitable[None]] | None = None, *, delay: float = 0.25, max_steps: int = 10) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]

Register an accumulated press-turn handler backed by a :class:DialAccumulator.

Same API as :meth:on_turn_accumulated but only fires while the encoder is held down.

Examples:

::

@encoder.on_press_turn_accumulated(delay=0.1, max_steps=5)
async def handle(steps: int):
    ...
Source code in src/deux/ui/controls/encoder_slot.py
def on_press_turn_accumulated(
    self,
    callback: Callable[[int], Awaitable[None]] | None = None,
    *,
    delay: float = 0.25,
    max_steps: int = 10,
) -> DialAccumulator | Callable[[Callable[[int], Awaitable[None]]], DialAccumulator]:
    """Register an accumulated press-turn handler backed by a :class:`DialAccumulator`.

    Same API as :meth:`on_turn_accumulated` but only fires while the
    encoder is held down.

    Examples
    --------
    ::

        @encoder.on_press_turn_accumulated(delay=0.1, max_steps=5)
        async def handle(steps: int):
            ...
    """

    def _wrap(cb: Callable[[int], Awaitable[None]]) -> DialAccumulator:
        acc = DialAccumulator(cb, delay=delay, max_steps=max_steps)
        self._press_turn_accumulator = acc

        async def _tick(direction: int) -> None:
            acc.tick(direction)

        self._press_turn_handler = _tick
        return acc

    if callback is not None:
        return _wrap(callback)
    return _wrap

on_press

on_press(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder press events.

Examples:

::

@encoder.on_press
async def handle():
    ...
Source code in src/deux/ui/controls/encoder_slot.py
def on_press(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder press events.

    Examples
    --------
    ::

        @encoder.on_press
        async def handle():
            ...
    """
    self._press_handler = handler
    return handler

on_release

on_release(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for encoder release events.

Examples:

::

@encoder.on_release
async def handle():
    ...
Source code in src/deux/ui/controls/encoder_slot.py
def on_release(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for encoder release events.

    Examples
    --------
    ::

        @encoder.on_release
        async def handle():
            ...
    """
    self._release_handler = handler
    return handler

dispatch_turn async

dispatch_turn(direction: int) -> None

Dispatch an encoder turn event through the registered handler.

When a press_turn handler is registered, turns while pressed are routed to it instead of the regular turn handler (matching the priority logic of :class:~deux.dui.event_map.EventMap).

Source code in src/deux/ui/controls/encoder_slot.py
async def dispatch_turn(self, direction: int) -> None:
    """Dispatch an encoder turn event through the registered handler.

    When a ``press_turn`` handler is registered, turns while pressed
    are routed to it instead of the regular ``turn`` handler (matching
    the priority logic of :class:`~deux.dui.event_map.EventMap`).
    """
    if self._pressed and self._press_turn_handler is not None:
        await self._press_turn_handler(direction)
        return
    if self._turn_handler is not None:
        await self._turn_handler(direction)

dispatch_press async

dispatch_press(pressed: bool) -> None

Dispatch an encoder press or release event.

Source code in src/deux/ui/controls/encoder_slot.py
async def dispatch_press(self, pressed: bool) -> None:
    """Dispatch an encoder press or release event."""
    self._pressed = pressed
    handler = self._press_handler if pressed else self._release_handler
    if handler is not None:
        await handler()

KeySlot

Represents a single physical key on the Stream Deck.

Examples:

Use decorators to register event handlers::

@key.on_press
async def handle():
    print("pressed!")
Source code in src/deux/ui/controls/key_slot.py
class KeySlot:
    """Represents a single physical key on the Stream Deck.

    Examples
    --------
    Use decorators to register event handlers::

        @key.on_press
        async def handle():
            print("pressed!")
    """

    def __init__(self) -> None:
        self._press_handler: AsyncHandler | None = None
        self._release_handler: AsyncHandler | None = None
        self._image_bytes: bytes | None = None
        self._dirty = True
        self._refresh_callbacks: list[AsyncHandler] = []

    def set_refresh_callback(self, callback: AsyncHandler) -> None:
        """Register an async callback the key can invoke to request a refresh.

        Multiple callbacks may be registered (e.g. when the same key is
        installed on screens belonging to different decks).  Each call
        adds *callback* to the list — duplicates are silently ignored so
        that re-wiring the same deck does not accumulate entries.

        This is set automatically by :class:`~deux.runtime.deck.Deck`
        when a screen is activated, so any code path (key handler,
        background task, external state change) can call
        :meth:`request_refresh` to trigger a re-render.

        Parameters
        ----------
        callback
            Async callable that triggers a deck refresh.
        """
        if callback not in self._refresh_callbacks:
            self._refresh_callbacks.append(callback)

    def remove_refresh_callback(self, callback: AsyncHandler) -> None:
        """Remove a previously registered refresh callback.

        No-op if *callback* is not in the list.

        Parameters
        ----------
        callback
            The callback to remove.
        """
        with contextlib.suppress(ValueError):
            self._refresh_callbacks.remove(callback)

    async def request_refresh(self) -> None:
        """Ask all registered decks to re-render the active screen.

        No-op if no refresh callbacks have been registered.
        """
        for cb in list(self._refresh_callbacks):
            await cb()

    def on_press(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for key press events.

        Examples
        --------
        ::

            @key.on_press
            async def handle():
                ...
        """
        self._press_handler = handler
        return handler

    def on_release(self, handler: AsyncHandler) -> AsyncHandler:
        """Decorator to register a handler for key release events.

        Examples
        --------
        ::

            @key.on_release
            async def handle():
                ...
        """
        self._release_handler = handler
        return handler

    async def dispatch(self, pressed: bool) -> None:
        """Dispatch a press or release event through the internal hook.

        Subclasses should override :meth:`_dispatch_event` to customise
        event handling without replacing the public entry point.
        """
        await self._dispatch_event(pressed)

    async def _dispatch_event(self, pressed: bool) -> None:
        """Internal dispatch hook — override in subclasses.

        The default implementation calls the registered press/release handler.

        Parameters
        ----------
        pressed : bool
            ``True`` for a press event, ``False`` for a release event.
        """
        handler = self._press_handler if pressed else self._release_handler
        if handler is not None:
            await handler()

    @property
    def is_dirty(self) -> bool:
        """Whether the key image needs re-rendering."""
        return self._dirty

    def mark_dirty(self) -> None:
        """Flag this key for re-rendering on the next refresh."""
        self._dirty = True

    def mark_clean(self) -> None:
        """Clear the dirty flag after the key image has been flushed."""
        self._dirty = False

    def set_rendered_image(self, image_bytes: bytes) -> None:
        """Store pre-rendered JPEG bytes (set by Deck during render)."""
        self._image_bytes = image_bytes
        self._dirty = False

    @property
    def image_bytes(self) -> bytes | None:
        """The pre-rendered image bytes, or ``None`` if not yet rendered."""
        return self._image_bytes

is_dirty property

is_dirty: bool

Whether the key image needs re-rendering.

image_bytes property

image_bytes: bytes | None

The pre-rendered image bytes, or None if not yet rendered.

set_refresh_callback

set_refresh_callback(callback: AsyncHandler) -> None

Register an async callback the key can invoke to request a refresh.

Multiple callbacks may be registered (e.g. when the same key is installed on screens belonging to different decks). Each call adds callback to the list — duplicates are silently ignored so that re-wiring the same deck does not accumulate entries.

This is set automatically by :class:~deux.runtime.deck.Deck when a screen is activated, so any code path (key handler, background task, external state change) can call :meth:request_refresh to trigger a re-render.

Parameters:

Name Type Description Default
callback AsyncHandler

Async callable that triggers a deck refresh.

required
Source code in src/deux/ui/controls/key_slot.py
def set_refresh_callback(self, callback: AsyncHandler) -> None:
    """Register an async callback the key can invoke to request a refresh.

    Multiple callbacks may be registered (e.g. when the same key is
    installed on screens belonging to different decks).  Each call
    adds *callback* to the list — duplicates are silently ignored so
    that re-wiring the same deck does not accumulate entries.

    This is set automatically by :class:`~deux.runtime.deck.Deck`
    when a screen is activated, so any code path (key handler,
    background task, external state change) can call
    :meth:`request_refresh` to trigger a re-render.

    Parameters
    ----------
    callback
        Async callable that triggers a deck refresh.
    """
    if callback not in self._refresh_callbacks:
        self._refresh_callbacks.append(callback)

remove_refresh_callback

remove_refresh_callback(callback: AsyncHandler) -> None

Remove a previously registered refresh callback.

No-op if callback is not in the list.

Parameters:

Name Type Description Default
callback AsyncHandler

The callback to remove.

required
Source code in src/deux/ui/controls/key_slot.py
def remove_refresh_callback(self, callback: AsyncHandler) -> None:
    """Remove a previously registered refresh callback.

    No-op if *callback* is not in the list.

    Parameters
    ----------
    callback
        The callback to remove.
    """
    with contextlib.suppress(ValueError):
        self._refresh_callbacks.remove(callback)

request_refresh async

request_refresh() -> None

Ask all registered decks to re-render the active screen.

No-op if no refresh callbacks have been registered.

Source code in src/deux/ui/controls/key_slot.py
async def request_refresh(self) -> None:
    """Ask all registered decks to re-render the active screen.

    No-op if no refresh callbacks have been registered.
    """
    for cb in list(self._refresh_callbacks):
        await cb()

on_press

on_press(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for key press events.

Examples:

::

@key.on_press
async def handle():
    ...
Source code in src/deux/ui/controls/key_slot.py
def on_press(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for key press events.

    Examples
    --------
    ::

        @key.on_press
        async def handle():
            ...
    """
    self._press_handler = handler
    return handler

on_release

on_release(handler: AsyncHandler) -> AsyncHandler

Decorator to register a handler for key release events.

Examples:

::

@key.on_release
async def handle():
    ...
Source code in src/deux/ui/controls/key_slot.py
def on_release(self, handler: AsyncHandler) -> AsyncHandler:
    """Decorator to register a handler for key release events.

    Examples
    --------
    ::

        @key.on_release
        async def handle():
            ...
    """
    self._release_handler = handler
    return handler

dispatch async

dispatch(pressed: bool) -> None

Dispatch a press or release event through the internal hook.

Subclasses should override :meth:_dispatch_event to customise event handling without replacing the public entry point.

Source code in src/deux/ui/controls/key_slot.py
async def dispatch(self, pressed: bool) -> None:
    """Dispatch a press or release event through the internal hook.

    Subclasses should override :meth:`_dispatch_event` to customise
    event handling without replacing the public entry point.
    """
    await self._dispatch_event(pressed)

mark_dirty

mark_dirty() -> None

Flag this key for re-rendering on the next refresh.

Source code in src/deux/ui/controls/key_slot.py
def mark_dirty(self) -> None:
    """Flag this key for re-rendering on the next refresh."""
    self._dirty = True

mark_clean

mark_clean() -> None

Clear the dirty flag after the key image has been flushed.

Source code in src/deux/ui/controls/key_slot.py
def mark_clean(self) -> None:
    """Clear the dirty flag after the key image has been flushed."""
    self._dirty = False

set_rendered_image

set_rendered_image(image_bytes: bytes) -> None

Store pre-rendered JPEG bytes (set by Deck during render).

Source code in src/deux/ui/controls/key_slot.py
def set_rendered_image(self, image_bytes: bytes) -> None:
    """Store pre-rendered JPEG bytes (set by Deck during render)."""
    self._image_bytes = image_bytes
    self._dirty = False

InfoScreen

Manage content on a non-touch info screen (e.g. Stream Deck Neo 248x58).

The info screen is a small display that shows status information. It does not support touch events or interactive cards — it is a simple image buffer with dirty tracking.

Parameters:

Name Type Description Default
width int

Screen width in pixels.

required
height int

Screen height in pixels.

required
image_format str

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

'JPEG'
Source code in src/deux/ui/info_screen.py
class InfoScreen:
    """Manage content on a non-touch info screen (e.g. Stream Deck Neo 248x58).

    The info screen is a small display that shows status information.
    It does not support touch events or interactive cards — it is a
    simple image buffer with dirty tracking.

    Parameters
    ----------
    width
        Screen width in pixels.
    height
        Screen height in pixels.
    image_format
        Image format for encoding (``"JPEG"`` or ``"BMP"``).
    """

    def __init__(
        self,
        width: int,
        height: int,
        image_format: str = "JPEG",
    ) -> None:
        self._width = width
        self._height = height
        self._image_format = image_format
        self._image: Image.Image | None = None
        self._dirty = True

    @property
    def width(self) -> int:
        """Screen width in pixels."""
        return self._width

    @property
    def height(self) -> int:
        """Screen height in pixels."""
        return self._height

    @property
    def size(self) -> tuple[int, int]:
        """Screen dimensions as ``(width, height)``."""
        return (self._width, self._height)

    @property
    def image_format(self) -> str:
        """Image encoding format (``"JPEG"`` or ``"BMP"``)."""
        return self._image_format

    @property
    def image(self) -> Image.Image | None:
        """The current screen image, or ``None`` if not set."""
        return self._image

    def set_image(self, image: Image.Image) -> None:
        """Set the info screen image and mark dirty.

        The image is resized to fit the screen dimensions if needed.

        Parameters
        ----------
        image
            A PIL Image to display.
        """
        if image.size != (self._width, self._height):
            image = image.resize((self._width, self._height), Image.Resampling.LANCZOS)
        self._image = image
        self._dirty = True

    def clear(self) -> None:
        """Clear the info screen to black."""
        self._image = Image.new("RGB", (self._width, self._height), "black")
        self._dirty = True

    @property
    def is_dirty(self) -> bool:
        """Whether the screen content has changed since the last flush."""
        return self._dirty

    def mark_clean(self) -> None:
        """Clear the dirty flag after the screen has been flushed to the device."""
        self._dirty = False

    def mark_dirty(self) -> None:
        """Flag the screen for re-rendering on the next refresh."""
        self._dirty = True

    def render_bytes(self) -> bytes:
        """Encode the current image to bytes in the device's format.

        Returns
        -------
        bytes
            Encoded image bytes. If no image has been set, returns
            a blank black image.
        """
        img = self._image or Image.new("RGB", (self._width, self._height), "black")
        if img.mode != "RGB":
            img = img.convert("RGB")

        buf = io.BytesIO()
        fmt = "JPEG" if self._image_format.upper() == "JPEG" else "BMP"
        img.save(buf, format=fmt, quality=90)
        return buf.getvalue()

width property

width: int

Screen width in pixels.

height property

height: int

Screen height in pixels.

size property

size: tuple[int, int]

Screen dimensions as (width, height).

image_format property

image_format: str

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

image property

image: Image | None

The current screen image, or None if not set.

is_dirty property

is_dirty: bool

Whether the screen content has changed since the last flush.

set_image

set_image(image: Image) -> None

Set the info screen image and mark dirty.

The image is resized to fit the screen dimensions if needed.

Parameters:

Name Type Description Default
image Image

A PIL Image to display.

required
Source code in src/deux/ui/info_screen.py
def set_image(self, image: Image.Image) -> None:
    """Set the info screen image and mark dirty.

    The image is resized to fit the screen dimensions if needed.

    Parameters
    ----------
    image
        A PIL Image to display.
    """
    if image.size != (self._width, self._height):
        image = image.resize((self._width, self._height), Image.Resampling.LANCZOS)
    self._image = image
    self._dirty = True

clear

clear() -> None

Clear the info screen to black.

Source code in src/deux/ui/info_screen.py
def clear(self) -> None:
    """Clear the info screen to black."""
    self._image = Image.new("RGB", (self._width, self._height), "black")
    self._dirty = True

mark_clean

mark_clean() -> None

Clear the dirty flag after the screen has been flushed to the device.

Source code in src/deux/ui/info_screen.py
def mark_clean(self) -> None:
    """Clear the dirty flag after the screen has been flushed to the device."""
    self._dirty = False

mark_dirty

mark_dirty() -> None

Flag the screen for re-rendering on the next refresh.

Source code in src/deux/ui/info_screen.py
def mark_dirty(self) -> None:
    """Flag the screen for re-rendering on the next refresh."""
    self._dirty = True

render_bytes

render_bytes() -> bytes

Encode the current image to bytes in the device's format.

Returns:

Type Description
bytes

Encoded image bytes. If no image has been set, returns a blank black image.

Source code in src/deux/ui/info_screen.py
def render_bytes(self) -> bytes:
    """Encode the current image to bytes in the device's format.

    Returns
    -------
    bytes
        Encoded image bytes. If no image has been set, returns
        a blank black image.
    """
    img = self._image or Image.new("RGB", (self._width, self._height), "black")
    if img.mode != "RGB":
        img = img.convert("RGB")

    buf = io.BytesIO()
    fmt = "JPEG" if self._image_format.upper() == "JPEG" else "BMP"
    img.save(buf, format=fmt, quality=90)
    return buf.getvalue()

Screen

A named layout containing keys, encoders, and touch-strip cards.

Screens allow you to define multiple layouts and switch between them. When a screen is activated, all key images, touch-strip cards, and event handlers swap atomically.

The number of available keys, encoders, and card zones is determined by the device capabilities. For devices without encoders or a touchscreen, those features are unavailable and accessing them raises :class:~deux.runtime.deck.DeckError.

Examples:

::

main = deck.screen("main")

@main.key(0).on_press
async def handle():
    await deck.set_screen("settings")
Source code in src/deux/ui/screen.py
 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
class Screen:
    """A named layout containing keys, encoders, and touch-strip cards.

    Screens allow you to define multiple layouts and switch between them.
    When a screen is activated, all key images, touch-strip cards, and
    event handlers swap atomically.

    The number of available keys, encoders, and card zones is determined
    by the device capabilities.  For devices without encoders or a
    touchscreen, those features are unavailable and accessing them raises
    :class:`~deux.runtime.deck.DeckError`.

    Examples
    --------
    ::

        main = deck.screen("main")

        @main.key(0).on_press
        async def handle():
            await deck.set_screen("settings")
    """

    def __init__(self, name: str, caps: DeviceCapabilities) -> None:
        """Initialise a screen for the given device capabilities.

        Screens are normally created via :meth:`Deck.screen` rather than
        instantiated directly; the deck supplies the matching device
        capabilities.

        Parameters
        ----------
        name : str
            Identifier used to look up and activate this screen via
            :meth:`Deck.set_screen`.
        caps : DeviceCapabilities
            Capability snapshot describing the target device.  Drives
            which controls are provisioned (touch strip, info screen)
            and which slot indices are valid.

        Notes
        -----
        Construction has the following side effects:

        * A :class:`~deux.render.background_layer.BackgroundLayer` is
          created for keys regardless of device.
        * A :class:`~deux.ui.touch_strip.TouchStrip` is provisioned only
          when the device has both a touchscreen and at least one dial.
        * An :class:`~deux.ui.info_screen.InfoScreen` is provisioned
          only when the device reports an info screen.
        * Bundled default backgrounds for the device's VID:PID are
          applied on a best-effort basis; failures are logged but never
          raised.
        """
        self._name = name
        self._caps = caps
        self._keys: dict[int, KeySlot] = {}
        self._encoders: dict[int, EncoderSlot] = {}
        self._theme: Theme | None = None
        self._key_bg_layer = BackgroundLayer(
            "key",
            key_size=self._caps.key_size,
            key_image_format=self._caps.key_image_format,
        )
        self._key_bg_dirty: bool = False

        if self._caps.has_touchscreen and self._caps.dial_count > 0:
            metrics = RenderMetrics(self._caps)
            self._touch_strip: TouchStrip | None = TouchStrip(
                panel_count=metrics.panel_count,
                panel_width=metrics.panel_width,
                panel_height=metrics.panel_height,
            )
        else:
            self._touch_strip = None

        if self._caps.has_info_screen:
            self._info_screen: InfoScreen | None = InfoScreen(
                width=self._caps.screen_width,
                height=self._caps.screen_height,
                image_format=self._caps.screen_image_format,
            )
        else:
            self._info_screen = None

        self._apply_default_backgrounds()

    @property
    def name(self) -> str:
        """The screen name."""
        return self._name

    def _apply_default_backgrounds(self) -> None:
        """Load and apply bundled default background SVGs for this device.

        Looks up the device's VID:PID in the bundled manifest and sets
        the touchscreen background SVG if one is available and no
        background has been explicitly configured.  Failures are logged
        but never raised — defaults are best-effort.
        """
        try:
            backgrounds = get_default_backgrounds(
                self._caps.vendor_id, self._caps.product_id
            )
        except Exception:
            logger.warning("Could not load default backgrounds", exc_info=True)
            return

        if "touchscreen" in backgrounds and self._touch_strip is not None:
            try:
                svg_data = backgrounds["touchscreen"]
                self._touch_strip.bg_layer.set_svg(svg_data, trusted=True)
            except (ET.ParseError, RasterizeError, OSError):
                logger.warning(
                    "Failed to apply default touchscreen background", exc_info=True
                )

        if "key" in backgrounds:
            try:
                svg_data = backgrounds["key"]
                self._key_bg_layer.set_svg(svg_data, trusted=True)
            except (ET.ParseError, RasterizeError, OSError):
                logger.warning(
                    "Failed to apply default key background", exc_info=True
                )

    def _rasterize_key_background(self) -> None:
        """Re-rasterize the key background via the BackgroundLayer.

        If no key background SVG is set, this is a no-op.
        """
        self._key_bg_layer.invalidate()

    @property
    def key_bg_image(self) -> bytes | None:
        """Pre-rendered default key background image, or ``None``.

        Returns
        -------
        bytes or None
            Encoded image bytes ready to push to the device, or ``None``
            if no default key background is configured.
        """
        return self._key_bg_layer.key_image

    @property
    def key_bg_dirty(self) -> bool:
        """Whether the key background needs re-rendering on all blank keys."""
        return self._key_bg_dirty

    def clear_key_bg_dirty(self) -> None:
        """Reset the key-background dirty flag after a full key render."""
        self._key_bg_dirty = False

    @property
    def capabilities(self) -> DeviceCapabilities:
        """The device capabilities this screen is configured for."""
        return self._caps

    @property
    def theme(self) -> Theme | None:
        """Per-screen theme override, or ``None`` to inherit.

        When set, this theme takes precedence over both the deck-level
        and system-wide theme for this screen.  Set to ``None`` to fall
        back to the deck or system theme.
        """
        return self._theme

    @theme.setter
    def theme(self, value: Theme | None) -> None:
        self._theme = value

    def key(self, index: int) -> KeySlot:
        """Get or create a key slot by index.

        Parameters
        ----------
        index
            Key index (0 to key_count-1).

        Returns
        -------
        KeySlot
            The KeySlot instance for this key on this screen.
        """
        key_count = self._caps.key_count
        if not 0 <= index < key_count:
            raise IndexError(
                f"Key index must be 0-{key_count - 1}, got {index}"
            )

        if index not in self._keys:
            self._keys[index] = KeySlot()
        return self._keys[index]

    def set_key(self, index: int, key: KeySlot) -> None:
        """Replace the key slot at *index* with a custom key.

        The same ``KeySlot`` (or :class:`~deux.dui.DuiKey`) instance may
        be installed on multiple screens at different slot indices — the
        screen's slot map is the single source of truth for routing, so
        no state is mutated on *key* itself.

        Parameters
        ----------
        index
            Key index.
        key
            The key slot to install.
        """
        key_count = self._caps.key_count
        if not 0 <= index < key_count:
            raise IndexError(
                f"Key index must be 0-{key_count - 1}, got {index}"
            )
        if not isinstance(key, KeySlot):
            raise TypeError(f"Expected KeySlot, got {type(key).__name__}")
        self._keys[index] = key

    def encoder(self, index: int) -> EncoderSlot:
        """Get or create an encoder slot by index.

        Parameters
        ----------
        index
            Encoder index (0 to dial_count-1).

        Returns
        -------
        EncoderSlot
            The EncoderSlot instance for this encoder on this screen.

        Raises
        ------
        IndexError
            If index is out of range or device has no encoders.
        """
        dial_count = self._caps.dial_count
        if dial_count == 0:
            raise IndexError("This device has no encoders")
        if not 0 <= index < dial_count:
            raise IndexError(
                f"Encoder index must be 0-{dial_count - 1}, got {index}"
            )

        if index not in self._encoders:
            self._encoders[index] = EncoderSlot()
        return self._encoders[index]

    def card(self, index: int) -> Card:
        """Get a touch-strip card zone by index."""
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        return self._touch_strip.card(index)

    def set_card(self, index: int, card: Card) -> None:
        """Replace the card at *index* with a custom card."""
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        self._touch_strip.set_card(index, card)

    @property
    def keys(self) -> Mapping[int, KeySlot]:
        """Mapping of key index to :class:`KeySlot` for all configured keys.

        Returns a read-only view; external code cannot mutate internal state.
        """
        return MappingProxyType(self._keys)

    @property
    def encoders(self) -> Mapping[int, EncoderSlot]:
        """Mapping of encoder index to :class:`EncoderSlot` for all configured encoders.

        Returns a read-only view; external code cannot mutate internal state.
        """
        return MappingProxyType(self._encoders)

    @property
    def touch_strip(self) -> TouchStrip | None:
        """The touch strip, or ``None`` if the device has no touchscreen."""
        return self._touch_strip

    @property
    def info_screen(self) -> InfoScreen | None:
        """The info screen, or ``None`` if the device has no info display."""
        return self._info_screen

    @property
    def touchstrip_background(self) -> str:
        """The fill colour for the touchscreen canvas behind cards."""
        if self._touch_strip is None:
            return "black"
        return self._touch_strip.background_color

    @touchstrip_background.setter
    def touchstrip_background(self, value: str) -> None:
        """Set the fill colour for the touchscreen canvas behind cards.

        Parameters
        ----------
        value : str
            CSS colour string for the background.

        Raises
        ------
        IndexError
            If the device has no touchscreen.
        """
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        self._touch_strip.background_color = value

    def set_touchstrip_background_svg(self, svg_data: bytes) -> None:
        """Set a background SVG for the touchstrip.

        The SVG is rasterized once, sliced into per-panel tiles, and
        cached.  Cards with transparent areas will show the background
        through.  When no background SVG is set, the solid
        :attr:`touchstrip_background` colour is used instead.

        Parameters
        ----------
        svg_data
            Raw SVG content as UTF-8 bytes.

        Raises
        ------
        IndexError
            If the device has no touchscreen.
        """
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        self._touch_strip.set_background_svg(svg_data)

    def set_touchstrip_background_svg_from_file(self, path: str | Path) -> None:
        """Load a touchstrip background SVG from a file path.

        Convenience wrapper around :meth:`set_touchstrip_background_svg`.

        Parameters
        ----------
        path
            Path to an SVG file.

        Raises
        ------
        IndexError
            If the device has no touchscreen.
        FileNotFoundError
            If *path* does not exist.
        """
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        self._touch_strip.set_background_svg_from_file(path)

    def clear_touchstrip_background_svg(self) -> None:
        """Remove the touchstrip background SVG.

        Reverts to the solid :attr:`touchstrip_background` colour.

        Raises
        ------
        IndexError
            If the device has no touchscreen.
        """
        if self._touch_strip is None:
            raise IndexError("This device has no touchscreen")
        self._touch_strip.clear_background_svg()

    @property
    def cards(self) -> list[Card]:
        """All touch-strip cards, or an empty list if the device has no touchscreen.

        Returns a shallow copy; external code cannot mutate internal state.
        """
        if self._touch_strip is None:
            return []
        return list(self._touch_strip.cards)

    def mark_all_dirty(self) -> None:
        """Flag every control on this screen for re-rendering.

        Marks all configured keys, touch-strip cards, and the info
        screen (if present) as dirty so the next refresh cycle
        re-renders the entire screen.  If the touch strip has a
        background SVG it is re-rasterized so that stylesheet changes
        are reflected.  Useful when a global property such as the
        active stylesheet changes.
        """
        for key_slot in self._keys.values():
            key_slot.mark_dirty()
        if self._key_bg_layer.has_svg:
            self._key_bg_layer.invalidate()
            self._key_bg_dirty = True
        if self._touch_strip is not None:
            self._touch_strip.invalidate_background()
        for card in self.cards:
            card.mark_dirty()
        if self._info_screen is not None:
            self._info_screen.mark_dirty()

    def collect_all_icons(self) -> set[str]:
        """Collect all Iconify icon identifiers needed by this screen.

        Iterates all DuiKey and DuiCard instances on this screen and
        aggregates their :meth:`~deux.dui.svg_renderer.SvgRenderer.collect_icon_names`
        results.

        Returns
        -------
        set[str]
            A set of ``"prefix:icon"`` strings used across all
            keys and cards on this screen.
        """
        # Inline import: dui.card imports ui.cards.base, which would create
        # a cycle if hoisted to module top.
        from ..dui.card import DuiCard  # noqa: PLC0415

        icons: set[str] = set()
        for key_slot in self._keys.values():
            if isinstance(key_slot, DuiKey):
                icons.update(key_slot.collect_icon_names())
        if self._touch_strip is not None:
            for card in self._touch_strip.cards:
                if isinstance(card, DuiCard):
                    icons.update(card.collect_icon_names())
        return icons

    def screenshot(self, directory: str | Path) -> list[Path]:
        """Save the current screen state as individual PNG files.

        Writes one file per key that has been rendered, one file per
        touch-strip card that has been rendered, and one file for the
        info screen (if it has content).  Blank or unrendered controls
        are skipped.

        PNG is used instead of JPEG to avoid additional compression
        artifacts on the already-small device images.

        Parameters
        ----------
        directory : str or Path
            Target directory for the screenshot files.  Created
            (including parents) if it does not already exist.

        Returns
        -------
        list[Path]
            Paths of all files written, in the order they were saved.

        Examples
        --------
        ::

            paths = screen.screenshot("/tmp/deck_screenshot")
            # [PosixPath('/tmp/deck_screenshot/key_0.png'),
            #  PosixPath('/tmp/deck_screenshot/card_1.png')]
        """
        out_dir = Path(directory)
        out_dir.mkdir(parents=True, exist_ok=True)
        written: list[Path] = []

        # Keys — decode device JPEG/BMP bytes back to PIL, re-encode as PNG.
        for index, key_slot in self._keys.items():
            if key_slot.image_bytes is None:
                continue
            img = Image.open(io.BytesIO(key_slot.image_bytes))
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            path = out_dir / f"key_{index}.png"
            path.write_bytes(buf.getvalue())
            written.append(path)

        # Touch-strip cards
        if self._touch_strip is not None:
            for index, card in enumerate(self._touch_strip.cards):
                # Render on demand; fall back to the last cached image
                # when the card does not support on-demand rendering.
                card_img = card.render() or card.rendered
                if card_img is None:
                    continue
                buf = io.BytesIO()
                rgb = card_img.convert("RGB") if card_img.mode != "RGB" else card_img
                rgb.save(buf, format="PNG")
                path = out_dir / f"card_{index}.png"
                path.write_bytes(buf.getvalue())
                written.append(path)

        # Info screen
        if self._info_screen is not None and self._info_screen.image is not None:
            info_img: Image.Image = self._info_screen.image
            if info_img.mode != "RGB":
                info_img = info_img.convert("RGB")
            buf = io.BytesIO()
            info_img.save(buf, format="PNG")
            path = out_dir / "info_screen.png"
            path.write_bytes(buf.getvalue())
            written.append(path)

        return written

name property

name: str

The screen name.

key_bg_image property

key_bg_image: bytes | None

Pre-rendered default key background image, or None.

Returns:

Type Description
bytes or None

Encoded image bytes ready to push to the device, or None if no default key background is configured.

key_bg_dirty property

key_bg_dirty: bool

Whether the key background needs re-rendering on all blank keys.

capabilities property

capabilities: DeviceCapabilities

The device capabilities this screen is configured for.

theme property writable

theme: Theme | None

Per-screen theme override, or None to inherit.

When set, this theme takes precedence over both the deck-level and system-wide theme for this screen. Set to None to fall back to the deck or system theme.

keys property

keys: Mapping[int, KeySlot]

Mapping of key index to :class:KeySlot for all configured keys.

Returns a read-only view; external code cannot mutate internal state.

encoders property

encoders: Mapping[int, EncoderSlot]

Mapping of encoder index to :class:EncoderSlot for all configured encoders.

Returns a read-only view; external code cannot mutate internal state.

touch_strip property

touch_strip: TouchStrip | None

The touch strip, or None if the device has no touchscreen.

info_screen property

info_screen: InfoScreen | None

The info screen, or None if the device has no info display.

touchstrip_background property writable

touchstrip_background: str

The fill colour for the touchscreen canvas behind cards.

cards property

cards: list[Card]

All touch-strip cards, or an empty list if the device has no touchscreen.

Returns a shallow copy; external code cannot mutate internal state.

__init__

__init__(name: str, caps: DeviceCapabilities) -> None

Initialise a screen for the given device capabilities.

Screens are normally created via :meth:Deck.screen rather than instantiated directly; the deck supplies the matching device capabilities.

Parameters:

Name Type Description Default
name str

Identifier used to look up and activate this screen via :meth:Deck.set_screen.

required
caps DeviceCapabilities

Capability snapshot describing the target device. Drives which controls are provisioned (touch strip, info screen) and which slot indices are valid.

required
Notes

Construction has the following side effects:

  • A :class:~deux.render.background_layer.BackgroundLayer is created for keys regardless of device.
  • A :class:~deux.ui.touch_strip.TouchStrip is provisioned only when the device has both a touchscreen and at least one dial.
  • An :class:~deux.ui.info_screen.InfoScreen is provisioned only when the device reports an info screen.
  • Bundled default backgrounds for the device's VID:PID are applied on a best-effort basis; failures are logged but never raised.
Source code in src/deux/ui/screen.py
def __init__(self, name: str, caps: DeviceCapabilities) -> None:
    """Initialise a screen for the given device capabilities.

    Screens are normally created via :meth:`Deck.screen` rather than
    instantiated directly; the deck supplies the matching device
    capabilities.

    Parameters
    ----------
    name : str
        Identifier used to look up and activate this screen via
        :meth:`Deck.set_screen`.
    caps : DeviceCapabilities
        Capability snapshot describing the target device.  Drives
        which controls are provisioned (touch strip, info screen)
        and which slot indices are valid.

    Notes
    -----
    Construction has the following side effects:

    * A :class:`~deux.render.background_layer.BackgroundLayer` is
      created for keys regardless of device.
    * A :class:`~deux.ui.touch_strip.TouchStrip` is provisioned only
      when the device has both a touchscreen and at least one dial.
    * An :class:`~deux.ui.info_screen.InfoScreen` is provisioned
      only when the device reports an info screen.
    * Bundled default backgrounds for the device's VID:PID are
      applied on a best-effort basis; failures are logged but never
      raised.
    """
    self._name = name
    self._caps = caps
    self._keys: dict[int, KeySlot] = {}
    self._encoders: dict[int, EncoderSlot] = {}
    self._theme: Theme | None = None
    self._key_bg_layer = BackgroundLayer(
        "key",
        key_size=self._caps.key_size,
        key_image_format=self._caps.key_image_format,
    )
    self._key_bg_dirty: bool = False

    if self._caps.has_touchscreen and self._caps.dial_count > 0:
        metrics = RenderMetrics(self._caps)
        self._touch_strip: TouchStrip | None = TouchStrip(
            panel_count=metrics.panel_count,
            panel_width=metrics.panel_width,
            panel_height=metrics.panel_height,
        )
    else:
        self._touch_strip = None

    if self._caps.has_info_screen:
        self._info_screen: InfoScreen | None = InfoScreen(
            width=self._caps.screen_width,
            height=self._caps.screen_height,
            image_format=self._caps.screen_image_format,
        )
    else:
        self._info_screen = None

    self._apply_default_backgrounds()

clear_key_bg_dirty

clear_key_bg_dirty() -> None

Reset the key-background dirty flag after a full key render.

Source code in src/deux/ui/screen.py
def clear_key_bg_dirty(self) -> None:
    """Reset the key-background dirty flag after a full key render."""
    self._key_bg_dirty = False

key

key(index: int) -> KeySlot

Get or create a key slot by index.

Parameters:

Name Type Description Default
index int

Key index (0 to key_count-1).

required

Returns:

Type Description
KeySlot

The KeySlot instance for this key on this screen.

Source code in src/deux/ui/screen.py
def key(self, index: int) -> KeySlot:
    """Get or create a key slot by index.

    Parameters
    ----------
    index
        Key index (0 to key_count-1).

    Returns
    -------
    KeySlot
        The KeySlot instance for this key on this screen.
    """
    key_count = self._caps.key_count
    if not 0 <= index < key_count:
        raise IndexError(
            f"Key index must be 0-{key_count - 1}, got {index}"
        )

    if index not in self._keys:
        self._keys[index] = KeySlot()
    return self._keys[index]

set_key

set_key(index: int, key: KeySlot) -> None

Replace the key slot at index with a custom key.

The same KeySlot (or :class:~deux.dui.DuiKey) instance may be installed on multiple screens at different slot indices — the screen's slot map is the single source of truth for routing, so no state is mutated on key itself.

Parameters:

Name Type Description Default
index int

Key index.

required
key KeySlot

The key slot to install.

required
Source code in src/deux/ui/screen.py
def set_key(self, index: int, key: KeySlot) -> None:
    """Replace the key slot at *index* with a custom key.

    The same ``KeySlot`` (or :class:`~deux.dui.DuiKey`) instance may
    be installed on multiple screens at different slot indices — the
    screen's slot map is the single source of truth for routing, so
    no state is mutated on *key* itself.

    Parameters
    ----------
    index
        Key index.
    key
        The key slot to install.
    """
    key_count = self._caps.key_count
    if not 0 <= index < key_count:
        raise IndexError(
            f"Key index must be 0-{key_count - 1}, got {index}"
        )
    if not isinstance(key, KeySlot):
        raise TypeError(f"Expected KeySlot, got {type(key).__name__}")
    self._keys[index] = key

encoder

encoder(index: int) -> EncoderSlot

Get or create an encoder slot by index.

Parameters:

Name Type Description Default
index int

Encoder index (0 to dial_count-1).

required

Returns:

Type Description
EncoderSlot

The EncoderSlot instance for this encoder on this screen.

Raises:

Type Description
IndexError

If index is out of range or device has no encoders.

Source code in src/deux/ui/screen.py
def encoder(self, index: int) -> EncoderSlot:
    """Get or create an encoder slot by index.

    Parameters
    ----------
    index
        Encoder index (0 to dial_count-1).

    Returns
    -------
    EncoderSlot
        The EncoderSlot instance for this encoder on this screen.

    Raises
    ------
    IndexError
        If index is out of range or device has no encoders.
    """
    dial_count = self._caps.dial_count
    if dial_count == 0:
        raise IndexError("This device has no encoders")
    if not 0 <= index < dial_count:
        raise IndexError(
            f"Encoder index must be 0-{dial_count - 1}, got {index}"
        )

    if index not in self._encoders:
        self._encoders[index] = EncoderSlot()
    return self._encoders[index]

card

card(index: int) -> Card

Get a touch-strip card zone by index.

Source code in src/deux/ui/screen.py
def card(self, index: int) -> Card:
    """Get a touch-strip card zone by index."""
    if self._touch_strip is None:
        raise IndexError("This device has no touchscreen")
    return self._touch_strip.card(index)

set_card

set_card(index: int, card: Card) -> None

Replace the card at index with a custom card.

Source code in src/deux/ui/screen.py
def set_card(self, index: int, card: Card) -> None:
    """Replace the card at *index* with a custom card."""
    if self._touch_strip is None:
        raise IndexError("This device has no touchscreen")
    self._touch_strip.set_card(index, card)

set_touchstrip_background_svg

set_touchstrip_background_svg(svg_data: bytes) -> None

Set a background SVG for the touchstrip.

The SVG is rasterized once, sliced into per-panel tiles, and cached. Cards with transparent areas will show the background through. When no background SVG is set, the solid :attr:touchstrip_background colour is used instead.

Parameters:

Name Type Description Default
svg_data bytes

Raw SVG content as UTF-8 bytes.

required

Raises:

Type Description
IndexError

If the device has no touchscreen.

Source code in src/deux/ui/screen.py
def set_touchstrip_background_svg(self, svg_data: bytes) -> None:
    """Set a background SVG for the touchstrip.

    The SVG is rasterized once, sliced into per-panel tiles, and
    cached.  Cards with transparent areas will show the background
    through.  When no background SVG is set, the solid
    :attr:`touchstrip_background` colour is used instead.

    Parameters
    ----------
    svg_data
        Raw SVG content as UTF-8 bytes.

    Raises
    ------
    IndexError
        If the device has no touchscreen.
    """
    if self._touch_strip is None:
        raise IndexError("This device has no touchscreen")
    self._touch_strip.set_background_svg(svg_data)

set_touchstrip_background_svg_from_file

set_touchstrip_background_svg_from_file(path: str | Path) -> None

Load a touchstrip background SVG from a file path.

Convenience wrapper around :meth:set_touchstrip_background_svg.

Parameters:

Name Type Description Default
path str | Path

Path to an SVG file.

required

Raises:

Type Description
IndexError

If the device has no touchscreen.

FileNotFoundError

If path does not exist.

Source code in src/deux/ui/screen.py
def set_touchstrip_background_svg_from_file(self, path: str | Path) -> None:
    """Load a touchstrip background SVG from a file path.

    Convenience wrapper around :meth:`set_touchstrip_background_svg`.

    Parameters
    ----------
    path
        Path to an SVG file.

    Raises
    ------
    IndexError
        If the device has no touchscreen.
    FileNotFoundError
        If *path* does not exist.
    """
    if self._touch_strip is None:
        raise IndexError("This device has no touchscreen")
    self._touch_strip.set_background_svg_from_file(path)

clear_touchstrip_background_svg

clear_touchstrip_background_svg() -> None

Remove the touchstrip background SVG.

Reverts to the solid :attr:touchstrip_background colour.

Raises:

Type Description
IndexError

If the device has no touchscreen.

Source code in src/deux/ui/screen.py
def clear_touchstrip_background_svg(self) -> None:
    """Remove the touchstrip background SVG.

    Reverts to the solid :attr:`touchstrip_background` colour.

    Raises
    ------
    IndexError
        If the device has no touchscreen.
    """
    if self._touch_strip is None:
        raise IndexError("This device has no touchscreen")
    self._touch_strip.clear_background_svg()

mark_all_dirty

mark_all_dirty() -> None

Flag every control on this screen for re-rendering.

Marks all configured keys, touch-strip cards, and the info screen (if present) as dirty so the next refresh cycle re-renders the entire screen. If the touch strip has a background SVG it is re-rasterized so that stylesheet changes are reflected. Useful when a global property such as the active stylesheet changes.

Source code in src/deux/ui/screen.py
def mark_all_dirty(self) -> None:
    """Flag every control on this screen for re-rendering.

    Marks all configured keys, touch-strip cards, and the info
    screen (if present) as dirty so the next refresh cycle
    re-renders the entire screen.  If the touch strip has a
    background SVG it is re-rasterized so that stylesheet changes
    are reflected.  Useful when a global property such as the
    active stylesheet changes.
    """
    for key_slot in self._keys.values():
        key_slot.mark_dirty()
    if self._key_bg_layer.has_svg:
        self._key_bg_layer.invalidate()
        self._key_bg_dirty = True
    if self._touch_strip is not None:
        self._touch_strip.invalidate_background()
    for card in self.cards:
        card.mark_dirty()
    if self._info_screen is not None:
        self._info_screen.mark_dirty()

collect_all_icons

collect_all_icons() -> set[str]

Collect all Iconify icon identifiers needed by this screen.

Iterates all DuiKey and DuiCard instances on this screen and aggregates their :meth:~deux.dui.svg_renderer.SvgRenderer.collect_icon_names results.

Returns:

Type Description
set[str]

A set of "prefix:icon" strings used across all keys and cards on this screen.

Source code in src/deux/ui/screen.py
def collect_all_icons(self) -> set[str]:
    """Collect all Iconify icon identifiers needed by this screen.

    Iterates all DuiKey and DuiCard instances on this screen and
    aggregates their :meth:`~deux.dui.svg_renderer.SvgRenderer.collect_icon_names`
    results.

    Returns
    -------
    set[str]
        A set of ``"prefix:icon"`` strings used across all
        keys and cards on this screen.
    """
    # Inline import: dui.card imports ui.cards.base, which would create
    # a cycle if hoisted to module top.
    from ..dui.card import DuiCard  # noqa: PLC0415

    icons: set[str] = set()
    for key_slot in self._keys.values():
        if isinstance(key_slot, DuiKey):
            icons.update(key_slot.collect_icon_names())
    if self._touch_strip is not None:
        for card in self._touch_strip.cards:
            if isinstance(card, DuiCard):
                icons.update(card.collect_icon_names())
    return icons

screenshot

screenshot(directory: str | Path) -> list[Path]

Save the current screen state as individual PNG files.

Writes one file per key that has been rendered, one file per touch-strip card that has been rendered, and one file for the info screen (if it has content). Blank or unrendered controls are skipped.

PNG is used instead of JPEG to avoid additional compression artifacts on the already-small device images.

Parameters:

Name Type Description Default
directory str or Path

Target directory for the screenshot files. Created (including parents) if it does not already exist.

required

Returns:

Type Description
list[Path]

Paths of all files written, in the order they were saved.

Examples:

::

paths = screen.screenshot("/tmp/deck_screenshot")
# [PosixPath('/tmp/deck_screenshot/key_0.png'),
#  PosixPath('/tmp/deck_screenshot/card_1.png')]
Source code in src/deux/ui/screen.py
def screenshot(self, directory: str | Path) -> list[Path]:
    """Save the current screen state as individual PNG files.

    Writes one file per key that has been rendered, one file per
    touch-strip card that has been rendered, and one file for the
    info screen (if it has content).  Blank or unrendered controls
    are skipped.

    PNG is used instead of JPEG to avoid additional compression
    artifacts on the already-small device images.

    Parameters
    ----------
    directory : str or Path
        Target directory for the screenshot files.  Created
        (including parents) if it does not already exist.

    Returns
    -------
    list[Path]
        Paths of all files written, in the order they were saved.

    Examples
    --------
    ::

        paths = screen.screenshot("/tmp/deck_screenshot")
        # [PosixPath('/tmp/deck_screenshot/key_0.png'),
        #  PosixPath('/tmp/deck_screenshot/card_1.png')]
    """
    out_dir = Path(directory)
    out_dir.mkdir(parents=True, exist_ok=True)
    written: list[Path] = []

    # Keys — decode device JPEG/BMP bytes back to PIL, re-encode as PNG.
    for index, key_slot in self._keys.items():
        if key_slot.image_bytes is None:
            continue
        img = Image.open(io.BytesIO(key_slot.image_bytes))
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        path = out_dir / f"key_{index}.png"
        path.write_bytes(buf.getvalue())
        written.append(path)

    # Touch-strip cards
    if self._touch_strip is not None:
        for index, card in enumerate(self._touch_strip.cards):
            # Render on demand; fall back to the last cached image
            # when the card does not support on-demand rendering.
            card_img = card.render() or card.rendered
            if card_img is None:
                continue
            buf = io.BytesIO()
            rgb = card_img.convert("RGB") if card_img.mode != "RGB" else card_img
            rgb.save(buf, format="PNG")
            path = out_dir / f"card_{index}.png"
            path.write_bytes(buf.getvalue())
            written.append(path)

    # Info screen
    if self._info_screen is not None and self._info_screen.image is not None:
        info_img: Image.Image = self._info_screen.image
        if info_img.mode != "RGB":
            info_img = info_img.convert("RGB")
        buf = io.BytesIO()
        info_img.save(buf, format="PNG")
        path = out_dir / "info_screen.png"
        path.write_bytes(buf.getvalue())
        written.append(path)

    return written

TouchStrip

Manage card zones on the Stream Deck touch strip.

The number of card zones is determined by the device's dial count (typically 4 for Stream Deck+).

The background_color fills the entire touchscreen canvas — including the margin and gap areas outside card panels. Each :class:Screen owns its own TouchStrip, so different screens can use different background colours.

An optional background SVG (covering the full touchscreen, e.g. 800x100 on Stream Deck+) can be set via :meth:set_background_svg. When set, the SVG is rasterized once, sliced into per-panel tiles, and cached. Cards render with transparent backgrounds and are composited onto their tile at render time.

Parameters:

Name Type Description Default
panel_count int

Number of card zones.

4
panel_width int

Width of each card panel in pixels.

200
panel_height int

Height of each card panel in pixels.

100
background_color str

Initial background colour.

'black'
Source code in src/deux/ui/touch_strip.py
class TouchStrip:
    """Manage card zones on the Stream Deck touch strip.

    The number of card zones is determined by the device's dial count
    (typically 4 for Stream Deck+).

    The *background_color* fills the entire touchscreen canvas — including
    the margin and gap areas outside card panels.  Each :class:`Screen`
    owns its own ``TouchStrip``, so different screens can use different
    background colours.

    An optional background SVG (covering the full touchscreen, e.g.
    800x100 on Stream Deck+) can be set via :meth:`set_background_svg`.
    When set, the SVG is rasterized once, sliced into per-panel tiles,
    and cached.  Cards render with transparent backgrounds and are
    composited onto their tile at render time.

    Parameters
    ----------
    panel_count
        Number of card zones.
    panel_width
        Width of each card panel in pixels.
    panel_height
        Height of each card panel in pixels.
    background_color
        Initial background colour.
    """

    def __init__(
        self,
        panel_count: int = 4,
        panel_width: int = 200,
        panel_height: int = 100,
        background_color: str = "black",
    ) -> None:
        self._panel_count = panel_count
        self._panel_width = panel_width
        self._panel_height = panel_height
        self._cards: list[Card] = [BlankCard() for _ in range(panel_count)]
        self._background_color = background_color
        self._bg_layer = BackgroundLayer(
            "touchstrip",
            panel_count=panel_count,
            panel_width=panel_width,
            panel_height=panel_height,
        )

    @property
    def panel_count(self) -> int:
        """Number of card zones on this touch strip."""
        return self._panel_count

    @property
    def background_color(self) -> str:
        """The fill colour for the touchscreen canvas (margins and gaps)."""
        return self._background_color

    @background_color.setter
    def background_color(self, value: str) -> None:
        if self._background_color != value:
            self._background_color = value
            for card in self._cards:
                card.mark_dirty()

    def card(self, index: int) -> Card:
        """Get a card zone by index."""
        if not 0 <= index < self._panel_count:
            raise IndexError(
                f"Card index must be 0-{self._panel_count - 1}, got {index}"
            )
        return self._cards[index]

    def set_card(self, index: int, card: Card) -> None:
        """Replace the card at *index* with a custom card.

        The same ``Card`` instance may be installed on multiple screens
        (and, on a single screen, in different slots across screens) —
        the strip's slot list is the single source of truth for routing,
        so no state is mutated on *card* itself.

        Parameters
        ----------
        index
            Card zone index.
        card
            A :class:`Card` subclass instance.

        Raises
        ------
        IndexError
            If *index* is out of range.
        TypeError
            If *card* is not a :class:`Card` instance.
        """
        if not 0 <= index < self._panel_count:
            raise IndexError(
                f"Card index must be 0-{self._panel_count - 1}, got {index}"
            )
        if not isinstance(card, Card):
            msg = f"Expected a Card instance, got {type(card).__name__}"
            raise TypeError(msg)
        self._cards[index] = card

    @property
    def cards(self) -> list[Card]:
        """All card zones on this touch strip.

        Returns a shallow copy; external code cannot mutate internal state.
        """
        return list(self._cards)

    @property
    def panel_width(self) -> int:
        """Width of each card panel in pixels."""
        return self._panel_width

    @property
    def panel_height(self) -> int:
        """Height of each card panel in pixels."""
        return self._panel_height

    @property
    def bg_tiles(self) -> list[bytes] | None:
        """Pre-sliced background tiles as PNG bytes, or ``None`` if no background SVG is set.

        Returns a shallow copy when tiles exist; external code cannot mutate internal state.
        """
        return self._bg_layer.tiles

    def bg_tile(self, index: int) -> bytes | None:
        """Return the cached background tile for panel *index*, or ``None``.

        Parameters
        ----------
        index
            Panel index (0 to panel_count-1).

        Returns
        -------
        bytes or None
            PNG-encoded tile bytes, or ``None`` if no background SVG is set.
        """
        return self._bg_layer.tile(index)

    @property
    def bg_svg_root(self) -> ET.Element | None:
        """The parsed background SVG root element, or ``None``.

        Used by the SVG-native pipeline to compose background layers
        with card SVGs at the vector level before rasterisation.
        """
        return self._bg_layer.svg_root

    @property
    def bg_layer(self) -> BackgroundLayer:
        """The background layer managing SVG, tiles, and rasterisation."""
        return self._bg_layer

    def set_background_svg(self, svg_data: bytes) -> None:
        """Set a background SVG for the entire touchstrip.

        The SVG is parsed and cached as an XML element tree for
        SVG-level composition.  For backward compatibility, the SVG
        is also rasterized and sliced into per-panel PIL tiles.

        All cards are marked dirty so they re-render with the new
        background.

        Parameters
        ----------
        svg_data
            Raw SVG content as UTF-8 bytes.
        """
        self._bg_layer.set_svg(svg_data)
        for card in self._cards:
            card.mark_dirty()

    def set_background_svg_from_file(self, path: str | Path) -> None:
        """Load a background SVG from a file path.

        Convenience wrapper around :meth:`set_background_svg`.

        Parameters
        ----------
        path
            Path to an SVG file.

        Raises
        ------
        FileNotFoundError
            If *path* does not exist.
        """
        svg_data = Path(path).read_bytes()
        self.set_background_svg(svg_data)

    def clear_background_svg(self) -> None:
        """Remove the background SVG and revert to solid-colour background.

        All cards are marked dirty so they re-render without the
        background tiles.
        """
        self._bg_layer.clear()
        for card in self._cards:
            card.mark_dirty()

    def invalidate_background(self) -> None:
        """Re-rasterize the cached background SVG tiles.

        Call this when a global property that affects SVG rendering
        (such as the active stylesheet) has changed.  If no background
        SVG is set this is a no-op.
        """
        if self._bg_layer.has_svg:
            self._bg_layer.invalidate()

    async def set_background_svg_async(self, svg_data: bytes) -> None:
        """Async variant of :meth:`set_background_svg`.

        Offloads the CPU-bound SVG rasterisation to a worker thread
        so the event loop stays responsive.

        Parameters
        ----------
        svg_data
            Raw SVG content as UTF-8 bytes.
        """
        self._bg_layer.set_svg_deferred(svg_data)
        await asyncio.to_thread(self._bg_layer.rasterize)
        for card in self._cards:
            card.mark_dirty()

    async def invalidate_background_async(self) -> None:
        """Async variant of :meth:`invalidate_background`.

        Offloads the CPU-bound SVG rasterisation to a worker thread
        so the event loop stays responsive.
        """
        if self._bg_layer.has_svg:
            await asyncio.to_thread(self._bg_layer.rasterize)

    @property
    def any_dirty(self) -> bool:
        """Whether any card zone needs re-rendering."""
        return any(card.is_dirty for card in self._cards)

panel_count property

panel_count: int

Number of card zones on this touch strip.

background_color property writable

background_color: str

The fill colour for the touchscreen canvas (margins and gaps).

cards property

cards: list[Card]

All card zones on this touch strip.

Returns a shallow copy; external code cannot mutate internal state.

panel_width property

panel_width: int

Width of each card panel in pixels.

panel_height property

panel_height: int

Height of each card panel in pixels.

bg_tiles property

bg_tiles: list[bytes] | None

Pre-sliced background tiles as PNG bytes, or None if no background SVG is set.

Returns a shallow copy when tiles exist; external code cannot mutate internal state.

bg_svg_root property

bg_svg_root: Element | None

The parsed background SVG root element, or None.

Used by the SVG-native pipeline to compose background layers with card SVGs at the vector level before rasterisation.

bg_layer property

bg_layer: BackgroundLayer

The background layer managing SVG, tiles, and rasterisation.

any_dirty property

any_dirty: bool

Whether any card zone needs re-rendering.

card

card(index: int) -> Card

Get a card zone by index.

Source code in src/deux/ui/touch_strip.py
def card(self, index: int) -> Card:
    """Get a card zone by index."""
    if not 0 <= index < self._panel_count:
        raise IndexError(
            f"Card index must be 0-{self._panel_count - 1}, got {index}"
        )
    return self._cards[index]

set_card

set_card(index: int, card: Card) -> None

Replace the card at index with a custom card.

The same Card instance may be installed on multiple screens (and, on a single screen, in different slots across screens) — the strip's slot list is the single source of truth for routing, so no state is mutated on card itself.

Parameters:

Name Type Description Default
index int

Card zone index.

required
card Card

A :class:Card subclass instance.

required

Raises:

Type Description
IndexError

If index is out of range.

TypeError

If card is not a :class:Card instance.

Source code in src/deux/ui/touch_strip.py
def set_card(self, index: int, card: Card) -> None:
    """Replace the card at *index* with a custom card.

    The same ``Card`` instance may be installed on multiple screens
    (and, on a single screen, in different slots across screens) —
    the strip's slot list is the single source of truth for routing,
    so no state is mutated on *card* itself.

    Parameters
    ----------
    index
        Card zone index.
    card
        A :class:`Card` subclass instance.

    Raises
    ------
    IndexError
        If *index* is out of range.
    TypeError
        If *card* is not a :class:`Card` instance.
    """
    if not 0 <= index < self._panel_count:
        raise IndexError(
            f"Card index must be 0-{self._panel_count - 1}, got {index}"
        )
    if not isinstance(card, Card):
        msg = f"Expected a Card instance, got {type(card).__name__}"
        raise TypeError(msg)
    self._cards[index] = card

bg_tile

bg_tile(index: int) -> bytes | None

Return the cached background tile for panel index, or None.

Parameters:

Name Type Description Default
index int

Panel index (0 to panel_count-1).

required

Returns:

Type Description
bytes or None

PNG-encoded tile bytes, or None if no background SVG is set.

Source code in src/deux/ui/touch_strip.py
def bg_tile(self, index: int) -> bytes | None:
    """Return the cached background tile for panel *index*, or ``None``.

    Parameters
    ----------
    index
        Panel index (0 to panel_count-1).

    Returns
    -------
    bytes or None
        PNG-encoded tile bytes, or ``None`` if no background SVG is set.
    """
    return self._bg_layer.tile(index)

set_background_svg

set_background_svg(svg_data: bytes) -> None

Set a background SVG for the entire touchstrip.

The SVG is parsed and cached as an XML element tree for SVG-level composition. For backward compatibility, the SVG is also rasterized and sliced into per-panel PIL tiles.

All cards are marked dirty so they re-render with the new background.

Parameters:

Name Type Description Default
svg_data bytes

Raw SVG content as UTF-8 bytes.

required
Source code in src/deux/ui/touch_strip.py
def set_background_svg(self, svg_data: bytes) -> None:
    """Set a background SVG for the entire touchstrip.

    The SVG is parsed and cached as an XML element tree for
    SVG-level composition.  For backward compatibility, the SVG
    is also rasterized and sliced into per-panel PIL tiles.

    All cards are marked dirty so they re-render with the new
    background.

    Parameters
    ----------
    svg_data
        Raw SVG content as UTF-8 bytes.
    """
    self._bg_layer.set_svg(svg_data)
    for card in self._cards:
        card.mark_dirty()

set_background_svg_from_file

set_background_svg_from_file(path: str | Path) -> None

Load a background SVG from a file path.

Convenience wrapper around :meth:set_background_svg.

Parameters:

Name Type Description Default
path str | Path

Path to an SVG file.

required

Raises:

Type Description
FileNotFoundError

If path does not exist.

Source code in src/deux/ui/touch_strip.py
def set_background_svg_from_file(self, path: str | Path) -> None:
    """Load a background SVG from a file path.

    Convenience wrapper around :meth:`set_background_svg`.

    Parameters
    ----------
    path
        Path to an SVG file.

    Raises
    ------
    FileNotFoundError
        If *path* does not exist.
    """
    svg_data = Path(path).read_bytes()
    self.set_background_svg(svg_data)

clear_background_svg

clear_background_svg() -> None

Remove the background SVG and revert to solid-colour background.

All cards are marked dirty so they re-render without the background tiles.

Source code in src/deux/ui/touch_strip.py
def clear_background_svg(self) -> None:
    """Remove the background SVG and revert to solid-colour background.

    All cards are marked dirty so they re-render without the
    background tiles.
    """
    self._bg_layer.clear()
    for card in self._cards:
        card.mark_dirty()

invalidate_background

invalidate_background() -> None

Re-rasterize the cached background SVG tiles.

Call this when a global property that affects SVG rendering (such as the active stylesheet) has changed. If no background SVG is set this is a no-op.

Source code in src/deux/ui/touch_strip.py
def invalidate_background(self) -> None:
    """Re-rasterize the cached background SVG tiles.

    Call this when a global property that affects SVG rendering
    (such as the active stylesheet) has changed.  If no background
    SVG is set this is a no-op.
    """
    if self._bg_layer.has_svg:
        self._bg_layer.invalidate()

set_background_svg_async async

set_background_svg_async(svg_data: bytes) -> None

Async variant of :meth:set_background_svg.

Offloads the CPU-bound SVG rasterisation to a worker thread so the event loop stays responsive.

Parameters:

Name Type Description Default
svg_data bytes

Raw SVG content as UTF-8 bytes.

required
Source code in src/deux/ui/touch_strip.py
async def set_background_svg_async(self, svg_data: bytes) -> None:
    """Async variant of :meth:`set_background_svg`.

    Offloads the CPU-bound SVG rasterisation to a worker thread
    so the event loop stays responsive.

    Parameters
    ----------
    svg_data
        Raw SVG content as UTF-8 bytes.
    """
    self._bg_layer.set_svg_deferred(svg_data)
    await asyncio.to_thread(self._bg_layer.rasterize)
    for card in self._cards:
        card.mark_dirty()

invalidate_background_async async

invalidate_background_async() -> None

Async variant of :meth:invalidate_background.

Offloads the CPU-bound SVG rasterisation to a worker thread so the event loop stays responsive.

Source code in src/deux/ui/touch_strip.py
async def invalidate_background_async(self) -> None:
    """Async variant of :meth:`invalidate_background`.

    Offloads the CPU-bound SVG rasterisation to a worker thread
    so the event loop stays responsive.
    """
    if self._bg_layer.has_svg:
        await asyncio.to_thread(self._bg_layer.rasterize)