Creating DUI Packages¶
A .dui package is a self-contained directory that declaratively defines a Stream Deck UI — either a touchscreen card or a physical key — using an SVG layout, a YAML manifest, and optional image assets. No Python rendering code required.
Package Structure¶
MyPackage.dui/
manifest.yaml # Required – metadata, bindings, events, regions
layout.svg # Required – SVG layout template
assets/ # Optional – static images (PNG, JPEG, etc.)
icon.png
The Manifest¶
manifest.yaml is the heart of every package. It has four required top-level fields, optional metadata for repository publishing, and three optional sections for bindings, events, and regions.
Required Fields¶
| Field | Type | Description |
|---|---|---|
name |
str |
Package name (non-empty) |
type |
str |
"TouchStripCard" (touchscreen panel) or "Key" (physical key) |
version |
int |
Positive integer (>= 1) |
layout |
str |
Relative path to the SVG file (e.g. layout.svg) |
Metadata Fields¶
These fields are optional for local use but required when publishing to a DUI package repository. The verify --strict tool enforces description and author.
| Field | Type | Required for repo | Description |
|---|---|---|---|
description |
str |
yes | One-line summary for search and display |
author |
str |
yes | Author name and optional email ("Jane Doe <jane@example.com>") |
license |
str |
no | SPDX license identifier (MIT, Apache-2.0, CC-BY-4.0) |
tags |
list[str] |
no | Free-form lowercase labels for search ([music, media, spotify]) |
category |
str |
no | Primary category from a controlled vocabulary (see below) |
url |
str |
no | Project or source URL |
icon |
str |
no | Path to a thumbnail in assets/ for repository listings |
min_deckui |
str |
no | Minimum DeckUI version required (e.g. "0.5.0") |
device |
list[str] |
no | Explicit device compatibility ([StreamDeckPlus, StreamDeckXL]) |
Valid Categories¶
media · productivity · system · gaming · social · development · utilities · streaming · home-automation · communication
Optional Sections¶
| Section | Description |
|---|---|
bindings |
Data bindings that connect values to SVG elements |
events |
Map physical inputs to named semantic events |
regions |
Touchscreen hit-test areas (TouchStripCard only) |
Minimal Example¶
name: HelloKey
type: Key
version: 1
layout: layout.svg
bindings:
label:
type: text
node: label
default: "Hello"
Repository-Ready Example¶
name: NowPlaying
type: TouchStripCard
version: 2
layout: layout.svg
description: "Media player card with album art, progress bar, and transport controls"
author: "Jane Doe <jane@example.com>"
license: MIT
category: media
tags: [music, spotify, media-player]
url: https://github.com/jane/nowplaying-dui
icon: assets/icon.png
min_deckui: "0.5.0"
bindings:
title:
type: text
node: title
default: "No Track"
max_width: 90
overflow: ellipsis
SVG Layout¶
The SVG file is a standard SVG document. Key rules:
- The root
<svg>must have explicitwidthandheightattributes. - Every element referenced by a binding must have a unique
idattribute. - Standard dimensions:
- Touchscreen card: 197 × 98 px
- Key: 120 × 120 px
Example Key SVG¶
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120">
<rect width="120" height="120" fill="#1a1a2e"/>
<text id="label" x="60" y="68" text-anchor="middle"
font-size="16" fill="white">Hello</text>
<rect id="indicator" x="10" y="100" width="100" height="6"
rx="3" fill="#333333"/>
</svg>
Assets¶
Files in the assets/ directory are loaded into memory and inlined as base64 data URIs at render time. Reference them in your SVG with href="assets/filename.png" on <image> elements.
Bindings¶
Bindings connect runtime data to SVG elements. Each binding has a name (the key in the bindings map), a type, and a node (the SVG element ID to target).
text¶
Bind a string value to a <text> element.
title:
type: text
node: title
default: "Untitled"
max_width: 90 # optional – truncate beyond this pixel width
overflow: ellipsis # "ellipsis" (default) or "clip"
wrap: false # optional – enable word-wrapping
max_height: 40 # optional – vertical budget for wrapped text (requires wrap)
line_height: 14.0 # optional – line spacing in px (requires wrap)
If wrap: true, then max_width is required. Text is wrapped into <tspan> elements using pixel-accurate font metrics.
image¶
Bind a PIL Image or raw bytes to an <image> element.
cover:
type: image
node: cover
fit: cover # "cover" (default), "contain", or "fill"
placeholder_node: placeholder # optional – shown when image is None
visibility¶
Toggle an element's display.
color¶
Set a color attribute on an element.
accent_color:
type: color
node: accent
attribute: fill # "fill" (default), "stroke", or "color"
default: "#ff0000"
range¶
Scale an element's width or height proportionally to a 0–1 value. Useful for progress bars.
progress:
type: range
node: progress_bar
default: 0.0
direction: horizontal # "horizontal" (default) or "vertical"
slider¶
Translate an element's position proportionally to a 0–1 value.
knob:
type: slider
node: knob
default: 0.5
direction: horizontal
min_pos: 10.0 # required – position at value 0.0
max_pos: 180.0 # required – position at value 1.0
toggle¶
Switch visibility between two elements based on a boolean.
play_pause:
type: toggle
node_on: icon_pause # shown when truthy
node_off: icon_play # shown when falsy
default: false
Note:
toggleusesnode_on/node_offinstead ofnode.
iconify¶
Embed an Iconify icon into a <g> element.
status_icon:
type: iconify
node: icon_group
size: 24 # required – icon size in px
default: "line-md:home"
Icons are fetched from the Iconify API and cached in-process.
Events¶
Events map physical hardware inputs to named semantic actions. Define them as a list under the events key.
events:
- name: activate
source: key_press_release
max_duration_ms: 300
- name: hold
source: key_hold
hold_ms: 500
Event Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name |
str |
yes | Unique semantic name |
source |
str |
yes | Physical input source (see table below) |
direction |
str |
no | "left" or "right" (turn events only) |
max_duration_ms |
int |
no | Max press duration for *_press_release (default 500) |
hold_ms |
int |
no | Hold threshold for *_hold (default 500) |
accumulate |
bool |
no | Debounce rapid ticks via DialAccumulator (turn events only, default false) |
accumulate_delay |
float |
no | Seconds to wait after last tick before flushing (default 0.25, requires accumulate: true) |
accumulate_max_steps |
int |
no | Cap on accumulated ticks (default 10, requires accumulate: true) |
busy |
bool |
no | Enable spinner animation and event suppression (default false) |
Sources¶
Key sources (for type: Key):
| Source | Fires when |
|---|---|
key_press |
Key pressed down |
key_release |
Key released (always) |
key_press_release |
Released within max_duration_ms (suppressed if hold fired) |
key_hold |
Held for hold_ms |
Encoder sources (for type: TouchStripCard):
| Source | Fires when |
|---|---|
encoder_press |
Encoder pressed down |
encoder_release |
Encoder released (always) |
encoder_press_release |
Released within max_duration_ms (suppressed if hold fired) |
encoder_turn |
Encoder rotated while not pressed; filter with direction |
encoder_press_turn |
Rotated while pressed; filter with direction |
encoder_hold |
Held for hold_ms |
encoder_turn and encoder_press_turn are mutually exclusive at runtime. While the encoder is pressed, only encoder_press_turn mappings are eligible — turning a pressed encoder will never fire encoder_turn, even if no encoder_press_turn is declared (or its direction filter doesn't match). Any turn while pressed cancels a pending encoder_hold.
After releasing the encoder following a press cycle that included at least one turn, plain encoder_turn events are suppressed for a short grace window (150 ms by default). This debounces the very common ergonomic mistake of letting a finger continue to nudge the dial while lifting off, so a press_turn gesture cannot accidentally bleed into an unrelated encoder_turn handler. Releases without an intervening turn — and encoder_press_turn events themselves — are never suppressed.
Touch sources (for regions):
| Source | Fires when |
|---|---|
tap |
Touch tap on a region |
long_press |
Long press on a region |
Accumulated Turns¶
By default, each encoder tick fires the handler once. For continuous controls like volume or brightness, rapid ticks produce many individual calls. The accumulate option debounces rapid ticks and flushes them as a single callback with the net step count.
events:
- name: brightness_up
source: encoder_turn
direction: right
accumulate: true
accumulate_delay: 0.2 # optional – seconds before flush (default 0.25)
accumulate_max_steps: 5 # optional – cap on net ticks (default 10)
- name: brightness_down
source: encoder_turn
direction: left
accumulate: true
- name: kelvin_up
source: encoder_press_turn
direction: right
accumulate: true
accumulate_delay: 0.1
accumulate_max_steps: 5
The handler receives a single int argument — the net accumulated steps (positive for right, negative for left):
@card.on("brightness_up")
async def handle(steps: int):
# steps is e.g. 3 if the user turned right 3 ticks quickly
new_val = card.adjust_range("brightness", steps * 5, min_val=0, max_val=100)
Validation rules:
accumulateis only valid onencoder_turnandencoder_press_turnsourcesaccumulate_delayandaccumulate_max_stepsrequireaccumulate: trueaccumulate_delaymust be a positive numberaccumulate_max_stepsmust be a positive integer
Regions (TouchStripCard Only)¶
Regions define rectangular touch-sensitive areas on the touchscreen.
regions:
album_art:
x: 0
y: 0
width: 98
height: 98
events: [tap, long_press]
controls:
x: 98
y: 0
width: 99
height: 98
events: [tap]
All coordinates are non-negative integers. The events list is optional and restricts which touch gestures the region responds to.
Spinner & Busy State¶
When an event handler takes time to complete (e.g. making an API call), users get no immediate visual feedback and may press the button again. The spinner system solves this by letting your application explicitly enter and exit a busy state with a visual animation.
Defining a Spinner¶
Add a spinner section to your manifest:
spinner:
type: rotation # "rotation", "pulse", or "custom"
node: spinner_icon # SVG element ID to animate (required for rotation/pulse)
frames: 12 # frames per cycle (default 12, minimum 2)
interval_ms: 80 # ms between frames (default 80, minimum 10)
Your SVG must include an element with the spinner node ID. It's typically hidden by default and made visible during animation:
Spinner Types¶
rotation¶
Rotates the SVG node by 360/frames degrees each frame around its centre. Good for loading spinners.
pulse¶
Cycles the node's opacity between 0.2 and 1.0 in a triangle wave. Good for subtle "working" indicators.
custom¶
Use your own pre-rendered frames. Place them in one of two formats:
Numbered PNGs in assets/spinner/:
Animated GIF at assets/spinner.gif:
For custom spinners, the node field is optional (ignored).
How It Works¶
The busy state is entirely controlled by your application via two methods:
await card.start_busy()/await key.start_busy()— enters the busy state and starts the spinner animationawait card.finish_busy()/await key.finish_busy()— stops the spinner and exits the busy state
When busy:
- Spinner frames are rendered on top of the current UI state — all bindings (text, colors, images, etc.) are preserved. Only the spinner node is animated; the rest of the key/card looks exactly as it did before.
- For touchscreen cards, only the affected panel region is updated (not the entire strip)
- The normal refresh cycle skips animating slots to avoid overwriting frames
- Frames are re-generated each time the spinner starts so they always reflect the latest binding values
Duplicate start_busy() calls while already busy are no-ops.
finish_busy() when not busy is also a no-op.
Controlling the Busy Lifecycle¶
Your application decides when to start and stop the spinner. This decouples the spinner from the event handler — the handler can return immediately while the spinner keeps running until an external signal arrives.
@key.on_event("toggle")
async def handle():
await key.start_busy()
# Fire the API call. The spinner keeps running
# after this handler returns.
await smart_home_api.toggle_light()
# Later, when the external system confirms the new state:
async def on_state_update(new_state):
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy() # stops the spinner and re-renders
If the work completes within the handler, call both in the same handler:
@key.on_event("toggle")
async def handle():
await key.start_busy()
new_state = await smart_home_api.toggle_light()
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy()
Sometimes you don't need a spinner at all — the task resolves instantly.
Since the application controls the busy state, you simply don't call
start_busy() for fast operations.
Validation rules:
- For rotation and pulse types, the node must exist in the SVG
- For custom type, either assets/spinner.gif or assets/spinner/frame_*.png files must exist
Complete Example¶
name: SmartLight
type: Key
version: 1
layout: layout.svg
spinner:
type: rotation
node: loading_ring
frames: 8
interval_ms: 100
bindings:
label:
type: text
node: label
default: "Light"
status_color:
type: color
node: indicator
attribute: fill
default: "#333333"
events:
- name: toggle
source: key_press_release
max_duration_ms: 300
from deckui.dui import DuiKey, load_package
spec = load_package("./SmartLight.dui")
key = DuiKey(spec)
@key.on_event("toggle")
async def handle():
await key.start_busy()
# Spinner starts — the key still shows the current
# label and status_color while the spinner animates in the corner.
await smart_home_api.toggle_light()
# Don't call finish_busy() here — wait for the state update callback.
async def on_light_state_changed(new_state):
"""Called by your integration when the light confirms its new state."""
key.set("status_color", "#00ff00" if new_state else "#333333")
await key.finish_busy() # stops spinner, re-renders the key
Complete Examples¶
Touchscreen Card (Audio Player)¶
name: AudioPlayer
type: TouchStripCard
version: 1
layout: layout.svg
bindings:
title:
type: text
node: title
default: "No Track"
max_width: 90
overflow: ellipsis
artist:
type: text
node: artist
default: ""
cover:
type: image
node: cover
fit: cover
placeholder_node: cover_placeholder
playing:
type: toggle
node_on: icon_pause
node_off: icon_play
default: false
progress:
type: range
node: progress_bar
default: 0.0
direction: horizontal
events:
- name: toggle_play
source: encoder_press_release
max_duration_ms: 250
- name: next
source: encoder_turn
direction: right
- name: previous
source: encoder_turn
direction: left
- name: seek_forward
source: encoder_press_turn
direction: right
accumulate: true
accumulate_delay: 0.15
- name: seek_backward
source: encoder_press_turn
direction: left
accumulate: true
accumulate_delay: 0.15
regions:
card:
x: 0
y: 0
width: 197
height: 98
events: [tap, long_press]
Physical Key (Status Indicator)¶
name: StatusKey
type: Key
version: 1
layout: layout.svg
bindings:
label:
type: text
node: label
default: "Status"
indicator_color:
type: color
node: indicator
attribute: fill
default: "#333333"
icon:
type: iconify
node: icon_group
size: 32
default: "line-md:check-all"
events:
- name: activate
source: key_press_release
max_duration_ms: 300
- name: hold
source: key_hold
hold_ms: 500
Using DUI Packages in Python¶
Loading a Package¶
from deckui.dui import load_package, load_all_packages
# Load a single package
spec = load_package("path/to/AudioPlayer.dui")
# Load all packages from a directory
packages = load_all_packages("path/to/packages/")
Touchscreen Card¶
from deckui.dui import DuiCard
card = DuiCard(spec, screen)
# Set binding values
card.set("title", "Bohemian Rhapsody")
card.set("artist", "Queen")
card.set("playing", True)
card.set("progress", 0.42)
# Set multiple at once
card.set_many(title="New Song", artist="Artist", progress=0.0)
# Range helpers
card.set_range("progress", 0.75)
card.adjust_range("progress", 0.01) # increment by 0.01
# Register event handlers
@card.on("toggle_play")
async def on_toggle():
...
@card.on("next")
async def on_next():
...
# Render to image
image = card.render()
Physical Key¶
from deckui.dui import DuiKey
key = DuiKey(spec, slot)
key.set("label", "Deploy")
key.set("indicator_color", "#00ff00")
@key.on_event("activate")
async def on_activate():
...
# Render to encoded image bytes
image_bytes = key.render_image()
Validation¶
Packages are validated on load. Common errors:
- Missing required manifest fields (
name,type,version,layout) - Unknown binding type
- Binding references a
nodeID that doesn't exist in the SVG togglebinding missingnode_onornode_offsliderbinding missingmin_posormax_pos- Duplicate event names
hold_msused on a non-hold source- Invalid region coordinates (negative values)
- Invalid
categoryvalue (must be from the controlled vocabulary) - Invalid
tagsentries (must be non-empty strings)
All validation errors raise PackageError with a descriptive message.
Package Verification Tool¶
Use the verify tool to check packages before publishing to a repository.
Basic verification¶
Checks that the package loads correctly and reports warnings for missing metadata, oversized assets, uppercase tags, and unknown manifest keys.
Strict mode (for repository submission)¶
Promotes all warnings to errors. Use this as a gate for repository submissions — packages must have description and author to pass.
Build a repository index¶
Verifies all .dui packages in a directory and emits a repository.json to stdout containing metadata from every valid package. This JSON index is all a repository needs — no external database required.
Verification checks¶
| Check | Severity | Description |
|---|---|---|
| Package loads | error | All load_package validation passes |
description present |
warning | Non-empty string |
author present |
warning | Non-empty string |
category valid |
error | Must be from the controlled vocabulary (if set) |
| Tags are lowercase | warning | Each tag should be lowercase |
icon exists |
warning | If declared, file must exist in assets/ |
| Unknown manifest keys | warning | Catches typos like desciption |
| Package size | warning | Total assets + SVG under 2 MB |
license is SPDX |
warning | No spaces in the license identifier |