1
0
Maksim Totmin 19e3040965 Multi-action keys with tap/long-press/double-tap/hold, gesture timing, display output colors, global font, and display output JSON/color parsing
- Multi-action system: each key supports multiple actions with different triggers
  (tap, long_press, double_tap, hold_start, hold_end)
- GestureEngine: timing-based gesture detection with configurable thresholds
- New config types: KeyAction, TimingConfig, DisplayOutput
- Display output now supports JSON format (text/background/text_color) and
  first-line-as-hex-color format for dynamic key backgrounds
- Display background colors are reflected in the web UI on the grid
- renderUnicodeText/renderUnicodeTextOnImage accept custom text color (fg)
- Global font setting (medium/regular) in Settings UI
- PageManager.defaultFont used as fallback for per-key font
- Alpine.js bundled locally (no CDN dependency)
- Web UI: action tabs (Tap/Long Press/Double Tap/Hold), unified More section
  with appearance + display settings, gesture timing sliders in Settings
- Grid: subtle action type icons in top-left corner matching display indicator style
- Backward compatible: old config 'action' field auto-migrated to 'actions' array
- M+ 1m font files in assets/ for reference
- Updated config.example.json and README with all new features
2026-06-17 00:09:48 +07:00

328 lines
9.2 KiB
Go

