Add folder support: keys that open sub-pages with a back key

A key with `folder: NAME` swaps the deck to the keys defined under
`folders.NAME`. Every folder shows a back key (slot 0 by default, drawn
with a built-in chevron) that pops back to the parent; folders nest.

The daemon now runs one page at a time: per-key setup moved into
setupKey(), openPage() draws a page under its own context, and
page.stop() tears it down before the next page is drawn (and on exit,
so device death no longer waits on uncancelled goroutines).

streamdeck-ctl gains `key set -folder`, reports kind "folder", lists
every folder in status, and refuses to `key press` a navigation-only
key. The Omarchy grid shows a folder glyph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
This commit is contained in:
Levi Woodard
2026-10-01 18:39:24 -06:00
parent c143db6b68
commit 4158165410
10 changed files with 568 additions and 153 deletions

View File

@@ -352,6 +352,45 @@ keys:
command: ""
```
### Folders
A key can open a **folder** — a second page of keys that replaces the deck until
you go back. Folders nest to any depth, and every folder gets a back key (slot 0
by default, drawn with a built-in arrow). Pressing the folder key's `command`, if
it has one, still runs.
```yaml
keys:
12:
icon: folder.png
text: Media
folder: media # name of an entry under folders:
folders:
media:
back: # optional — defaults to key 0, built-in arrow, "Back"
key: 0
icon: back.png # omit to use the built-in arrow
text: Back
keys: # same key types as the root page: icons, GIFs,
1: # text, toggles, modules, live text, priv: commands
icon: play.png
command: playerctl play-pause
2:
icon: more.png
folder: media-extra # folders can open other folders
media-extra:
keys:
1:
text: Next
command: playerctl next
```
Each page's animations, polls, and live-text refreshes are stopped when you leave
it and restarted when you return. A config reload always lands on the root page.
Folder keys show up in `streamdeck-ctl status` with kind `folder`, and every
folder's keys are listed under `folders`.
### Status / toggle keys
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.

View File

@@ -78,6 +78,7 @@ key set flags (only the flags you pass are written):
-icon NAME -icon-true NAME -icon-false NAME
-text STR -text-color STR -text-command STR -refresh DUR
-command STR -module NAME -function NAME
-folder NAME (pressing the key opens that folder)
-param K=V (repeatable)
-poll-command STR -poll-match STR -poll-interval DUR
-unset FIELD (repeatable — removes a field)
@@ -161,6 +162,7 @@ func keySet(cfgPath string, index int, args []string) error {
"text-command": fs.String("text-command", "", "command whose stdout becomes the overlay text"),
"refresh": fs.String("refresh", "", "how often to re-run text-command, e.g. 1s"),
"command": fs.String("command", "", "shell command to run on press"),
"folder": fs.String("folder", "", "folder (from the folders: block) the key opens"),
"module": fs.String("module", "", "module name from modules.yaml"),
"function": fs.String("function", "", "function name within the module"),
"poll-command": fs.String("poll-command", "", "command used to poll toggle state"),
@@ -171,6 +173,7 @@ func keySet(cfgPath string, index int, args []string) error {
"icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false",
"text": "text", "text-color": "text_color", "text-command": "text_command",
"refresh": "refresh", "command": "command", "module": "module", "function": "function",
"folder": "folder",
}
var params stringList
@@ -300,6 +303,10 @@ func keyPress(cfgPath string, index int) error {
return fmt.Errorf("key %d is not configured", index)
}
if key.Folder != "" && key.Command == "" && key.Module == "" {
return fmt.Errorf("key %d opens folder %q — navigation only happens on the deck", index, key.Folder)
}
command := key.Command
if key.Module != "" && key.Function != "" {
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))

View File

@@ -30,6 +30,7 @@ type Status struct {
Daemon DaemonStatus `json:"daemon"`
Device DeviceStatus `json:"device"`
Keys []KeyStatus `json:"keys"`
Folders []FolderStatus `json:"folders"`
Icons []string `json:"icons"`
Modules []ModuleStatus `json:"modules"`
Warnings []string `json:"warnings,omitempty"`
@@ -62,7 +63,7 @@ type DeviceStatus struct {
// shape comes from Device.Cols/Rows so a front-end can lay out empty slots.
type KeyStatus struct {
Index int `json:"index"`
Kind string `json:"kind"` // empty|static|text|toggle|module
Kind string `json:"kind"` // empty|static|text|toggle|module|folder
Label string `json:"label"` // short human label for a grid cell
Icon string `json:"icon,omitempty"`
IconPath string `json:"iconPath,omitempty"` // absolute, for previews
@@ -78,6 +79,17 @@ type KeyStatus struct {
Params map[string]string `json:"params,omitempty"`
Privileged bool `json:"privileged"`
Poll *PollStatus `json:"poll,omitempty"`
Folder string `json:"folder,omitempty"` // folder the key opens on press
}
// FolderStatus is one sub-page from the folders: block, with the same key
// shape as the root page so a front-end can render it with the same grid.
type FolderStatus struct {
Name string `json:"name"`
BackKey int `json:"backKey"`
BackIcon string `json:"backIcon,omitempty"`
BackText string `json:"backText,omitempty"`
Keys []KeyStatus `json:"keys"`
}
type PollStatus struct {
@@ -104,6 +116,7 @@ func cmdStatus(cfgPath string) error {
ConfigPath: cfgPath,
ModulesPath: config.ModulesPath(cfgPath),
Keys: []KeyStatus{},
Folders: []FolderStatus{},
Icons: []string{},
Modules: []ModuleStatus{},
}
@@ -131,6 +144,7 @@ func cmdStatus(cfgPath string) error {
}
status.Modules = moduleList(reg)
status.Keys = keyList(cfg, reg)
status.Folders = folderList(cfg, reg)
status.Icons = iconList(cfg.IconsDir)
emit(status)
@@ -138,15 +152,41 @@ func cmdStatus(cfgPath string) error {
}
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
indices := make([]int, 0, len(cfg.Keys))
for index := range cfg.Keys {
return keyEntries(cfg.Keys, cfg.IconsDir, reg)
}
// folderList renders every folder in name order.
func folderList(cfg *config.Config, reg *modules.Registry) []FolderStatus {
names := make([]string, 0, len(cfg.Folders))
for name := range cfg.Folders {
names = append(names, name)
}
sort.Strings(names)
out := make([]FolderStatus, 0, len(names))
for _, name := range names {
f := cfg.Folders[name]
out = append(out, FolderStatus{
Name: name,
BackKey: f.BackKey(),
BackIcon: f.Back.Icon,
BackText: f.Back.Text,
Keys: keyEntries(f.Keys, cfg.IconsDir, reg),
})
}
return out
}
func keyEntries(keys map[int]config.KeyConfig, iconsDir string, reg *modules.Registry) []KeyStatus {
indices := make([]int, 0, len(keys))
for index := range keys {
indices = append(indices, index)
}
sort.Ints(indices)
out := make([]KeyStatus, 0, len(indices))
for _, index := range indices {
key := cfg.Keys[index]
key := keys[index]
entry := KeyStatus{
Index: index,
Icon: key.Icon,
@@ -160,10 +200,11 @@ func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
Module: key.Module,
Function: key.Function,
Params: key.Params,
Folder: key.Folder,
}
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
entry.IconPath = resolveIcon(cfg.IconsDir, icon)
entry.IconPath = resolveIcon(iconsDir, icon)
}
// The rendered template can inline secrets ({{env "TOKEN"}}), so it never
@@ -194,6 +235,8 @@ func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
func keyKind(key config.KeyConfig) string {
switch {
case key.Folder != "":
return "folder"
case key.Poll != nil:
return "toggle"
case key.Module != "":
@@ -215,6 +258,9 @@ func keyLabel(key config.KeyConfig) string {
return strings.TrimSpace(line)
}
}
if key.Folder != "" {
return key.Folder
}
if key.Function != "" {
return strings.ReplaceAll(key.Function, "_", " ")
}

View File

@@ -0,0 +1,30 @@
package main
import (
"image/color"
"testing"
)
// The built-in back arrow must leave the label band (bottom third) untouched
// and actually draw something above it.
func TestBackArrowLayout(t *testing.T) {
img := backArrow(96, 96, color.White)
lit := 0
for y := 0; y < 64; y++ {
for x := 0; x < 96; x++ {
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
lit++
}
}
}
if lit < 200 {
t.Errorf("arrow region has only %d lit pixels", lit)
}
for y := 72; y < 96; y++ {
for x := 0; x < 96; x++ {
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
t.Fatalf("label band pixel (%d,%d) is lit", x, y)
}
}
}
}

View File

@@ -14,6 +14,7 @@ import (
_ "image/jpeg"
_ "image/png"
"log"
"math"
"net"
"os"
"os/exec"
@@ -158,6 +159,12 @@ func main() {
// run initialises the deck and handles button presses until ctx is cancelled
// or the device dies. Returns true if the device died, false if ctx was cancelled.
//
// The deck shows one page at a time: the root page (cfg.Keys) or a folder
// (cfg.Folders[name]). Pressing a key with a `folder:` field pushes that folder;
// pressing a folder's back key pops it. Each page owns its own goroutines
// (animations, polls, live text) and is torn down completely before the next
// page is drawn, so a folder's keys never fight the root page's for a slot.
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
// Recover from any panic inside this goroutine tree.
defer func() {
@@ -173,141 +180,15 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
if err := sd.SetBrightness(cfg.Brightness); err != nil {
log.Printf("warn: brightness: %v", err)
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
// Track animation/poll goroutines so we can wait for them on exit.
var wg sync.WaitGroup
// Navigation stack of folder names; empty means the root page is showing.
var stack []string
current := openPage(ctx, sd, cfg, reg, "")
defer func() { current.stop() }()
// triggers lets button presses immediately re-poll toggle keys.
triggers := make(map[int]chan struct{})
for keyIdx, keyCfg := range cfg.Keys {
// Resolve module-based commands to shell strings before any other logic.
// keyCfg is a copy from the map, so we must write it back after modification.
if keyCfg.Module != "" {
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
continue
}
keyCfg.Command = resolved
cfg.Keys[keyIdx] = keyCfg
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
continue
}
keyCfg.Poll.Command = resolved
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 == "" {
log.Printf("key %d: toggle key requires icon_on and icon_off", 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 pollKey %d: %v", idx, r)
}
}()
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
}(keyIdx, keyCfg, trigger)
continue
}
// Regular key: load icon once.
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
}
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
frames, delays, err := loadGIF(sd, iconPath)
if err != nil {
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
wg.Add(1)
go func(idx int, f [][]byte, d []time.Duration) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in animateKey %d: %v", idx, r)
}
}()
animateKey(ctx, sd, idx, f, d)
}(keyIdx, frames, delays)
} else {
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
showPage := func(name string) {
current.stop()
current = openPage(ctx, sd, cfg, reg, name)
}
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
@@ -318,7 +199,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
for {
select {
case <-ctx.Done():
wg.Wait()
return false
default:
}
@@ -328,8 +208,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
consecutiveErrors++
if consecutiveErrors >= 3 {
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
// Cancel animations, wait for them to stop, then signal device death.
wg.Wait()
return true
}
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
@@ -343,11 +221,26 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
}
for i, pressed := range buttons {
if pressed && !prev[i] {
keyCfg, ok := cfg.Keys[i]
if !ok || keyCfg.Command == "" {
continue
if !pressed || prev[i] {
continue
}
if i == current.backKey {
stack = stack[:len(stack)-1]
parent := ""
if len(stack) > 0 {
parent = stack[len(stack)-1]
}
log.Printf("key %d pressed → back to %s", i, pageName(parent))
showPage(parent)
break // this report belongs to the old page
}
keyCfg, ok := current.keys[i]
if !ok {
continue
}
if keyCfg.Command != "" {
log.Printf("key %d pressed → %s", i, keyCfg.Command)
go func(cmd string) {
defer func() {
@@ -357,21 +250,289 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
}()
runCommand(cmd)
}(keyCfg.Command)
}
// For toggle keys, signal the poll goroutine to re-check state
// shortly after the command runs (gives the system time to update).
if ch, ok := triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
// For toggle keys, signal the poll goroutine to re-check state
// shortly after the command runs (gives the system time to update).
if ch, ok := current.triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", i, keyCfg.Folder)
continue
}
log.Printf("key %d pressed → open folder %q", i, keyCfg.Folder)
stack = append(stack, keyCfg.Folder)
showPage(keyCfg.Folder)
break // this report belongs to the old page
}
}
// Keys still held across a page swap stay "pressed" here, so the new
// page sees no edge for them until they are physically released.
prev = buttons
}
}
// pageName names a page for log lines.
func pageName(folder string) string {
if folder == "" {
return "root page"
}
return fmt.Sprintf("folder %q", folder)
}
// page is one screenful of keys and the goroutines keeping them drawn.
type page struct {
keys map[int]config.KeyConfig // module templates already resolved
triggers map[int]chan struct{} // per-key re-poll channels
backKey int // slot that navigates up, -1 on the root page
cancel context.CancelFunc
wg sync.WaitGroup
}
// stop tears the page down: cancels its goroutines, waits for them, and blanks
// every key so the next page starts from an empty deck.
func (p *page) stop() {
p.cancel()
p.wg.Wait()
}
// openPage clears the deck and draws the root page (folder == "") or the named
// folder, starting whatever goroutines its keys need. Keys that fail to set up
// are logged and skipped — one bad icon must not blank the whole page.
func openPage(parent context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry, folder string) *page {
ctx, cancel := context.WithCancel(parent)
p := &page{
keys: map[int]config.KeyConfig{},
triggers: map[int]chan struct{}{},
backKey: -1,
cancel: cancel,
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
source := cfg.Keys
if folder != "" {
f := cfg.Folders[folder]
source = f.Keys
p.backKey = f.BackKey()
if _, shadowed := source[p.backKey]; shadowed {
log.Printf("folder %q: key %d is the back key — its own definition is ignored", folder, p.backKey)
}
drawBackKey(sd, p.backKey, f.Back, cfg.IconsDir)
}
for keyIdx, keyCfg := range source {
if keyIdx == p.backKey {
continue
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", keyIdx, keyCfg.Folder)
}
}
resolved, ok := setupKey(ctx, sd, p, keyIdx, keyCfg, cfg.IconsDir, reg)
if ok {
p.keys[keyIdx] = resolved
}
}
return p
}
// setupKey resolves module templates, draws the key's initial image, and starts
// any goroutine the key needs (animation, poll, live text). It returns the key
// config with commands resolved — the button loop dispatches from that copy.
// ok is false when the key could not be set up at all.
func setupKey(ctx context.Context, sd *device.StreamDeck, p *page, keyIdx int, keyCfg config.KeyConfig, iconsDir string, reg *modules.Registry) (resolved config.KeyConfig, ok bool) {
// Resolve module-based commands to shell strings before any other logic.
if keyCfg.Module != "" {
cmd, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
return keyCfg, false
}
keyCfg.Command = cmd
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
cmd, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
return keyCfg, false
}
poll := *keyCfg.Poll // don't mutate the shared config
poll.Command = cmd
keyCfg.Poll = &poll
}
// Optional $(...) sugar → text_command.
if keyCfg.TextCommand == "" {
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
keyCfg.TextCommand = inner
keyCfg.Text = ""
}
}
spawn := func(name string, fn func()) {
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s %d: %v", name, keyIdx, r)
}
}()
fn()
}()
}
// 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)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("refreshTextKey", func() { refreshTextKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
log.Printf("key %d: toggle key requires icon_true and icon_false", keyIdx)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("pollKey", func() { pollKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Regular key: load icon once.
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)
}
}
return keyCfg, true
}
iconPath := filepath.Join(iconsDir, keyCfg.Icon)
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
frames, delays, err := loadGIF(sd, iconPath)
if err != nil {
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
spawn("animateKey", func() { animateKey(ctx, sd, keyIdx, frames, delays) })
return keyCfg, true
}
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
return keyCfg, true
}
// drawBackKey renders a folder's back key: the configured icon if there is
// one, otherwise a built-in chevron on black, with a "Back" label by default.
func drawBackKey(sd *device.StreamDeck, keyIdx int, back config.BackConfig, iconsDir string) {
if keyIdx < 0 || keyIdx >= sd.KeyCount() {
log.Printf("back key %d is outside the deck (%d keys)", keyIdx, sd.KeyCount())
return
}
var img image.Image
if back.Icon != "" {
loaded, err := loadImage(filepath.Join(iconsDir, back.Icon))
if err != nil {
log.Printf("back key %d: load icon %q: %v — using built-in arrow", keyIdx, back.Icon, err)
} else {
img = loaded
}
}
if img == nil {
img = backArrow(sd.ImageWidth(), sd.ImageHeight(), parseTextColor(back.TextColor))
}
text := back.Text
if text == "" && back.Icon == "" {
text = "Back"
}
if text != "" {
img = overlayText(img, text, back.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("back key %d: set image: %v", keyIdx, err)
}
}
// backArrow draws a left-pointing chevron in the upper part of a black key,
// leaving the bottom clear for the label.
func backArrow(w, h int, c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), image.Black, image.Point{}, draw.Src)
fw, fh := float64(w), float64(h)
tip := [2]float64{fw * 0.36, fh * 0.40}
top := [2]float64{fw * 0.62, fh * 0.16}
bottom := [2]float64{fw * 0.62, fh * 0.64}
thickness := fw * 0.075
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
px, py := float64(x)+0.5, float64(y)+0.5
d := math.Min(distToSegment(px, py, top, tip), distToSegment(px, py, tip, bottom))
if d <= thickness {
img.Set(x, y, c)
}
}
}
return img
}
// distToSegment is the distance from point p to the segment a→b.
func distToSegment(px, py float64, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
lenSq := dx*dx + dy*dy
t := 0.0
if lenSq > 0 {
t = ((px-a[0])*dx + (py-a[1])*dy) / lenSq
t = math.Max(0, math.Min(1, t))
}
cx, cy := a[0]+t*dx, a[1]+t*dy
return math.Hypot(px-cx, py-cy)
}
// pollKey watches the state of a toggle key and keeps its icon up to date.
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {

View File

@@ -106,3 +106,21 @@ keys:
# 13:
# text_command: "date +%H:%M:%S"
# refresh: 1s
# Folders: a key with `folder:` opens a second page of keys. Every folder shows
# a back key (slot 0 by default, built-in arrow) that returns to the parent.
# Folders can open other folders.
#
# 7:
# icon: folder.png
# text: Media
# folder: media
#
# folders:
# media:
# back: # optional: key, icon, text, text_color
# key: 0
# keys:
# 1:
# icon: play.png
# command: playerctl play-pause

View File

@@ -42,6 +42,34 @@ type KeyConfig struct {
Module string `yaml:"module"`
Function string `yaml:"function"`
Params map[string]string `yaml:"params"`
// Folder key: pressing it swaps the deck to the named folder's keys
// (defined under the top-level `folders:` block). Folders nest — a folder's
// key may itself have a folder — and every folder shows a back key.
Folder string `yaml:"folder"`
}
// BackConfig customises the back key every folder shows. All fields are
// optional: the default is key 0 drawn with a built-in arrow and "Back" label.
type BackConfig struct {
Key *int `yaml:"key"` // slot index for the back key (default 0)
Icon string `yaml:"icon"` // icon filename relative to icons_dir (default: built-in arrow)
Text string `yaml:"text"` // label (default "Back")
TextColor string `yaml:"text_color"` // same values as KeyConfig.TextColor
}
// FolderConfig is a sub-page of keys shown while a folder is open.
type FolderConfig struct {
Back BackConfig `yaml:"back"`
Keys map[int]KeyConfig `yaml:"keys"`
}
// BackKey returns the slot index the folder's back key occupies.
func (f FolderConfig) BackKey() int {
if f.Back.Key != nil {
return *f.Back.Key
}
return 0
}
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
@@ -65,6 +93,9 @@ type Config struct {
Brightness int `yaml:"brightness"`
Device DeviceConfig `yaml:"device"`
Keys map[int]KeyConfig `yaml:"keys"`
// Folders are sub-pages opened by keys with a `folder:` field. The root
// page is Keys; a folder's keys replace it until its back key is pressed.
Folders map[string]FolderConfig `yaml:"folders"`
}
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).

View File

@@ -103,7 +103,7 @@ var fieldOrder = []string{
"icon", "icon_true", "icon_false",
"text", "text_color", "text_command", "refresh",
"module", "function", "params",
"command", "poll",
"command", "folder", "poll",
}
func sortedFieldNames(m map[string]any) []string {

View File

@@ -0,0 +1,82 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFolders(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
src := `
keys:
3:
icon: folder.png
folder: media
folders:
media:
back:
key: 7
icon: back.png
text: Up
keys:
0:
icon: play.png
command: play
1:
icon: more.png
folder: nested
nested:
keys:
1:
text: Deep
command: echo deep
`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := cfg.Keys[3].Folder; got != "media" {
t.Errorf("key 3 folder = %q, want media", got)
}
media, ok := cfg.Folders["media"]
if !ok {
t.Fatal("folder media missing")
}
if media.BackKey() != 7 || media.Back.Icon != "back.png" || media.Back.Text != "Up" {
t.Errorf("media back = %+v (key %d)", media.Back, media.BackKey())
}
if media.Keys[0].Command != "play" || media.Keys[1].Folder != "nested" {
t.Errorf("media keys = %+v", media.Keys)
}
nested := cfg.Folders["nested"]
if nested.BackKey() != 0 {
t.Errorf("nested back key = %d, want default 0", nested.BackKey())
}
if nested.Keys[1].Text != "Deep" {
t.Errorf("nested keys = %+v", nested.Keys)
}
}
func TestApplyKeyEditFolder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte("keys: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
err := ApplyKeyEdit(path, 5, KeyEdit{Set: map[string]any{"icon": "f.png", "folder": "media"}})
if err != nil {
t.Fatalf("ApplyKeyEdit: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Keys[5].Folder != "media" || cfg.Keys[5].Icon != "f.png" {
t.Errorf("key 5 = %+v", cfg.Keys[5])
}
}

View File

@@ -123,6 +123,7 @@ function formatDuration(seconds) {
// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go.
function kindGlyph(kind) {
switch (kind) {
case "folder": return "󰉋" // folder
case "toggle": return "󰄣" // toggle-switch
case "module": return "󰅵" // puzzle piece
case "text": return "󰉼" // text