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:
@@ -207,6 +207,43 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
cfg.Keys[keyIdx] = keyCfg
|
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.
|
// Toggle/status key: managed by a polling goroutine.
|
||||||
if keyCfg.Poll != nil {
|
if keyCfg.Poll != nil {
|
||||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
||||||
@@ -488,6 +525,119 @@ func queryPollState(poll *config.PollConfig) int {
|
|||||||
return 0
|
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.
|
// mustConnect blocks until the device opens successfully.
|
||||||
//
|
//
|
||||||
// Strategy: try quickly at first (device may just be enumerating), then settle
|
// 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.
|
// Outline offsets for readability on any background.
|
||||||
offsets := [8]image.Point{
|
offsets := [8]image.Point{
|
||||||
{-1, -1}, {0, -1}, {1, -1},
|
{-1, -1},
|
||||||
{-1, 0}, {1, 0},
|
{0, -1},
|
||||||
{-1, 1}, {0, 1}, {1, 1},
|
{1, -1},
|
||||||
|
{-1, 0},
|
||||||
|
{1, 0},
|
||||||
|
{-1, 1},
|
||||||
|
{0, 1},
|
||||||
|
{1, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
@@ -910,11 +1065,11 @@ func defaultConfigPath() string {
|
|||||||
func ensureConfigDir(cfgPath string) error {
|
func ensureConfigDir(cfgPath string) error {
|
||||||
dir := filepath.Dir(cfgPath)
|
dir := filepath.Dir(cfgPath)
|
||||||
iconsDir := filepath.Join(dir, "icons")
|
iconsDir := filepath.Join(dir, "icons")
|
||||||
if err := os.MkdirAll(iconsDir, 0755); err != nil {
|
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -946,4 +1101,3 @@ device:
|
|||||||
keys: {}
|
keys: {}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,48 @@ keys:
|
|||||||
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
||||||
# interval: 2s
|
# interval: 2s
|
||||||
# match: "yes"
|
# 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
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ type KeyConfig struct {
|
|||||||
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
|
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
|
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.
|
// 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
|
||||||
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ modules:
|
|||||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
-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
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
|
|||||||
Reference in New Issue
Block a user