diff --git a/README.md b/README.md index ad8a72e..56eb351 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ ## Features - **Web-based editor** — Alpine.js SPA for managing pages, keys, icons, and actions -- **Multi-action support** — assign shell commands, scripts, page switching, media/volume/brightness controls, and **keyboard shortcuts** to any key -- **On-device display** — render icons, text labels, and periodic command output directly on Stream Deck keys +- **Multi-action keys** — assign different actions to **tap**, **long press**, **double tap**, and **hold** on the same key +- **On-device display** — render icons, text labels, and periodic command output directly on Stream Deck keys; output can include custom background and text colors - **Auto page switching** — automatically change pages based on the focused window (Hyprland, Sway, Niri, GNOME, KDE, X11) - **Screensaver** — dim or blank the deck after a configurable idle timeout +- **Gesture timing** — configurable long press (default 500ms) and double tap (default 300ms) thresholds - **Hot-reload** — config changes via the web UI are applied live; manual edits to `config.json` are detected and reloaded automatically - **No cloud, no Electron** — single Go binary with an embedded web frontend @@ -50,7 +51,33 @@ Open `http://localhost:9090` in your browser. The config file is stored at `~/.config/streamdeck-lets-go/config.json`. You can edit it manually or through the web UI. -### Key actions +### Multi-action keys + +Each key can have multiple actions with different triggers: + +```json +{ + "index": 7, + "icon": "fa:chevron-right", + "label": "Next", + "actions": [ + { "trigger": "tap", "type": "builtin", "builtin": "page:next" }, + { "trigger": "long_press", "type": "builtin", "builtin": "page:prev" } + ] +} +``` + +Supported triggers: + +| Trigger | Behavior | +|---|---| +| `tap` | Quick press and release | +| `long_press` | Held past the threshold (default 500ms) | +| `double_tap` | Two taps within the threshold (default 300ms) | +| `hold_start` | Fires when held past the threshold | +| `hold_end` | Fires on release after a hold | + +### Key action types | Type | Field | Description | |---|---|---| @@ -78,10 +105,42 @@ The daemon warns on startup if the required tool is missing. | `volume_up` / `volume_down` / `volume_mute` | PipeWire volume (via `wpctl`) | | `brightness_up` / `brightness_down` | Display brightness (via `brightnessctl`) | | `media_play_pause` / `media_next` / `media_prev` / `media_stop` | MPRIS media (via `playerctl`) | +| `page:next` / `page:prev` | Switch to next/previous page | +| `deck:brightness-up` / `deck:brightness-down` | Cycle deck brightness | ### Periodic display -Any key can display the output of a command or script, updated on an interval. Use this for weather, system stats, calendar, etc. +Any key can display the output of a command or script, updated on an interval. Use this for weather, system stats, monitoring, etc. + +The command output can include **background color** and **text color** using one of these formats: + +**JSON format:** +```json +{"text": "Server OK", "background": "#22c55e", "text_color": "#ffffff"} +``` + +**First-line-as-color format:** +``` +#ff0000 +Server down! +``` + +If no color is specified, the key uses its configured background or the default black. + +### Gesture timing + +Configured in the web UI (Settings → Gesture Timing) or in `config.json`: + +```json +"timing": { + "long_press_ms": 500, + "double_tap_ms": 300 +} +``` + +### Default font + +Set globally in Settings → Device → Default Font (`"medium"` or `"regular"`), or override per-key in the key editor. ### Auto-switch rules @@ -125,10 +184,10 @@ Dependencies: | Model | PID | Keys | |---|---|---| | Stream Deck Original | `0x006d` | 15 | -| Stream Deck Mini | `0x0063` | 6 | +| Stream Deck Mini | `0x0060` | 6 | | Stream Deck XL | `0x006c` | 32 | | Stream Deck MK.2 | `0x0080` | 15 | -| Stream Deck Pedal | `0x0086` | 3 | +| Stream Deck Original V2 | `0x0063` | 15 | --- diff --git a/assets/mplus-1m-medium.ttf b/assets/mplus-1m-medium.ttf new file mode 100644 index 0000000..861692e Binary files /dev/null and b/assets/mplus-1m-medium.ttf differ diff --git a/assets/mplus-1m-regular.ttf b/assets/mplus-1m-regular.ttf new file mode 100644 index 0000000..f28fc25 Binary files /dev/null and b/assets/mplus-1m-regular.ttf differ diff --git a/config.example.json b/config.example.json index e1ec623..7edf44d 100644 --- a/config.example.json +++ b/config.example.json @@ -2,6 +2,7 @@ "version": 1, "log_level": "info", "default_page": "main", + "font": "medium", "devices": [ { "serial": "", @@ -15,23 +16,45 @@ { "index": 0, "icon": "fa:terminal", - "action": { - "type": "command", - "command": "kitty", - "background": true - } + "actions": [ + { + "trigger": "tap", + "type": "command", + "command": "kitty", + "background": true + } + ] }, { "index": 1, "icon": "fab:firefox", "label": "Browser", - "font": "medium", "font_size": 11, - "action": { - "type": "command", - "command": "firefox", - "background": true - } + "actions": [ + { + "trigger": "tap", + "type": "command", + "command": "firefox", + "background": true + } + ] + }, + { + "index": 7, + "icon": "fa:chevron-right", + "label": "Next", + "actions": [ + { + "trigger": "tap", + "type": "builtin", + "builtin": "page:next" + }, + { + "trigger": "long_press", + "type": "builtin", + "builtin": "page:prev" + } + ] } ] } @@ -42,5 +65,9 @@ "idle_seconds": 30, "image": "", "brightness": 10 + }, + "timing": { + "long_press_ms": 500, + "double_tap_ms": 300 } } diff --git a/daemon.go b/daemon.go index e87e781..7215087 100644 --- a/daemon.go +++ b/daemon.go @@ -56,6 +56,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { pageMgrs := make([]*PageManager, len(decks)) for i, d := range decks { pageMgrs[i] = NewPageManager(d) + pageMgrs[i].defaultFont = cfg.Font pageMgrs[i].LoadPages(cfg.Pages) } primaryPM := pageMgrs[0] @@ -99,6 +100,21 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { ssCtrl := NewScreensaver(&cfg.Screensaver) + ge := NewGestureEngine(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs, func(a *config.Action) { + if a == nil { + return + } + if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil { + slog.Error("execute action", "error", err) + } + if a.Type == "page" { + asm.NotifyManualPage(a.Page) + for _, pm := range pageMgrs[1:] { + pm.ActivatePage(a.Page) + } + } + }) + reconnectTicker := time.NewTicker(5 * time.Second) defer reconnectTicker.Stop() @@ -146,38 +162,27 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { } } - for idx, output := range savedOutputs { - primaryPM.ReRenderDisplayKey(idx, output) - } - } - - page := primaryPM.ActivePage() - if page == nil { - continue - } - for _, k := range page.Keys { - if k.Index == evt.Index && k.Action != nil { - slog.Debug("key pressed", "index", evt.Index, "action", k.Action.Type) - - if k.Action.Type == "page" { - asm.NotifyManualPage(k.Action.Page) + for idx, dout := range savedOutputs { + if dout != nil { + primaryPM.ReRenderDisplayKey(idx, dout.Text) } - - go func(a *config.Action) { - if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil { - slog.Error("execute action", "error", err) - } - if a.Type == "page" { - for _, pm := range pageMgrs[1:] { - pm.ActivatePage(a.Page) - } - } - }(k.Action) - break } } } + page := primaryPM.ActivePage() + if page == nil { + continue + } + for _, k := range page.Keys { + if k.Index == evt.Index { + if len(k.Actions) > 0 { + ge.HandleEvent(evt, k.Actions) + } + break + } + } + case win := <-windowCh: if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok { for _, pm := range pageMgrs { @@ -209,10 +214,12 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { for _, d := range decks { d.SetBrightness(deviceBrightness(cfg, d.Serial())) } - for _, pm := range pageMgrs { - pm.stopPeriodicKeys() - pm.LoadPages(cfg.Pages) - } + for _, pm := range pageMgrs { + pm.stopPeriodicKeys() + pm.defaultFont = cfg.Font + pm.LoadPages(cfg.Pages) + } + ge.ReloadTiming(cfg.Timing.LongPressMs, cfg.Timing.DoubleTapMs) asm.Reload(cfg.AutoSwitch) if len(cfg.AutoSwitch) > 0 && detector == nil { detector = NewWindowDetector() @@ -285,6 +292,7 @@ func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, as newDeck, err := OpenDeck("") if err == nil { *pm = NewPageManager(newDeck) + (*pm).defaultFont = cfg.Font (*pm).LoadPages(cfg.Pages) (*pm).ActivatePage(cfg.DefaultPage) (*pm).startPeriodicKeys() diff --git a/deck.go b/deck.go index 27d3490..e44a3b1 100644 --- a/deck.go +++ b/deck.go @@ -7,6 +7,7 @@ import ( "image/draw" "log/slog" "sync" + "time" "github.com/bearsh/hid" "github.com/dh1tw/streamdeck" @@ -83,6 +84,7 @@ func toStreamDeckConfig(dc DeckConfig) *streamdeck.Config { type Event struct { Kind int Index int + At time.Time } const ( @@ -137,7 +139,7 @@ func OpenDeck(serial string) (*Deck, error) { return } select { - case d.events <- Event{Kind: kind, Index: e.Which}: + case d.events <- Event{Kind: kind, Index: e.Which, At: time.Now()}: default: slog.Warn("event channel full, dropping event", "kind", e.Kind, "index", e.Which) } diff --git a/gesture.go b/gesture.go new file mode 100644 index 0000000..985bfca --- /dev/null +++ b/gesture.go @@ -0,0 +1,172 @@ +package main + +import ( + "sync" + "time" + + "streamdeck-lets-go/internal/config" +) + +type ActionCallback func(a *config.Action) + +type gestureKeyState struct { + mu sync.Mutex + pressedAt time.Time + lastTapAt time.Time + holdTimer *time.Timer + holdActive bool + pendingTap *time.Timer +} + +type GestureEngine struct { + mu sync.Mutex + states map[int]*gestureKeyState + longMs time.Duration + doubleMs time.Duration + onAction ActionCallback +} + +func NewGestureEngine(longMs, doubleMs int, cb ActionCallback) *GestureEngine { + if longMs <= 0 { + longMs = config.DefaultLongPressMs + } + if doubleMs <= 0 { + doubleMs = config.DefaultDoubleTapMs + } + return &GestureEngine{ + states: make(map[int]*gestureKeyState), + longMs: time.Duration(longMs) * time.Millisecond, + doubleMs: time.Duration(doubleMs) * time.Millisecond, + onAction: cb, + } +} + +func (ge *GestureEngine) ReloadTiming(longMs, doubleMs int) { + ge.mu.Lock() + defer ge.mu.Unlock() + if longMs > 0 { + ge.longMs = time.Duration(longMs) * time.Millisecond + } + if doubleMs > 0 { + ge.doubleMs = time.Duration(doubleMs) * time.Millisecond + } +} + +func (ge *GestureEngine) Reset() { + ge.mu.Lock() + defer ge.mu.Unlock() + for _, s := range ge.states { + s.mu.Lock() + s.cancelTimers() + s.holdActive = false + s.lastTapAt = time.Time{} + s.mu.Unlock() + } +} + +func (s *gestureKeyState) cancelTimers() { + if s.holdTimer != nil { + s.holdTimer.Stop() + s.holdTimer = nil + } + if s.pendingTap != nil { + s.pendingTap.Stop() + s.pendingTap = nil + } +} + +func (ge *GestureEngine) getState(index int) *gestureKeyState { + ge.mu.Lock() + defer ge.mu.Unlock() + s, ok := ge.states[index] + if !ok { + s = &gestureKeyState{} + ge.states[index] = s + } + return s +} + +func findAction(actions []config.KeyAction, trigger string) *config.Action { + for _, a := range actions { + if a.Trigger == trigger { + return &a.Action + } + } + return nil +} + +func (ge *GestureEngine) HandleEvent(evt Event, actions []config.KeyAction) { + state := ge.getState(evt.Index) + + if len(actions) == 0 { + return + } + + switch evt.Kind { + case EventKeyPressed: + ge.handleKeyPress(state, evt, actions) + case EventKeyReleased: + ge.handleKeyRelease(state, evt, actions) + } +} + +func (ge *GestureEngine) handleKeyPress(state *gestureKeyState, evt Event, actions []config.KeyAction) { + state.mu.Lock() + state.cancelTimers() + state.holdActive = false + state.pressedAt = evt.At + state.holdTimer = time.AfterFunc(ge.longMs, func() { + state.mu.Lock() + state.holdActive = true + state.holdTimer = nil + state.mu.Unlock() + + if a := findAction(actions, "hold_start"); a != nil { + ge.onAction(a) + } else if a := findAction(actions, "long_press"); a != nil { + ge.onAction(a) + } + }) + state.mu.Unlock() +} + +func (ge *GestureEngine) handleKeyRelease(state *gestureKeyState, evt Event, actions []config.KeyAction) { + state.mu.Lock() + state.cancelTimers() + + if state.holdActive { + state.holdActive = false + state.mu.Unlock() + if a := findAction(actions, "hold_end"); a != nil { + ge.onAction(a) + } + return + } + + if !state.lastTapAt.IsZero() && evt.At.Sub(state.lastTapAt) <= ge.doubleMs { + state.lastTapAt = time.Time{} + state.mu.Unlock() + if a := findAction(actions, "double_tap"); a != nil { + ge.onAction(a) + } + return + } + + lastTap := evt.At + state.lastTapAt = lastTap + + state.pendingTap = time.AfterFunc(ge.doubleMs, func() { + state.mu.Lock() + if state.lastTapAt.Equal(lastTap) { + state.lastTapAt = time.Time{} + state.pendingTap = nil + state.mu.Unlock() + if a := findAction(actions, "tap"); a != nil { + ge.onAction(a) + } + return + } + state.mu.Unlock() + }) + state.mu.Unlock() +} diff --git a/internal/config/config.go b/internal/config/config.go index f44961c..ec39581 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,12 +14,24 @@ 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"` @@ -35,15 +47,20 @@ type PageConfig struct { } 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"` - Action *Action `json:"action,omitempty"` - Display *DisplayCfg `json:"display,omitempty"` + 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 { @@ -56,6 +73,21 @@ type Action struct { 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"` @@ -90,6 +122,10 @@ func DefaultConfig() *Config { Keys: []KeyConfig{}, }, }, + Timing: TimingConfig{ + LongPressMs: DefaultLongPressMs, + DoubleTapMs: DefaultDoubleTapMs, + }, } } @@ -120,6 +156,13 @@ func LoadConfig(path string) (*Config, error) { 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) } @@ -142,6 +185,14 @@ func (c *Config) Save(path string) error { 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) @@ -169,29 +220,41 @@ func (c *Config) Validate() error { } seenKeys[k.Index] = true - if k.Action != nil { - switch k.Action.Type { + 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 k.Action.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 k.Action.Script == "" { + if ka.Script == "" { return fmt.Errorf("page %s key %d: script path required for script action", p.Name, k.Index) } case "page": - if k.Action.Page == "" { + if ka.Page == "" { return fmt.Errorf("page %s key %d: page name required for page action", p.Name, k.Index) } case "keyboard": - if k.Action.Keys == "" { + 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, k.Action.Type) + return fmt.Errorf("page %s key %d: unknown action type: %s", p.Name, k.Index, ka.Type) } } @@ -232,9 +295,11 @@ func (c *Config) Validate() error { for _, p := range c.Pages { for _, k := range p.Keys { - if k.Action != nil && k.Action.Type == "page" { - if !pageNames[k.Action.Page] { - return fmt.Errorf("page %s key %d: references unknown page %q", p.Name, k.Index, k.Action.Page) + 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) + } } } } diff --git a/page.go b/page.go index dd9997b..2b8fd34 100644 --- a/page.go +++ b/page.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "image" "image/color" @@ -33,14 +34,21 @@ type keyState struct { cancel context.CancelFunc } +type DisplayOutput struct { + Text string `json:"text"` + Background string `json:"background,omitempty"` + TextColor string `json:"text_color,omitempty"` +} + type PageManager struct { mu sync.RWMutex pages map[string]*config.PageConfig active string deck *Deck keyStates map[int]*keyState - displayOutputs map[int]string + displayOutputs map[int]*DisplayOutput displayMu sync.RWMutex + defaultFont string } func NewPageManager(deck *Deck) *PageManager { @@ -48,7 +56,8 @@ func NewPageManager(deck *Deck) *PageManager { pages: make(map[string]*config.PageConfig), deck: deck, keyStates: make(map[int]*keyState), - displayOutputs: make(map[int]string), + displayOutputs: make(map[int]*DisplayOutput), + defaultFont: "medium", } } @@ -74,7 +83,7 @@ func (pm *PageManager) ActivatePage(name string) error { pm.active = name pm.displayMu.Lock() - pm.displayOutputs = make(map[int]string) + pm.displayOutputs = make(map[int]*DisplayOutput) pm.displayMu.Unlock() if err := pm.deck.ClearAll(); err != nil { @@ -122,10 +131,10 @@ func (pm *PageManager) ActivePage() *config.PageConfig { return pm.pages[pm.active] } -func (pm *PageManager) GetDisplayOutputs() map[int]string { +func (pm *PageManager) GetDisplayOutputs() map[int]*DisplayOutput { pm.displayMu.RLock() defer pm.displayMu.RUnlock() - result := make(map[int]string, len(pm.displayOutputs)) + result := make(map[int]*DisplayOutput, len(pm.displayOutputs)) for k, v := range pm.displayOutputs { result[k] = v } @@ -171,7 +180,7 @@ func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) { func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) { fontName := k.Font if fontName == "" { - fontName = "medium" + fontName = pm.defaultFont } fontSize := 18.0 if k.FontSize != nil { @@ -208,7 +217,7 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) { } onImgFontName := fontName if k.Font == "" { - onImgFontName = "regular" + onImgFontName = pm.defaultFont } if k.Label != "" { @@ -312,6 +321,69 @@ func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context. } } +type displayFormat struct { + Text string `json:"text"` + Background string `json:"background,omitempty"` + TextColor string `json:"text_color,omitempty"` +} + +func looksLikeHex(s string) bool { + if len(s) != 7 && len(s) != 9 { + return false + } + if s[0] != '#' { + return false + } + for _, c := range s[1:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color) { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return &DisplayOutput{}, nil, nil + } + + var do DisplayOutput + var bgColor, fgColor color.Color + if err := json.Unmarshal([]byte(trimmed), &do); err == nil && (do.Text != "" || do.Background != "") { + if do.Background != "" { + if c, err := parseHexColor(do.Background); err == nil { + bgColor = c + } + } + if do.TextColor != "" { + if c, err := parseHexColor(do.TextColor); err == nil { + fgColor = c + } + } + do.Text = sanitizeText(do.Text) + return &do, bgColor, fgColor + } + + lines := strings.SplitN(trimmed, "\n", 2) + if len(lines) > 0 { + first := strings.TrimSpace(lines[0]) + if looksLikeHex(first) { + do.Background = first + if c, err := parseHexColor(first); err == nil { + bgColor = c + } + if len(lines) > 1 { + do.Text = sanitizeText(lines[1]) + } + return &do, bgColor, fgColor + } + } + + do.Text = sanitizeText(trimmed) + return &do, bgColor, fgColor +} + func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) { pm.mu.RLock() page := pm.pages[pm.active] @@ -336,23 +408,30 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str slog.Warn("display command failed", "key", idx, "error", execErr) } - text := sanitizeText(output) - if text == "" { + do, bgOverride, fgOverride := parseDisplayOutput(output) + fg := fgOverride + if fg == nil { + fg = color.White + } + + if do.Text == "" && bgOverride == nil { return } pm.displayMu.Lock() - pm.displayOutputs[idx] = text + pm.displayOutputs[idx] = do pm.displayMu.Unlock() maxLen := d.MaxLen if maxLen <= 0 { maxLen = 128 } - if len(text) > maxLen { - text = text[:maxLen] + if len(do.Text) > maxLen { + do.Text = do.Text[:maxLen] } + text := do.Text + faScale := 0.55 if kc.IconScale != nil { faScale = *kc.IconScale @@ -367,40 +446,45 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale) if err != nil { slog.Warn("load icon for display", "path", kc.Icon, "error", err) - textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black) + var bg color.Color = color.Black + if bgOverride != nil { + bg = bgOverride + } + textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg) if err := pm.deck.FillImage(idx, textImg); err != nil { slog.Warn("fill image", "error", err) } return } - if kc.Background != "" { + if kc.Background != "" && bgOverride == nil { if bg, err := parseHexColor(kc.Background); err == nil { img = applyBackground(img, bg) } else { slog.Warn("invalid background color", "value", kc.Background, "error", err) } + } else if bgOverride != nil { + if rgba, ok := bgOverride.(color.RGBA); ok { + img = applyBackground(img, rgba) + } } - composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize()) + composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg) if err := pm.deck.FillImage(idx, composite); err != nil { slog.Warn("fill image", "error", err) } return } - if kc.Background != "" { - bg, err := parseHexColor(kc.Background) - if err == nil { - textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg) - if err := pm.deck.FillImage(idx, textImg); err != nil { - slog.Warn("fill image", "error", err) - } - return + var bg color.Color = color.Black + if bgOverride != nil { + bg = bgOverride + } else if kc.Background != "" { + if c, err := parseHexColor(kc.Background); err == nil { + bg = c } - slog.Warn("invalid background color", "value", kc.Background, "error", err) } - textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), color.Black) + textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg) if err := pm.deck.FillImage(idx, textImg); err != nil { slog.Warn("fill image", "error", err) } @@ -457,7 +541,7 @@ func renderEmojiGlyph(r rune, size int, scale float64) image.Image { data := loadEmojiFontBytes() fnt, err := opentype.Parse(data) if err != nil { - return renderUnicodeText(string(r), fontSize, size, color.Black) + return renderUnicodeText(string(r), fontSize, size, color.Black, color.White) } face, err := opentype.NewFace(fnt, &opentype.FaceOptions{ Size: fontSize, @@ -465,7 +549,7 @@ func renderEmojiGlyph(r rune, size int, scale float64) image.Image { Hinting: font.HintingFull, }) if err != nil { - return renderUnicodeText(string(r), fontSize, size, color.Black) + return renderUnicodeText(string(r), fontSize, size, color.Black, color.White) } defer face.Close() @@ -523,11 +607,11 @@ func parseDisplayFace(size float64) (font.Face, error) { }) } -func renderUnicodeText(text string, fontSize float64, keySize int, bg color.Color) *image.RGBA { +func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.Color) *image.RGBA { rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) - if text == "" { + if text == "" || fg == nil { return rgba } @@ -552,7 +636,7 @@ func renderUnicodeText(text string, fontSize float64, keySize int, bg color.Colo d := &font.Drawer{ Dst: rgba, - Src: &image.Uniform{color.White}, + Src: &image.Uniform{fg}, Face: face, Dot: fixed.P(x, y), } @@ -561,7 +645,7 @@ func renderUnicodeText(text string, fontSize float64, keySize int, bg color.Colo return rgba } -func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int) *image.RGBA { +func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color) *image.RGBA { g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling)) rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) g.Draw(rgba, baseImg) @@ -573,7 +657,7 @@ func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64 barRect := image.Rect(0, keySize-barHeight, keySize, keySize) draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over) - if text == "" { + if text == "" || fg == nil { return rgba } @@ -598,7 +682,7 @@ func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64 d := &font.Drawer{ Dst: rgba, - Src: &image.Uniform{color.White}, + Src: &image.Uniform{fg}, Face: face, Dot: fixed.P(x, y), } diff --git a/render.go b/render.go index 472f9a2..9c69b71 100644 --- a/render.go +++ b/render.go @@ -57,7 +57,7 @@ func RenderKeyToImage(k *config.KeyConfig, keySize int) image.Image { if k.FontSize != nil { fontSize = *k.FontSize } - return renderUnicodeText(k.Label, fontSize, keySize, bg) + return renderUnicodeText(k.Label, fontSize, keySize, bg, color.White) } return blankImage(keySize, bg) } diff --git a/static/alpine.min.js b/static/alpine.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/static/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` - + @@ -56,6 +56,7 @@
@@ -113,6 +122,13 @@
+
+ + +
@@ -134,6 +150,25 @@ + +
+

Gesture Timing

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+

Auto-switch rules

@@ -250,8 +285,214 @@
+ +

Actions

+ + +
+ +
+ + + + + +
+
+ + +
+ + +
+ - @@ -277,112 +518,15 @@ placeholder="#rrggbb or #rrggbbaa" maxlength="9" style="flex:1;"> -
- - -
+
- - - - - - - - - - - - - - - -
@@ -397,7 +541,7 @@