diff --git a/README.md b/README.md index bc8f1e6..1b37bec 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/cmd/streamdeck-init/main.go b/cmd/streamdeck-init/main.go index a1adc26..0a89558 100644 --- a/cmd/streamdeck-init/main.go +++ b/cmd/streamdeck-init/main.go @@ -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" diff --git a/cmd/streamdeck-init/yaml.go b/cmd/streamdeck-init/yaml.go index 7f756a4..78c02c3 100644 --- a/cmd/streamdeck-init/yaml.go +++ b/cmd/streamdeck-init/yaml.go @@ -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() } diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index 306b5d0..d9a5ea1 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 91c7048..181ef48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/device/streamdeck.go b/internal/device/streamdeck.go index 7931aea..0c401c8 100644 --- a/internal/device/streamdeck.go +++ b/internal/device/streamdeck.go @@ -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) diff --git a/streamdeck-init b/streamdeck-init index 5d2d09f..947e1af 100755 Binary files a/streamdeck-init and b/streamdeck-init differ