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

@@ -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, "_", " ")
}