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
This commit is contained in:
+172
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user