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 - Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
- No Stream Deck app, no Node.js, no Electron - 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). - **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). - **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 │ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved) │ (templates expanded, env vars resolved)
├── text overlay? ──▶ overlayText() (auto-size font, word wrap, outline)
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID) ├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once) ├── 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/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 | | [`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 | | [`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 | | [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation |
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding | | 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 ### 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. 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 ## Roadmap
### Text / label overlay on icons ### Dynamic text from command output
Render dynamic text directly onto a key image at runtime — useful for showing Extend the text overlay feature to render live output from a shell command —
live state like volume level, a clock, a counter, or the current git branch. useful for showing volume level, a clock, a counter, or the current git branch.
Example config (proposed): Example config (proposed):
@@ -1015,8 +1080,7 @@ Example config (proposed):
keys: keys:
4: 4:
icon: volume.png icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')" text: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
refresh: 5s refresh: 5s
``` ```

View File

@@ -13,7 +13,7 @@
// 3. Renders an 8x4 key grid showing which slots are free vs occupied. // 3. Renders an 8x4 key grid showing which slots are free vs occupied.
// 4. Walks the user through a series of huh forms: // 4. Walks the user through a series of huh forms:
// - Pick a key slot (0-31) // - 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 module: pick module → function → customize params (with defaults)
// - If command: type a shell command // - If command: type a shell command
// - Pick an icon from icons_dir (or type a custom filename) // - Pick an icon from icons_dir (or type a custom filename)
@@ -22,10 +22,10 @@
// //
// ## Expanding this tool // ## Expanding this tool
// //
// To add new TUI steps (e.g. toggle key setup with poll config, or a // To add new TUI steps (e.g. a "delete key" flow), add a new function
// "delete key" flow), add a new function following the pattern of // following the pattern of configureModuleKey/configureCommandKey/
// configureModuleKey/configureCommandKey: build huh forms, collect values // configureToggleKey: build huh forms, collect values into a keyEntry,
// into a keyEntry, return it for the confirm/append step. // return it for the confirm/append step.
// //
// To support new key types in the YAML output, update keyEntry and // To support new key types in the YAML output, update keyEntry and
// renderKeySnippet() in yaml.go. // renderKeySnippet() in yaml.go.
@@ -185,29 +185,51 @@ func runOnce(cfgPath string) error {
var entry keyEntry var entry keyEntry
entry.Index = keyIdx entry.Index = keyIdx
if actionType == "module" { switch actionType {
// Module path: module → function → params (with defaults pre-filled). case "module":
entry, err = configureModuleKey(keyIdx, reg) entry, err = configureModuleKey(keyIdx, reg)
} else { case "toggle":
// Command path: just a shell command string. entry, err = configureToggleKey(keyIdx, reg)
default:
entry, err = configureCommandKey(keyIdx) entry, err = configureCommandKey(keyIdx)
} }
if err != nil { if err != nil {
return err return err
} }
// Step 4: Pick an icon file. // Step 4: Pick icon(s).
icon, err := pickIcon(cfg.IconsDir) if entry.IsToggle {
if err != nil { fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
return err 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. // Step 5: Show summary and confirm before writing.
fmt.Println() fmt.Println()
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:")) fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
fmt.Printf(" Key: %d\n", entry.Index) 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 != "" { if entry.Module != "" {
fmt.Printf(" Module: %s\n", entry.Module) fmt.Printf(" Module: %s\n", entry.Module)
fmt.Printf(" Function: %s\n", entry.Function) fmt.Printf(" Function: %s\n", entry.Function)
@@ -220,6 +242,27 @@ func runOnce(cfgPath string) error {
} else { } else {
fmt.Printf(" Command: %s\n", entry.Command) 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() fmt.Println()
var confirm bool var confirm bool
@@ -281,10 +324,8 @@ func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
return keyIdx, nil return keyIdx, nil
} }
// pickActionType asks whether to configure a module function or a raw shell command. // pickActionType asks whether to configure a module function, a raw shell command,
// // or a toggle key with poll-based state checking.
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
// through icon_true/icon_false and poll config setup.
func pickActionType() (string, error) { func pickActionType() (string, error) {
var actionType string var actionType string
form := huh.NewForm( form := huh.NewForm(
@@ -292,8 +333,9 @@ func pickActionType() (string, error) {
huh.NewSelect[string](). huh.NewSelect[string]().
Title("What should this key do?"). Title("What should this key do?").
Options( Options(
huh.NewOption("Module function (Slack, etc.)", "module"), huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
huh.NewOption("Shell command", "command"), huh.NewOption("Shell command", "command"),
huh.NewOption("Toggle key (with poll)", "toggle"),
). ).
Value(&actionType), Value(&actionType),
), ),
@@ -434,6 +476,123 @@ func configureCommandKey(keyIdx int) (keyEntry, error) {
return entry, nil 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. // pickIcon lets the user select an icon file.
// //
// If icons_dir has image files, shows a select list of them plus a "type custom" // If icons_dir has image files, shows a select list of them plus a "type custom"

View File

@@ -31,8 +31,8 @@
// //
// ## Expanding // ## Expanding
// //
// To support toggle keys (icon_true/icon_false + poll block), add those fields // Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines. // 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 // 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 // (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 // keyEntry holds the data collected from the TUI forms, ready to be rendered
// as a YAML snippet and appended to config.yaml. // 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 // - Module key: Module + Function + Params are set, Command is empty
// - Command key: Command is set, Module/Function/Params are 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 { type keyEntry struct {
Index int // key slot (0-31) Index int // key slot (0-31)
Icon string // icon filename relative to icons_dir 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") Function string // function name within the module (e.g. "set_status")
Params map[string]string // param overrides (merged with module defaults at runtime) Params map[string]string // param overrides (merged with module defaults at runtime)
Command string // raw shell command (non-module keys only) 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, // 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 var b strings.Builder
fmt.Fprintf(&b, " %d:\n", e.Index) 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 != "" { if e.Module != "" {
fmt.Fprintf(&b, " module: %s\n", 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) 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() return b.String()
} }

View File

@@ -3,10 +3,12 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"image" "image"
"image/color"
"image/draw" "image/draw"
"image/gif" "image/gif"
_ "image/jpeg" _ "image/jpeg"
@@ -27,6 +29,11 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg" "github.com/srwiley/oksvg"
"github.com/srwiley/rasterx" "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" "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. // Regular key: load icon once.
if keyCfg.Icon == "" { 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 continue
} }
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon) 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) log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue continue
} }
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil { if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err) 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 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 lastState := -1 // unknown, forces initial icon set
var animCancel context.CancelFunc var animCancel context.CancelFunc
@@ -555,6 +577,169 @@ func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time
return frames, delays, nil 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) { func loadImage(path string) (image.Image, error) {
if strings.ToLower(filepath.Ext(path)) == ".svg" { if strings.ToLower(filepath.Ext(path)) == ".svg" {
return loadSVG(path) return loadSVG(path)

View File

@@ -22,8 +22,10 @@ type PollConfig struct {
// KeyConfig defines what a single Stream Deck key does. // KeyConfig defines what a single Stream Deck key does.
type KeyConfig struct { type KeyConfig struct {
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys) Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
Command string `yaml:"command"` // shell command to run on press 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. // Toggle/status keys: show different icons based on polled state.
IconTrue string `yaml:"icon_true"` // icon when poll match is true 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. // KeyCount returns the number of keys on this device.
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount } 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. // Reset clears all key images and returns the device to its default state.
func (sd *StreamDeck) Reset() error { func (sd *StreamDeck) Reset() error {
report := make([]byte, 32) report := make([]byte, 32)

Binary file not shown.