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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEoLyNbtbjbDq7aTWAymUG
This commit is contained in:
lwoodard
2026-08-07 13:23:43 -06:00
parent a542ad60cd
commit d1d5d88508
4 changed files with 235 additions and 6 deletions

View File

@@ -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: {}
`
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)