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
|
||||
}
|
||||
|
||||
// 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: {}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user