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:~deckui.ui.touch_strip.TouchStrip before the user assigns real cards. Returns None from :meth:render so the compositor skips the slot and the :attr:~deckui.ui.touch_strip.TouchStrip.background_color is visible.

Source code in src/deckui/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:`~deckui.ui.touch_strip.TouchStrip`
    before the user assigns real cards.  Returns ``None`` from :meth:`render`
    so the compositor skips the slot and the
    :attr:`~deckui.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/deckui/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._request_refresh: AsyncHandler | None = None

    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.

        This is set automatically by :class:`~deckui.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.
        """
        self._request_refresh = callback

    async def request_refresh(self) -> None:
        """Ask the deck to re-render this card.

        No-op if no refresh callback has been registered.
        """
        if self._request_refresh is not None:
            await self._request_refresh()

    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:`~deckui.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 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

    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:`~deckui.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

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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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.

This is set automatically by :class:~deckui.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.

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

    This is set automatically by :class:`~deckui.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.
    """
    self._request_refresh = callback

request_refresh async

request_refresh() -> None

Ask the deck to re-render this card.

No-op if no refresh callback has been registered.

Source code in src/deckui/ui/cards/base.py
async def request_refresh(self) -> None:
    """Ask the deck to re-render this card.

    No-op if no refresh callback has been registered.
    """
    if self._request_refresh is not None:
        await self._request_refresh()

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:~deckui.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/deckui/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:`~deckui.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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/ui/cards/base.py
async def prepare_assets(self) -> None:
    """Prepare external assets needed for rendering this card."""
    return None

dispatch_encoder_turn async

dispatch_encoder_turn(direction: int) -> None

Dispatch an encoder-turn event to the card.

Source code in src/deckui/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/deckui/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/deckui/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/deckui/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:~deckui.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/deckui/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:`~deckui.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/deckui/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/deckui/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/deckui/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/deckui/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:~deckui.DuiCard to :attr:card in __init__.
  2. Wire service-event subscriptions and DUI-event forwards (typically via :meth:~deckui.DuiCard.bind, :meth:~deckui.DuiCard.bind_range, :meth:~deckui.DuiCard.bind_many, :meth:~deckui.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/deckui/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:`~deckui.DuiCard` to :attr:`card` in ``__init__``.
    2. Wire service-event subscriptions and DUI-event forwards (typically
       via :meth:`~deckui.DuiCard.bind`,
       :meth:`~deckui.DuiCard.bind_range`,
       :meth:`~deckui.DuiCard.bind_many`,
       :meth:`~deckui.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:`~deckui.Deck` instance.
        """

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

        Default implementation is a no-op.  Override to cancel
        background tasks or release deck-linked resources.
        """

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:~deckui.Deck instance.

required
Source code in src/deckui/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:`~deckui.Deck` instance.
    """

on_detach async

on_detach() -> None

Hook invoked from the app's on_disconnect callback.

Default implementation is a no-op. Override to cancel background tasks or release deck-linked resources.

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

    Default implementation is a no-op.  Override to cancel
    background tasks or release deck-linked resources.
    """

KeyController

Base class for service-backed key controllers.

Mirror of :class:CardController for :class:~deckui.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/deckui/ui/controller.py
class KeyController:
    """Base class for service-backed key controllers.

    Mirror of :class:`CardController` for :class:`~deckui.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:`~deckui.Deck` instance.
        """

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

        Default implementation is a no-op.
        """

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:~deckui.Deck instance.

required
Source code in src/deckui/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:`~deckui.Deck` instance.
    """

on_detach async

on_detach() -> None

Hook invoked from the app's on_disconnect callback.

Default implementation is a no-op.

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

    Default implementation is a no-op.
    """

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/deckui/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."""
        logger.debug("DialAccumulator.tick: direction=%+d pending=%d", direction, self._pending)
        self._pending = max(-self._max_steps, min(self._max_steps, self._pending + direction))
        if self._flush_task is not None:
            self._flush_task.cancel()
        self._flush_task = asyncio.create_task(self._schedule_flush())

    async def _schedule_flush(self) -> None:
        """Wait for the debounce delay, then flush accumulated ticks."""
        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)

    def cancel(self) -> None:
        """Cancel any pending flush and reset the accumulated count."""
        if self._flush_task is not None:
            self._flush_task.cancel()
            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.

Source code in src/deckui/ui/controls/dial_accumulator.py
def tick(self, direction: int) -> None:
    """Add *direction* (+1 or -1). Clamps to ±max_steps."""
    logger.debug("DialAccumulator.tick: direction=%+d pending=%d", direction, self._pending)
    self._pending = max(-self._max_steps, min(self._max_steps, self._pending + direction))
    if self._flush_task is not None:
        self._flush_task.cancel()
    self._flush_task = asyncio.create_task(self._schedule_flush())

cancel

cancel() -> None

Cancel any pending flush and reset the accumulated count.

Source code in src/deckui/ui/controls/dial_accumulator.py
def cancel(self) -> None:
    """Cancel any pending flush and reset the accumulated count."""
    if self._flush_task is not None:
        self._flush_task.cancel()
        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/deckui/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:`~deckui.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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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:~deckui.dui.event_map.EventMap).

Source code in src/deckui/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:`~deckui.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/deckui/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/deckui/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._request_refresh: AsyncHandler | None = None

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

        This is set automatically by :class:`~deckui.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.
        """
        self._request_refresh = callback

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

        No-op if no refresh callback has been registered.
        """
        if self._request_refresh is not None:
            await self._request_refresh()

    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 registered handlers."""
        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_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.

This is set automatically by :class:~deckui.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/deckui/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.

    This is set automatically by :class:`~deckui.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.
    """
    self._request_refresh = callback

request_refresh async

request_refresh() -> None

Ask the deck to re-render the active screen.

No-op if no refresh callback has been registered.

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

    No-op if no refresh callback has been registered.
    """
    if self._request_refresh is not None:
        await self._request_refresh()

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/deckui/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/deckui/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 registered handlers.

Source code in src/deckui/ui/controls/key_slot.py
async def dispatch(self, pressed: bool) -> None:
    """Dispatch a press or release event through the registered handlers."""
    handler = self._press_handler if pressed else self._release_handler
    if handler is not None:
        await handler()

mark_clean

mark_clean() -> None

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

Source code in src/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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/deckui/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:~deckui.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/deckui/ui/screen.py
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:`~deckui.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:
        self._name = name
        self._caps = caps
        self._keys: dict[int, KeySlot] = {}
        self._encoders: dict[int, EncoderSlot] = {}

        if self._caps.has_touchscreen and self._caps.dial_count > 0:
            from ..render.metrics import RenderMetrics

            metrics = RenderMetrics(self._caps)
            self._touch_strip: TouchStrip | None = TouchStrip(
                panel_count=metrics.panel_count,
            )
        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

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

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

    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:`~deckui.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) -> dict[int, KeySlot]:
        """Mapping of key index to :class:`KeySlot` for all configured keys."""
        return self._keys

    @property
    def encoders(self) -> dict[int, EncoderSlot]:
        """Mapping of encoder index to :class:`EncoderSlot` for all configured encoders."""
        return 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:
        if self._touch_strip is not None:
            self._touch_strip.background_color = value

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

name property

name: str

The screen name.

capabilities property

capabilities: DeviceCapabilities

The device capabilities this screen is configured for.

keys property

keys: dict[int, KeySlot]

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

encoders property

encoders: dict[int, EncoderSlot]

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

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.

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/deckui/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:~deckui.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/deckui/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:`~deckui.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/deckui/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/deckui/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/deckui/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)

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.

Parameters:

Name Type Description Default
panel_count int

Number of card zones.

4
background_color str

Initial background colour.

'black'
Source code in src/deckui/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.

    Parameters
    ----------
    panel_count
        Number of card zones.
    background_color
        Initial background colour.
    """

    def __init__(
        self,
        panel_count: int = 4,
        background_color: str = "black",
    ) -> None:
        self._panel_count = panel_count
        self._cards: list[Card] = [BlankCard() for _ in range(panel_count)]
        self._background_color = background_color

    @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."""
        return self._cards

    @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.

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/deckui/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/deckui/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