package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"time"
)
type Config struct {
Version int `json:"version"`
LogLevel string `json:"log_level"`
DefaultPage string `json:"default_page"`
Font string `json:"font,omitempty"`
Devices []DeviceConfig `json:"devices"`
Pages []PageConfig `json:"pages"`
AutoSwitch []SwitchRule `json:"auto_switch"`
Screensaver ScreensaverCfg `json:"screensaver"`
Timing TimingConfig `json:"timing,omitempty"`
}
type TimingConfig struct {
LongPressMs int `json:"long_press_ms,omitempty"`
DoubleTapMs int `json:"double_tap_ms,omitempty"`
}
const (
DefaultLongPressMs = 500
DefaultDoubleTapMs = 300
)
type DeviceConfig struct {
Serial string `json:"serial"`
Brightness int `json:"brightness"`
Rotation int `json:"rotation,omitempty"`
}
type PageConfig struct {
Name string `json:"name"`
Icon string `json:"icon,omitempty"`
Keys []KeyConfig `json:"keys"`
Background string `json:"background,omitempty"`
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
}
type KeyConfig struct {
Index int `json:"index"`
Icon string `json:"icon,omitempty"`
Background string `json:"background,omitempty"`
Label string `json:"label,omitempty"`
Font string `json:"font,omitempty"`
FontSize *float64 `json:"font_size,omitempty"`
IconScale *float64 `json:"icon_scale,omitempty"`
Actions []KeyAction `json:"actions,omitempty"`
Display *DisplayCfg `json:"display,omitempty"`
}
type KeyAction struct {
Trigger string `json:"trigger"`
Action `json:",inline"`
}
type Action struct {
Type string `json:"type"`
Command string `json:"command,omitempty"`
Builtin string `json:"builtin,omitempty"`
Script string `json:"script,omitempty"`
Page string `json:"page,omitempty"`
Background bool `json:"background,omitempty"`
Keys string `json:"keys,omitempty"`
}
func (k *KeyConfig) UnmarshalJSON(data []byte) error {
type Alias KeyConfig
aux := &struct {
OldAction *Action `json:"action"`
*Alias
}{Alias: (*Alias)(k)}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
if aux.OldAction != nil && len(k.Actions) == 0 {
k.Actions = []KeyAction{{Trigger: "tap", Action: *aux.OldAction}}
}
return nil
}
type DisplayCfg struct {
Command string `json:"command,omitempty"`
Script string `json:"script,omitempty"`
Interval string `json:"interval"`
MaxLen int `json:"max_len,omitempty"`
Timeout string `json:"timeout,omitempty"`
}
type SwitchRule struct {
WMClass string `json:"wm_class"`
Title string `json:"title,omitempty"`
Page string `json:"page"`
Devices []string `json:"devices,omitempty"`
Stay bool `json:"stay,omitempty"`
}
type ScreensaverCfg struct {
Enabled bool `json:"enabled"`
IdleSeconds int `json:"idle_seconds,omitempty"`
Image string `json:"image,omitempty"`
Brightness int `json:"brightness,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
Version: 1,
LogLevel: "info",
DefaultPage: "default",
Pages: []PageConfig{
{
Name: "default",
Keys: []KeyConfig{},
},
},
Timing: TimingConfig{
LongPressMs: DefaultLongPressMs,
DoubleTapMs: DefaultDoubleTapMs,
},
}
}
func ConfigPath(path string) string {
if path != "" {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".config", "streamdeck-lets-go", "config.json")
}
func LoadConfig(path string) (*Config, error) {
path = ConfigPath(path)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
slog.Info("config not found, using defaults", "path", path)
return DefaultConfig(), nil
}
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if cfg.Timing.LongPressMs <= 0 {
cfg.Timing.LongPressMs = DefaultLongPressMs
}
if cfg.Timing.DoubleTapMs <= 0 {
cfg.Timing.DoubleTapMs = DefaultDoubleTapMs
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
func (c *Config) Save(path string) error {
path = ConfigPath(path)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
func validTrigger(t string) bool {
switch t {
case "tap", "long_press", "double_tap", "hold_start", "hold_end":
return true
}
return false
}
func (c *Config) Validate() error {
if c.Version < 1 {
return fmt.Errorf("unsupported config version: %d", c.Version)
}
if c.DefaultPage == "" {
return fmt.Errorf("default_page is required")
}
pageNames := make(map[string]bool)
for _, p := range c.Pages {
if p.Name == "" {
return fmt.Errorf("page name is required")
}
if pageNames[p.Name] {
return fmt.Errorf("duplicate page name: %s", p.Name)
}
pageNames[p.Name] = true
seenKeys := make(map[int]bool)
for _, k := range p.Keys {
if k.Index < 0 {
return fmt.Errorf("page %s: key index must be >= 0", p.Name)
}
if seenKeys[k.Index] {
return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index)
}
seenKeys[k.Index] = true
seenTriggers := make(map[string]bool)
for _, ka := range k.Actions {
if ka.Trigger == "" {
return fmt.Errorf("page %s key %d: action trigger is required", p.Name, k.Index)
}
if !validTrigger(ka.Trigger) {
return fmt.Errorf("page %s key %d: unknown trigger %q", p.Name, k.Index, ka.Trigger)
}
if seenTriggers[ka.Trigger] {
return fmt.Errorf("page %s key %d: duplicate trigger %q", p.Name, k.Index, ka.Trigger)
}
seenTriggers[ka.Trigger] = true
switch ka.Type {
case "command":
if ka.Command == "" {
return fmt.Errorf("page %s key %d: command required for command action", p.Name, k.Index)
}
case "builtin":
case "script":
if ka.Script == "" {
return fmt.Errorf("page %s key %d: script path required for script action", p.Name, k.Index)
}
case "page":
if ka.Page == "" {
return fmt.Errorf("page %s key %d: page name required for page action", p.Name, k.Index)
}
case "keyboard":
if ka.Keys == "" {
return fmt.Errorf("page %s key %d: keys required for keyboard action", p.Name, k.Index)
}
case "":
return fmt.Errorf("page %s key %d: action type is required", p.Name, k.Index)
default:
return fmt.Errorf("page %s key %d: unknown action type: %s", p.Name, k.Index, ka.Type)
}
}
if k.Display != nil {
if k.Display.Command == "" && k.Display.Script == "" {
return fmt.Errorf("page %s key %d: display requires command or script", p.Name, k.Index)
}
if k.Display.Command != "" && k.Display.Script != "" {
return fmt.Errorf("page %s key %d: display command and script are mutually exclusive", p.Name, k.Index)
}
if k.Display.Interval == "" {
return fmt.Errorf("page %s key %d: display interval is required", p.Name, k.Index)
}
interval, err := time.ParseDuration(k.Display.Interval)
if err != nil {
return fmt.Errorf("page %s key %d: invalid display interval %q: %w", p.Name, k.Index, k.Display.Interval, err)
}
if interval < time.Second {
return fmt.Errorf("page %s key %d: display interval must be at least 1s", p.Name, k.Index)
}
if interval > 24*time.Hour {
return fmt.Errorf("page %s key %d: display interval must not exceed 24h", p.Name, k.Index)
}
if k.Display.Timeout != "" {
if _, err := time.ParseDuration(k.Display.Timeout); err != nil {
return fmt.Errorf("page %s key %d: invalid display timeout %q: %w", p.Name, k.Index, k.Display.Timeout, err)
}
}
if k.Display.MaxLen < 0 {
return fmt.Errorf("page %s key %d: max_len must be >= 0", p.Name, k.Index)
}
if k.Display.MaxLen > 4096 {
return fmt.Errorf("page %s key %d: max_len must be <= 4096", p.Name, k.Index)
}
}
}
}
for _, p := range c.Pages {
for _, k := range p.Keys {
for _, ka := range k.Actions {
if ka.Type == "page" {
if !pageNames[ka.Page] {
return fmt.Errorf("page %s key %d: references unknown page %q", p.Name, k.Index, ka.Page)
}
}
}
}
}
for _, sr := range c.AutoSwitch {
if sr.WMClass == "" && sr.Title == "" {
return fmt.Errorf("auto_switch rule: wm_class or title is required")
}
if !pageNames[sr.Page] {
return fmt.Errorf("auto_switch rule: references unknown page %q", sr.Page)
}
if _, err := regexp.Compile(sr.WMClass); sr.WMClass != "" && err != nil {
return fmt.Errorf("auto_switch rule: invalid wm_class regex %q: %w", sr.WMClass, err)
}
if _, err := regexp.Compile(sr.Title); sr.Title != "" && err != nil {
return fmt.Errorf("auto_switch rule: invalid title regex %q: %w", sr.Title, err)
}
}
if !pageNames[c.DefaultPage] {
return fmt.Errorf("default_page %q not found in pages", c.DefaultPage)
}
return nil
}