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:
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
82
internal/config/folders_test.go
Normal file
82
internal/config/folders_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user