Files
streamdeck-go/internal/config/edit.go
Levi Woodard 4158165410 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
2026-10-01 18:39:24 -06:00

403 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Editing works at the yaml.Node level rather than by marshalling the Config
// struct back out. The struct round-trip would drop every comment in the file
// and materialise defaults the user never wrote; node-level edits touch only
// the mapping entries that actually change.
//
// Writes are atomic (temp file in the same directory, then rename). The daemon
// watches both the config file and its parent directory, so the rename lands as
// a CREATE event on the directory watch and triggers a hot reload.
// KeyEdit describes a change to a single key. Fields present in Set are written
// (creating the key block if needed); names listed in Unset are removed. Unset
// is applied first, so a field named in both ends up set. Values may be strings,
// ints, or map[string]string (params).
type KeyEdit struct {
Set map[string]any
Unset []string
}
// SetBrightness rewrites the top-level brightness value, clamped to 0–100.
func SetBrightness(path string, value int) error {
if value < 0 {
value = 0
}
if value > 100 {
value = 100
}
return editDocument(path, func(root *yaml.Node) error {
mapSet(root, "brightness", scalarNode(strconv.Itoa(value), "!!int"))
return nil
})
}
// ApplyKeyEdit merges an edit into the keys block for the given index, creating
// the keys block or the key entry if either is missing.
func ApplyKeyEdit(path string, index int, edit KeyEdit) error {
return editDocument(path, func(root *yaml.Node) error {
keys := mapGet(root, "keys")
if keys == nil || keys.Kind != yaml.MappingNode {
keys = &yaml.Node{Kind: yaml.MappingNode}
mapSet(root, "keys", keys)
}
// A fresh config from the installer carries `keys: {}` — a flow-style
// empty mapping. Force block style or new entries render inline.
keys.Style = 0
entry := mapGet(keys, strconv.Itoa(index))
if entry == nil || entry.Kind != yaml.MappingNode {
entry = &yaml.Node{Kind: yaml.MappingNode}
// The index must be an int scalar: config.Load unmarshals keys into
// map[int]KeyConfig, and yaml.v3 refuses to coerce a !!str key ("25":)
// into an int — a string-tagged key here bricks the config on reload.
mapSetNode(keys, scalarNode(strconv.Itoa(index), "!!int"), entry)
}
entry.Style = 0
for _, name := range edit.Unset {
mapDelete(entry, name)
}
// Deterministic ordering so repeated edits don't shuffle the file, and so
// a brand-new key block reads in a sensible order.
for _, name := range sortedFieldNames(edit.Set) {
node, err := valueNode(edit.Set[name])
if err != nil {
return fmt.Errorf("field %q: %w", name, err)
}
mapSet(entry, name, node)
}
return nil
})
}
// ClearKey removes a key entry entirely. Removing a key that isn't there is not
// an error — the caller's intent (that slot ends up empty) is already satisfied.
func ClearKey(path string, index int) error {
return editDocument(path, func(root *yaml.Node) error {
keys := mapGet(root, "keys")
if keys == nil || keys.Kind != yaml.MappingNode {
return nil
}
mapDelete(keys, strconv.Itoa(index))
return nil
})
}
// fieldOrder is the order key fields are written in when a key block is built or
// extended, so generated YAML reads the way the hand-written examples do.
var fieldOrder = []string{
"icon", "icon_true", "icon_false",
"text", "text_color", "text_command", "refresh",
"module", "function", "params",
"command", "folder", "poll",
}
func sortedFieldNames(m map[string]any) []string {
rank := make(map[string]int, len(fieldOrder))
for i, name := range fieldOrder {
rank[name] = i
}
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
ri, oki := rank[names[i]]
rj, okj := rank[names[j]]
if oki != okj {
return oki // known fields sort before unknown ones
}
if oki && ri != rj {
return ri < rj
}
return names[i] < names[j]
})
return names
}
// editDocument parses path, hands the root mapping to fn, and writes the result
// back atomically. The file must already exist and parse.
func editDocument(path string, fn func(root *yaml.Node) error) error {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
// yaml.v3 keeps comments but discards blank lines, so a config edited from a
// GUI would slowly lose its paragraph breaks. Standing them in as sentinel
// comments carries them through the round-trip.
data, blanksProtected := protectBlankLines(raw)
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
var root *yaml.Node
switch {
case doc.Kind == yaml.DocumentNode && len(doc.Content) > 0:
root = doc.Content[0]
case doc.Kind == 0:
// Empty (or comment-only) file — start a mapping so edits have somewhere to go.
root = &yaml.Node{Kind: yaml.MappingNode}
doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
default:
return fmt.Errorf("config %q: unexpected document shape", path)
}
if root.Kind != yaml.MappingNode {
return fmt.Errorf("config %q: top level is not a mapping", path)
}
if err := fn(root); err != nil {
return err
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&doc); err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("encode config: %w", err)
}
out := buf.Bytes()
if blanksProtected {
out = restoreBlankLines(out)
}
return writeAtomic(path, out)
}
// blankSentinel stands in for a blank line across the parse/encode round-trip.
// It is deliberately obscure so it cannot collide with a real comment.
const blankSentinel = "#__streamdeck_go_blank_line__"
// protectBlankLines rewrites blank lines as sentinel comments. It reports false
// (and leaves the input untouched) when the document contains a block scalar,
// where an inserted line would become part of the string's content rather than
// structure. Losing blank-line formatting is a cosmetic regression; corrupting a
// multi-line command is not, so the ambiguous case declines to act.
func protectBlankLines(data []byte) ([]byte, bool) {
if blockScalarPattern.Match(data) {
return data, false
}
lines := strings.Split(string(data), "\n")
changed := false
for i, line := range lines {
if strings.TrimSpace(line) == "" && i != len(lines)-1 {
lines[i] = blankSentinel
changed = true
}
}
if !changed {
return data, false
}
return []byte(strings.Join(lines, "\n")), true
}
// restoreBlankLines turns sentinel comments back into blank lines. The encoder
// may have indented them along with the comment block they joined, so the match
// is on the trimmed line.
func restoreBlankLines(data []byte) []byte {
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if strings.TrimSpace(line) == blankSentinel {
lines[i] = ""
}
}
return []byte(strings.Join(lines, "\n"))
}
// blockScalarPattern matches a literal or folded block scalar header, e.g.
// `command: |`, `command: >-`, or `command: |2 # note`.
var blockScalarPattern = regexp.MustCompile(`(?m):[ \t]*[|>][-+0-9]*[ \t]*(#.*)?$`)
// writeAtomic writes to a temp file in the same directory and renames it into
// place, so a reader (or the daemon's fsnotify reload) never sees a half-written
// config. The original mode is preserved when it can be read.
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
mode := os.FileMode(0o644)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
tmp, err := os.CreateTemp(dir, ".streamdeck-config-*.yaml")
if err != nil {
return fmt.Errorf("create temp file in %q: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once the rename succeeds
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, mode); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace %q: %w", path, err)
}
return nil
}
// ── yaml.Node mapping helpers ────────────────────────────────────────────────
//
// A MappingNode stores Content as a flat [key, value, key, value, ...] slice.
func mapGet(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// mapSet replaces the value for key, or appends the pair if it isn't present.
// Replacing keeps the existing key node so its comments stay attached.
func mapSet(m *yaml.Node, key string, value *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
// Carry the old value's comments onto the replacement — they describe
// the setting, not the specific value being overwritten.
old := m.Content[i+1]
if value.HeadComment == "" {
value.HeadComment = old.HeadComment
}
if value.LineComment == "" {
value.LineComment = old.LineComment
}
if value.FootComment == "" {
value.FootComment = old.FootComment
}
m.Content[i+1] = value
return
}
}
m.Content = append(m.Content, scalarNode(key, "!!str"), value)
}
// mapSetNode is mapSet with a caller-built key node, for keys that must carry a
// non-string tag (e.g. the !!int key indices under `keys:`).
func mapSetNode(m *yaml.Node, key, value *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key.Value {
m.Content[i+1] = value
return
}
}
m.Content = append(m.Content, key, value)
}
func mapDelete(m *yaml.Node, key string) {
if m == nil || m.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content = append(m.Content[:i], m.Content[i+2:]...)
return
}
}
}
func scalarNode(value, tag string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value}
}
// valueNode converts a Go value from a KeyEdit into a yaml.Node.
func valueNode(v any) (*yaml.Node, error) {
switch typed := v.(type) {
case string:
return stringNode(typed), nil
case int:
return scalarNode(strconv.Itoa(typed), "!!int"), nil
case bool:
return scalarNode(strconv.FormatBool(typed), "!!bool"), nil
case map[string]string:
node := &yaml.Node{Kind: yaml.MappingNode}
names := make([]string, 0, len(typed))
for name := range typed {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
node.Content = append(node.Content, scalarNode(name, "!!str"), stringNode(typed[name]))
}
return node, nil
default:
return nil, fmt.Errorf("unsupported value type %T", v)
}
}
// stringNode emits a string scalar, quoting it when leaving it bare would change
// its meaning on re-parse (empty, leading/trailing space, or a value YAML would
// read back as a bool/number/null).
func stringNode(s string) *yaml.Node {
node := scalarNode(s, "!!str")
if needsQuoting(s) {
node.Style = yaml.DoubleQuotedStyle
}
return node
}
func needsQuoting(s string) bool {
if s == "" {
return true
}
if s != trimSpace(s) {
return true
}
var probe any
if err := yaml.Unmarshal([]byte(s), &probe); err != nil {
// Not parseable bare (e.g. contains ": ") — quote it and let the encoder escape.
return true
}
if _, isString := probe.(string); !isString {
return true
}
return false
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && isSpace(s[start]) {
start++
}
for end > start && isSpace(s[end-1]) {
end--
}
return s[start:end]
}
func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}