diff --git a/README.md b/README.md index 3799e94..4d745b3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/streamdeck-ctl/main.go b/cmd/streamdeck-ctl/main.go index 9e40b71..d3703a7 100644 --- a/cmd/streamdeck-ctl/main.go +++ b/cmd/streamdeck-ctl/main.go @@ -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)) diff --git a/cmd/streamdeck-ctl/status.go b/cmd/streamdeck-ctl/status.go index 963cc51..3caad67 100644 --- a/cmd/streamdeck-ctl/status.go +++ b/cmd/streamdeck-ctl/status.go @@ -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, "_", " ") } diff --git a/cmd/streamdeck/folder_test.go b/cmd/streamdeck/folder_test.go new file mode 100644 index 0000000..fece41d --- /dev/null +++ b/cmd/streamdeck/folder_test.go @@ -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) + } + } + } +} diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index 294d529..4abb2fc 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -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{}) { diff --git a/config.example.yaml b/config.example.yaml index e4b9497..2243686 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index af8f070..06ee889 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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). diff --git a/internal/config/edit.go b/internal/config/edit.go index a91344b..f6ce917 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -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 { diff --git a/internal/config/folders_test.go b/internal/config/folders_test.go new file mode 100644 index 0000000..d4cdc6c --- /dev/null +++ b/internal/config/folders_test.go @@ -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]) + } +} diff --git a/omarchy-plugin/Model.js b/omarchy-plugin/Model.js index 546edd9..ada8891 100644 --- a/omarchy-plugin/Model.js +++ b/omarchy-plugin/Model.js @@ -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