From d1d5d88508ff85b94a9bd191669af9235837627c Mon Sep 17 00:00:00 2001 From: lwoodard <92559124+WoodardDigital@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:23:43 -0600 Subject: [PATCH] Add text_command + refresh live-text keys Introduce a per-key `text_command` + `refresh` feature: the service periodically runs a shell command and renders its stdout as the key's text overlay, compositing over the base icon (or a black background for text-only keys) each tick. `text` acts as the static fallback shown before the first run or when the command errors/outputs nothing. A refresh goroutine owns the key's image and also handles an optional poll block (choosing icon_true/icon_false per tick). Adds `$(...)` sugar in `text` as shorthand for `text_command`. - internal/config: add TextCommand/Refresh fields to KeyConfig - cmd/streamdeck: refreshTextKey goroutine, runTextCommand, unwrapCmdSubst - config.example.yaml: document the feature with a `flow` pomodoro live-countdown example (status --short, toggle/skip/stop/gui) - modules.example.yaml: example config updates Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TEoLyNbtbjbDq7aTWAymUG --- cmd/streamdeck/main.go | 166 ++++++++++++++++++++++++++++++++++++-- config.example.yaml | 45 +++++++++++ internal/config/config.go | 6 ++ modules.example.yaml | 24 ++++++ 4 files changed, 235 insertions(+), 6 deletions(-) diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index 7e0c9d2..294d529 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -207,6 +207,43 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo cfg.Keys[keyIdx] = keyCfg } + // Optional $(...) sugar → text_command. + if keyCfg.TextCommand == "" { + if inner, ok := unwrapCmdSubst(keyCfg.Text); ok { + keyCfg.TextCommand = inner + keyCfg.Text = "" + cfg.Keys[keyIdx] = keyCfg + } + } + + // Dynamic text key: a refresh goroutine owns this key's image. It also + // handles an optional poll block (choosing icon_true/icon_false per tick), + // so it must take precedence over the plain toggle path below. + if keyCfg.TextCommand != "" { + baseIcon := keyCfg.Icon + if keyCfg.Poll != nil { + baseIcon = keyCfg.IconTrue + } + if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" || + strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" { + log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx) + continue + } + trigger := make(chan struct{}, 1) + triggers[keyIdx] = trigger + wg.Add(1) + go func(idx int, kCfg config.KeyConfig, trig chan struct{}) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + log.Printf("panic in refreshTextKey %d: %v", idx, r) + } + }() + refreshTextKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig) + }(keyIdx, keyCfg, trigger) + continue + } + // Toggle/status key: managed by a polling goroutine. if keyCfg.Poll != nil { if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" { @@ -488,6 +525,119 @@ func queryPollState(poll *config.PollConfig) int { return 0 } +// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as +// the key's text overlay, compositing over the key's base icon each tick (never +// accumulating — overlayText always renders onto a fresh image from the base). +// If the key also has a Poll block, the base icon is chosen from the polled +// state (icon_true/icon_false) so a single goroutine owns the key's image. +func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) { + // Interval: default 1s, floor 250ms. + interval := time.Second + if keyCfg.Refresh != "" { + if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 { + interval = d + } else if err != nil { + log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh) + } + } + if interval < 250*time.Millisecond { + interval = 250 * time.Millisecond + } + + // Preload immutable base image(s). An empty name yields a black background. + load := func(name string) (image.Image, bool) { + if name == "" { + return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true + } + img, err := loadImage(filepath.Join(iconsDir, name)) + if err != nil { + log.Printf("key %d: load icon %q: %v", keyIdx, name, err) + return nil, false + } + return img, true + } + + pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != "" + var baseImg, imgTrue, imgFalse image.Image + if pollMode { + var ok1, ok2 bool + imgTrue, ok1 = load(keyCfg.IconTrue) + imgFalse, ok2 = load(keyCfg.IconFalse) + if !ok1 || !ok2 { + return + } + } else { + var ok bool + baseImg, ok = load(keyCfg.Icon) // "" → black background + if !ok { + return + } + } + + render := func() { + text := runTextCommand(keyCfg.TextCommand) + if text == "" { + text = keyCfg.Text // static fallback + } + base := baseImg + if pollMode { + if queryPollState(keyCfg.Poll) == 1 { + base = imgTrue + } else { + base = imgFalse + } + } + img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth()) + if err := sd.SetKeyImage(keyIdx, img); err != nil { + log.Printf("key %d: set dynamic text image: %v", keyIdx, err) + } + } + + render() // set immediately on startup + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + render() + case <-trigger: + // Wait briefly for a press-triggered command to take effect, then re-render. + select { + case <-ctx.Done(): + return + case <-time.After(300 * time.Millisecond): + } + render() + } + } +} + +// runTextCommand runs cmd via sh -c and returns its stdout with trailing +// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label. +func runTextCommand(cmd string) string { + out, err := exec.Command("sh", "-c", cmd).Output() + if err != nil { + log.Printf("text_command %q: %v", cmd, err) + } + return strings.TrimRight(string(out), " \t\r\n") +} + +// unwrapCmdSubst returns the inner command if s is exactly "$( ... )". +func unwrapCmdSubst(s string) (string, bool) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") { + inner := strings.TrimSpace(s[2 : len(s)-1]) + if inner != "" { + return inner, true + } + } + return "", false +} + // mustConnect blocks until the device opens successfully. // // Strategy: try quickly at first (device may just be enumerating), then settle @@ -707,9 +857,14 @@ func overlayText(img image.Image, text, textColorStr string, keySize int) image. // 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}, + {-1, -1}, + {0, -1}, + {1, -1}, + {-1, 0}, + {1, 0}, + {-1, 1}, + {0, 1}, + {1, 1}, } for i, line := range lines { @@ -910,11 +1065,11 @@ func defaultConfigPath() string { func ensureConfigDir(cfgPath string) error { dir := filepath.Dir(cfgPath) iconsDir := filepath.Join(dir, "icons") - if err := os.MkdirAll(iconsDir, 0755); err != nil { + if err := os.MkdirAll(iconsDir, 0o755); err != nil { return err } if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644) + return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644) } return nil } @@ -946,4 +1101,3 @@ device: keys: {} ` } - diff --git a/config.example.yaml b/config.example.yaml index 09cb7e5..e4b9497 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -61,3 +61,48 @@ keys: # command: pactl get-source-mute @DEFAULT_SOURCE@ # interval: 2s # match: "yes" + + # --- Dynamic text from command output --- + # + # text_command: a shell command whose stdout is rendered as the key's label, + # re-run every `refresh` interval (default 1s; floored to 250ms). Newlines + # in the output become label line breaks; trailing whitespace is trimmed. + # `text` (if set) is the static fallback shown before the first run or when + # the command errors / outputs nothing. Composited over `icon` each tick. + # + # NOTE: use the ABSOLUTE binary path — the service runs with a minimal PATH. + # Apple Silicon Homebrew installs may live at /opt/homebrew/bin/flow instead. + # + # Pomodoro (`flow`) live countdown — press toggles start/pause: + # 8: + # icon: pomodoro.png # base icon; text on top + # text_command: "/usr/local/bin/flow status --short" # prints e.g. WORK\n24:12 (real newline) + # refresh: 1s + # text_color: "#FFFFFF" + # command: "/usr/local/bin/flow toggle" + # + # Separate icon-swap indicator (running vs paused): + # 9: + # icon_true: running.png + # icon_false: paused.png + # command: "/usr/local/bin/flow toggle" + # poll: + # command: "/usr/local/bin/flow status" # prints: state=running phase=work remaining=24:12 + # interval: 1s + # match: "state=running" + # + # Control keys: + # 10: + # icon: skip.png + # command: "/usr/local/bin/flow skip" + # 11: + # icon: stop.png + # command: "/usr/local/bin/flow stop" + # 12: + # icon: gui.png + # command: "/usr/local/bin/flow gui" + # + # A text-only live clock (no icon → white text on black), refreshed each second: + # 13: + # text_command: "date +%H:%M:%S" + # refresh: 1s diff --git a/internal/config/config.go b/internal/config/config.go index 181ef48..af8f070 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,12 @@ type KeyConfig struct { 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 + // Dynamic text: periodically run TextCommand and render its stdout as the + // key's text overlay. Refresh is the interval (default 1s, floor 250ms). + // Text (above) is the static fallback / initial value. + TextCommand string `yaml:"text_command"` // shell command whose stdout becomes the overlay text + Refresh string `yaml:"refresh"` // how often to re-run text_command, e.g. "1s" (default: "1s") + // Toggle/status keys: show different icons based on polled state. IconTrue string `yaml:"icon_true"` // icon when poll match is true IconFalse string `yaml:"icon_false"` // icon when poll match is false diff --git a/modules.example.yaml b/modules.example.yaml index 19f1a32..47b6fcb 100644 --- a/modules.example.yaml +++ b/modules.example.yaml @@ -57,6 +57,30 @@ modules: curl -s -X POST https://slack.com/api/dnd.endSnooze \ -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" + # Pomodoro / focus timer — the `flow` CLI. + # + # Absolute path required (minimal service PATH). Apple Silicon Homebrew installs + # live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env: + # FLOW_CMD=/opt/homebrew/bin/flow + # + # Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press. + # `status` prints one line like `state=running phase=work remaining=24:12` for + # icon-swap poll blocks (match: "state=running"). + # + # The LIVE countdown label uses a key's `text_command` field directly (not a + # module), e.g. text_command: "/usr/local/bin/flow status --short" which prints + # a two-line label like WORK\n24:12 (an actual newline between the lines). + flow: + start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' } + pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' } + resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' } + toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' } + skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' } + stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' } + reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' } + gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' } + status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' } + # OBS Studio — media player, streaming, and scene/transition control via obs-cmd # # Requires: obs-cmd (https://github.com/grigio/obs-cmd)