Text overlay support

This commit is contained in:
2026-04-18 11:38:16 -06:00
parent 44dc22d8ee
commit 962ee747fd
7 changed files with 495 additions and 33 deletions

View File

@@ -21,10 +21,11 @@ No Elgato software required — communicates directly with the device over USB H
- Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
- No Stream Deck app, no Node.js, no Electron
- **Text overlays** — add a `text` field to any key for auto-sized, word-wrapped labels; white by default, or set `text_color` to black, red, blue, or any `#RRGGBB` hex. Works with icons (overlaid), without icons (white text on black), and on toggle keys. See [Text overlays](#text-overlays).
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. Built-in examples: Slack (status, presence, snooze) and OBS Studio (recording, streaming, scene switching, media control). See [Modules](#modules).
- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder).
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
**Planned:** multi-page layouts, AUR package — see [Roadmap](#roadmap)
---
@@ -69,6 +70,8 @@ streamdeck-go/
│ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved)
├── text overlay? ──▶ overlayText() (auto-size font, word wrap, outline)
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once)
@@ -96,7 +99,7 @@ interleave partial image data across keys.
|---|---|
| [`github.com/sstallion/go-hid`](https://github.com/sstallion/go-hid) | Bindings for `libhidapi` — USB HID read/write |
| [`github.com/fsnotify/fsnotify`](https://github.com/fsnotify/fsnotify) | Config file watching for live reload |
| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling |
| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling, font rendering (text overlays use embedded Go Bold font) |
| [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) | YAML config parsing |
| [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation |
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
@@ -403,6 +406,68 @@ keys:
---
### Text overlays
Add a `text` field to any key to render a small label at the bottom of the key. The text is rendered at a fixed label size (~15pt at 96×96 key resolution), bottom-aligned so the icon stays visible above it. A contrasting outline is drawn automatically for readability on any background.
Text stays on a single line unless you add explicit line breaks with `\n` or YAML's `|` literal block syntax. If the text is too wide for the key, the font shrinks to fit. Multi-line text (up to 4-5 lines) is supported via explicit newlines.
The source image is scaled to key resolution (96×96 for XL, 72×72 for MK.2) before text is rendered, so label size is consistent regardless of source icon dimensions.
```yaml
keys:
# Label on an icon — single line, bottom-aligned
9:
icon: lock.png
text: "Lock Machine"
command: hyprctl dispatch exec omarchy-lock-screen
# Multi-line (explicit newlines only — no automatic word wrap)
16:
icon: server.png
text: |
Restart
Web Server
command: systemctl restart nginx
# Text-only key (no icon — white text on black background)
17:
text: "Build\nDeploy"
command: make deploy
# Custom text color
18:
icon: alert.png
text: "DANGER"
text_color: red
command: nuke-from-orbit
# Hex color
19:
icon: status.png
text: "Online"
text_color: "#00FF00"
command: ""
```
**`text_color`** is optional and defaults to white. Supported values:
| Value | Color |
|---|---|
| `white` (default) | White |
| `black` | Black |
| `red` | Red |
| `blue` | Blue |
| `#RRGGBB` | Any hex color |
Text overlays work on:
- **Static keys** — label at the bottom of the icon
- **Text-only keys** — no icon needed, renders on a black background
- **Toggle keys** — text appears on both `icon_true` and `icon_false`
- GIF keys do not currently support text overlays
---
### Launching applications
On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently.
@@ -1004,10 +1069,10 @@ To add a model, edit the `models` map in [internal/device/streamdeck.go](interna
## Roadmap
### Text / label overlay on icons
### Dynamic text from command output
Render dynamic text directly onto a key image at runtime — useful for showing
live state like volume level, a clock, a counter, or the current git branch.
Extend the text overlay feature to render live output from a shell command —
useful for showing volume level, a clock, a counter, or the current git branch.
Example config (proposed):
@@ -1015,8 +1080,7 @@ Example config (proposed):
keys:
4:
icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
text: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
refresh: 5s
```

View File

@@ -13,7 +13,7 @@
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
// 4. Walks the user through a series of huh forms:
// - Pick a key slot (0-31)
// - Choose action type: module function or raw shell command
// - Choose action type: module function, shell command, or toggle key (with poll)
// - If module: pick module → function → customize params (with defaults)
// - If command: type a shell command
// - Pick an icon from icons_dir (or type a custom filename)
@@ -22,10 +22,10 @@
//
// ## Expanding this tool
//
// To add new TUI steps (e.g. toggle key setup with poll config, or a
// "delete key" flow), add a new function following the pattern of
// configureModuleKey/configureCommandKey: build huh forms, collect values
// into a keyEntry, return it for the confirm/append step.
// To add new TUI steps (e.g. a "delete key" flow), add a new function
// following the pattern of configureModuleKey/configureCommandKey/
// configureToggleKey: build huh forms, collect values into a keyEntry,
// return it for the confirm/append step.
//
// To support new key types in the YAML output, update keyEntry and
// renderKeySnippet() in yaml.go.
@@ -185,29 +185,51 @@ func runOnce(cfgPath string) error {
var entry keyEntry
entry.Index = keyIdx
if actionType == "module" {
// Module path: module → function → params (with defaults pre-filled).
switch actionType {
case "module":
entry, err = configureModuleKey(keyIdx, reg)
} else {
// Command path: just a shell command string.
case "toggle":
entry, err = configureToggleKey(keyIdx, reg)
default:
entry, err = configureCommandKey(keyIdx)
}
if err != nil {
return err
}
// Step 4: Pick an icon file.
icon, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
// Step 4: Pick icon(s).
if entry.IsToggle {
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
iconTrue, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconTrue = iconTrue
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
iconFalse, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconFalse = iconFalse
} else {
icon, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.Icon = icon
}
entry.Icon = icon
// Step 5: Show summary and confirm before writing.
fmt.Println()
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
fmt.Printf(" Key: %d\n", entry.Index)
fmt.Printf(" Icon: %s\n", entry.Icon)
if entry.IsToggle {
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
} else {
fmt.Printf(" Icon: %s\n", entry.Icon)
}
if entry.Module != "" {
fmt.Printf(" Module: %s\n", entry.Module)
fmt.Printf(" Function: %s\n", entry.Function)
@@ -220,6 +242,27 @@ func runOnce(cfgPath string) error {
} else {
fmt.Printf(" Command: %s\n", entry.Command)
}
if entry.IsToggle {
fmt.Println(" Poll:")
if entry.PollModule != "" {
fmt.Printf(" Module: %s\n", entry.PollModule)
fmt.Printf(" Function: %s\n", entry.PollFunction)
if len(entry.PollParams) > 0 {
fmt.Println(" Params:")
for k, v := range entry.PollParams {
fmt.Printf(" %s: %s\n", k, v)
}
}
} else {
fmt.Printf(" Command: %s\n", entry.PollCommand)
}
if entry.PollMatch != "" {
fmt.Printf(" Match: %s\n", entry.PollMatch)
}
if entry.PollInterval != "" {
fmt.Printf(" Interval: %s\n", entry.PollInterval)
}
}
fmt.Println()
var confirm bool
@@ -281,10 +324,8 @@ func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
return keyIdx, nil
}
// pickActionType asks whether to configure a module function or a raw shell command.
//
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
// through icon_true/icon_false and poll config setup.
// pickActionType asks whether to configure a module function, a raw shell command,
// or a toggle key with poll-based state checking.
func pickActionType() (string, error) {
var actionType string
form := huh.NewForm(
@@ -292,8 +333,9 @@ func pickActionType() (string, error) {
huh.NewSelect[string]().
Title("What should this key do?").
Options(
huh.NewOption("Module function (Slack, etc.)", "module"),
huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
huh.NewOption("Shell command", "command"),
huh.NewOption("Toggle key (with poll)", "toggle"),
).
Value(&actionType),
),
@@ -434,6 +476,123 @@ func configureCommandKey(keyIdx int) (keyEntry, error) {
return entry, nil
}
// configureToggleKey walks the user through setting up a toggle key:
// press action (module or command) + poll configuration (module or command,
// match string, interval). Icons are handled separately in runOnce().
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
entry := keyEntry{Index: keyIdx, IsToggle: true}
// Ask what happens when the key is pressed.
var pressType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What should pressing this key do?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pressType),
),
)
if err := form.Run(); err != nil {
return entry, err
}
if pressType == "module" {
modEntry, err := configureModuleKey(keyIdx, reg)
if err != nil {
return entry, err
}
entry.Module = modEntry.Module
entry.Function = modEntry.Function
entry.Params = modEntry.Params
} else {
cmdEntry, err := configureCommandKey(keyIdx)
if err != nil {
return entry, err
}
entry.Command = cmdEntry.Command
}
// Configure the poll block.
if err := configurePoll(&entry, reg); err != nil {
return entry, err
}
return entry, nil
}
// configurePoll walks through poll configuration for a toggle key: how to check
// state (module function or shell command), optional match string, and interval.
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
var pollType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("How should the key check its state?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pollType),
),
)
if err := form.Run(); err != nil {
return err
}
if pollType == "module" {
modEntry, err := configureModuleKey(entry.Index, reg)
if err != nil {
return err
}
entry.PollModule = modEntry.Module
entry.PollFunction = modEntry.Function
entry.PollParams = modEntry.Params
} else {
var cmd string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Poll command").
Description("Shell command to check key state").
Value(&cmd).
Placeholder("e.g. pgrep -x myapp"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollCommand = cmd
}
// Match string and interval.
var match, interval string
interval = "2s"
form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Match string").
Description("Substring in output that means ON (leave empty to use exit code)").
Value(&match).
Placeholder("optional"),
huh.NewInput().
Title("Poll interval").
Description("How often to check state").
Value(&interval).
Placeholder("2s"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollMatch = match
entry.PollInterval = interval
return nil
}
// pickIcon lets the user select an icon file.
//
// If icons_dir has image files, shows a select list of them plus a "type custom"

View File

@@ -31,8 +31,8 @@
//
// ## Expanding
//
// To support toggle keys (icon_true/icon_false + poll block), add those fields
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines.
// Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle
// and the Poll* fields on keyEntry, and the toggle branch in renderKeySnippet().
//
// To support deleting or editing existing keys, you'd need a different strategy
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
@@ -51,9 +51,11 @@ import (
// keyEntry holds the data collected from the TUI forms, ready to be rendered
// as a YAML snippet and appended to config.yaml.
//
// Two mutually exclusive modes:
// Three mutually exclusive modes:
// - Module key: Module + Function + Params are set, Command is empty
// - Command key: Command is set, Module/Function/Params are empty
// - Toggle key: IsToggle is true, IconTrue/IconFalse replace Icon,
// and Poll* fields define state checking. Press action uses Module or Command.
type keyEntry struct {
Index int // key slot (0-31)
Icon string // icon filename relative to icons_dir
@@ -61,6 +63,17 @@ type keyEntry struct {
Function string // function name within the module (e.g. "set_status")
Params map[string]string // param overrides (merged with module defaults at runtime)
Command string // raw shell command (non-module keys only)
// Toggle key fields
IsToggle bool
IconTrue string // icon when poll state is true/on
IconFalse string // icon when poll state is false/off
PollCommand string // shell command to check state
PollModule string // module for poll (alternative to PollCommand)
PollFunction string // function for poll
PollParams map[string]string // poll param overrides
PollMatch string // substring match in output -> true state
PollInterval string // poll frequency (e.g. "2s")
}
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
@@ -123,7 +136,13 @@ func renderKeySnippet(e keyEntry) string {
var b strings.Builder
fmt.Fprintf(&b, " %d:\n", e.Index)
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
if e.IsToggle {
fmt.Fprintf(&b, " icon_true: %s\n", e.IconTrue)
fmt.Fprintf(&b, " icon_false: %s\n", e.IconFalse)
} else {
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
}
if e.Module != "" {
fmt.Fprintf(&b, " module: %s\n", e.Module)
@@ -144,5 +163,32 @@ func renderKeySnippet(e keyEntry) string {
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
}
if e.IsToggle {
b.WriteString(" poll:\n")
if e.PollModule != "" {
fmt.Fprintf(&b, " module: %s\n", e.PollModule)
fmt.Fprintf(&b, " function: %s\n", e.PollFunction)
if len(e.PollParams) > 0 {
b.WriteString(" params:\n")
pkeys := make([]string, 0, len(e.PollParams))
for k := range e.PollParams {
pkeys = append(pkeys, k)
}
sort.Strings(pkeys)
for _, k := range pkeys {
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.PollParams[k])
}
}
} else {
fmt.Fprintf(&b, " command: \"%s\"\n", e.PollCommand)
}
if e.PollMatch != "" {
fmt.Fprintf(&b, " match: \"%s\"\n", e.PollMatch)
}
if e.PollInterval != "" {
fmt.Fprintf(&b, " interval: \"%s\"\n", e.PollInterval)
}
}
return b.String()
}

View File

@@ -3,10 +3,12 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
_ "image/jpeg"
@@ -27,6 +29,11 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"gopkg.in/yaml.v3"
)
@@ -223,6 +230,14 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
// Regular key: load icon once.
if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
continue
}
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
@@ -249,6 +264,9 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
@@ -371,6 +389,10 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
return
}
}
if keyCfg.Text != "" {
imgTrue = overlayText(imgTrue, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
imgFalse = overlayText(imgFalse, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
lastState := -1 // unknown, forces initial icon set
var animCancel context.CancelFunc
@@ -555,6 +577,169 @@ func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time
return frames, delays, nil
}
// --- Text overlay ---
var (
parsedFont *opentype.Font
fontOnce sync.Once
)
func getFont() *opentype.Font {
fontOnce.Do(func() {
f, err := opentype.Parse(gobold.TTF)
if err != nil {
log.Fatalf("parse embedded font: %v", err)
}
parsedFont = f
})
return parsedFont
}
// parseTextColor converts a color name or hex string to a color.Color.
// Supported names: white (default), black, red, blue. Hex: "#RRGGBB".
func parseTextColor(s string) color.Color {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "white":
return color.White
case "black":
return color.Black
case "red":
return color.RGBA{R: 255, A: 255}
case "blue":
return color.RGBA{B: 255, A: 255}
default:
s = strings.TrimPrefix(s, "#")
if len(s) == 6 {
b, err := hex.DecodeString(s)
if err == nil && len(b) == 3 {
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
}
}
log.Printf("unknown text_color %q, using white", s)
return color.White
}
}
// contrastColor returns black or white depending on which contrasts better
// with the given color. Used for the text outline/shadow.
func contrastColor(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
// Perceived luminance (values are 16-bit, so divide by 257 to get 8-bit).
lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/257)
if lum > 128 {
return color.Black
}
return color.White
}
// splitLines splits text on explicit newlines only. No automatic word wrapping.
func splitLines(text string) []string {
return strings.Split(text, "\n")
}
// overlayText renders text onto img at keySize resolution with auto-sizing.
// The image is first scaled to keySize×keySize so font sizes are consistent
// regardless of source image dimensions.
// textColor is parsed from the config; shadow/outline uses the contrasting color.
func overlayText(img image.Image, text, textColorStr string, keySize int) image.Image {
if text == "" {
return img
}
// Scale to key resolution so font sizing is consistent.
dst := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
w, h := keySize, keySize
f := getFont()
marginX := int(float64(w) * 0.06)
marginY := int(float64(h) * 0.06)
availW := w - 2*marginX
availH := h - 2*marginY
// Split on explicit newlines only — no automatic word wrapping.
lines := splitLines(text)
// Find the largest font size where all lines fit.
// Cap at h*0.16 (~15pt on 96px key) so text stays label-sized.
var bestFace font.Face
maxSize := float64(h) * 0.16
const minSize = 6.0
for size := maxSize; size >= minSize; size -= 0.5 {
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
continue
}
lineH := face.Metrics().Height.Ceil()
fits := lineH*len(lines) <= availH
if fits {
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > availW {
fits = false
break
}
}
}
if fits {
bestFace = face
break
}
}
if bestFace == nil {
bestFace, _ = opentype.NewFace(f, &opentype.FaceOptions{
Size: minSize, DPI: 72, Hinting: font.HintingFull,
})
}
metrics := bestFace.Metrics()
lineH := metrics.Height.Ceil()
totalH := lineH * len(lines)
// Bottom-align so the icon stays visible above.
startY := h - marginY - totalH + metrics.Ascent.Ceil()
txtColor := parseTextColor(textColorStr)
shadow := contrastColor(txtColor)
// Outline offsets for readability on any background.
offsets := [8]image.Point{
{-1, -1}, {0, -1}, {1, -1},
{-1, 0}, {1, 0},
{-1, 1}, {0, 1}, {1, 1},
}
for i, line := range lines {
adv := font.MeasureString(bestFace, line)
x := (w - adv.Ceil()) / 2
y := startY + i*lineH
// Draw outline.
for _, off := range offsets {
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(shadow),
Face: bestFace,
Dot: fixed.P(x+off.X, y+off.Y),
}
d.DrawString(line)
}
// Draw text.
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(txtColor),
Face: bestFace,
Dot: fixed.P(x, y),
}
d.DrawString(line)
}
return dst
}
func loadImage(path string) (image.Image, error) {
if strings.ToLower(filepath.Ext(path)) == ".svg" {
return loadSVG(path)

View File

@@ -22,8 +22,10 @@ type PollConfig struct {
// KeyConfig defines what a single Stream Deck key does.
type KeyConfig struct {
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
Command string `yaml:"command"` // shell command to run on press
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
Text string `yaml:"text"` // text overlay on the key (auto-sized, supports newlines)
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
Command string `yaml:"command"` // shell command to run on press
// Toggle/status keys: show different icons based on polled state.
IconTrue string `yaml:"icon_true"` // icon when poll match is true

View File

@@ -87,6 +87,12 @@ func (sd *StreamDeck) Close() error {
// KeyCount returns the number of keys on this device.
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount }
// ImageWidth returns the pixel width of key images for this device.
func (sd *StreamDeck) ImageWidth() int { return sd.model.ImageWidth }
// ImageHeight returns the pixel height of key images for this device.
func (sd *StreamDeck) ImageHeight() int { return sd.model.ImageHeight }
// Reset clears all key images and returns the device to its default state.
func (sd *StreamDeck) Reset() error {
report := make([]byte, 32)

Binary file not shown.