1
0

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:
Maksim Totmin 2026-06-17 00:09:48 +07:00
parent dfe6544686
commit 19e3040965
14 changed files with 1037 additions and 284 deletions

View File

@ -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 |
---

BIN
assets/mplus-1m-medium.ttf Normal file

Binary file not shown.

BIN
assets/mplus-1m-regular.ttf Normal file

Binary file not shown.

View File

@ -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
}
}

View File

@ -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()

View File

@ -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)
}

172
gesture.go Normal file
View File

@ -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()
}

View File

@ -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)
}
}
}
}

150
page.go
View File

@ -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),
}

View File

@ -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)
}

5
static/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,6 @@ document.addEventListener('alpine:init', () => {
activePage: '',
view: 'grid',
showAdvanced: false,
showDisplay: false,
toast: null,
displayOutputs: {},
isCapturingKey: false,
@ -15,15 +14,38 @@ document.addEventListener('alpine:init', () => {
// ── Edit state ──
editing: null,
editForm: {},
editForm: {
actions: {
tap: {},
long_press: {},
double_tap: {},
hold: { start: {}, end: {} },
},
},
// ── Sub-views ──
subView: null, // 'backups', 'settings', 'switcher', null
subView: null,
// ── Settings ──
settingsForm: {},
autoSwitchRules: [],
// ── Constants ──
actionIconMap: {
command: 'terminal',
builtin: 'cog',
script: 'file-code',
page: 'layer-group',
keyboard: 'keyboard',
},
actionTitleMap: {
command: 'Command',
builtin: 'Built-in',
script: 'Script',
page: 'Switch page',
keyboard: 'Keyboard shortcut',
},
// ── Init ──
async init() {
await this.loadConfig()
@ -68,9 +90,12 @@ document.addEventListener('alpine:init', () => {
syncSettings() {
this.settingsForm = {
brightness: this.config.devices?.[0]?.brightness || 75,
font: this.config.font || 'medium',
screensaver_enabled: this.config.screensaver?.enabled || false,
screensaver_idle: this.config.screensaver?.idle_seconds || 30,
screensaver_brightness: this.config.screensaver?.brightness || 10,
long_press_ms: this.config.timing?.long_press_ms || 500,
double_tap_ms: this.config.timing?.double_tap_ms || 300,
}
this.autoSwitchRules = [...(this.config.auto_switch || [])]
},
@ -97,22 +122,37 @@ document.addEventListener('alpine:init', () => {
const n = this.activeDeck.num_keys
for (let i = 0; i < n; i++) {
const kc = page.keys.find(k => k.index === i)
const dout = this.displayOutputs[i]
keys.push({
index: i,
...kc,
configured: !!kc,
hasDisplay: !!(kc?.display),
previewUrl: this.keyPreviewUrl(kc ? kc : {}),
hasAction: !!(kc?.actions?.length),
actionTypes: this.getActionTypes(kc?.actions || []),
displayBg: dout?.background || '',
displayText: dout?.text || '',
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
})
}
return keys
},
keyPreviewUrl(kc) {
getActionTypes(actions) {
const seen = {}
for (const a of actions) {
if (a.type && a.type !== 'none') {
seen[a.type] = true
}
}
return Object.keys(seen)
},
keyPreviewUrl(kc, dout) {
const k = kc || {}
let url = '/api/render?key_size=72'
if (k.icon) url += `&icon=${encodeURIComponent(k.icon)}`
const label = this.displayOutputs[k.index] || k.label
const label = dout?.text || this.displayOutputs[k.index]?.text || k.label
if (label) url += `&label=${encodeURIComponent(label)}`
if (k.icon_scale != null) url += `&icon_scale=${k.icon_scale}`
if (k.font_size != null) url += `&font_size=${k.font_size}`
@ -133,33 +173,66 @@ document.addEventListener('alpine:init', () => {
return ''
},
// ── Multi-action helpers ──
getCurrentAction() {
const t = this.editForm.activeTrigger || 'tap'
if (t === 'hold') {
return this.editForm.actions?.hold?.[this.editForm.holdPhase || 'start']
}
return this.editForm.actions?.[t]
},
hasCurrentAction(trigger) {
const a = this.editForm.actions?.[trigger]
return a && a.type && a.type !== 'none'
},
hasHoldAction(phase) {
const a = this.editForm.actions?.hold?.[phase]
return a && a.type && a.type !== 'none'
},
triggerLabel(trigger) {
return { tap: 'Tap', long_press: 'Long Press', double_tap: 'Double Tap', hold: 'Hold' }[trigger] || trigger
},
// ── Keyboard helpers (for current action) ──
get keyChips() {
if (!this.editForm.keys) return []
const a = this.getCurrentAction()
if (!a || !a.keys) return []
const map = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', super: 'Super', meta: 'Super' }
return this.editForm.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
return a.keys.split('+').map(k => map[k] || k.charAt(0).toUpperCase() + k.slice(1))
},
toggleMod(mod) {
if (mod === 'ctrl') this.editForm.modCtrl = !this.editForm.modCtrl
else if (mod === 'alt') this.editForm.modAlt = !this.editForm.modAlt
else if (mod === 'shift') this.editForm.modShift = !this.editForm.modShift
else if (mod === 'super') this.editForm.modSuper = !this.editForm.modSuper
const a = this.getCurrentAction()
if (!a) return
if (mod === 'ctrl') a.modCtrl = !a.modCtrl
else if (mod === 'alt') a.modAlt = !a.modAlt
else if (mod === 'shift') a.modShift = !a.modShift
else if (mod === 'super') a.modSuper = !a.modSuper
this.rebuildKeys()
},
rebuildKeys() {
const a = this.getCurrentAction()
if (!a) return
const mods = []
if (this.editForm.modCtrl) mods.push('ctrl')
if (this.editForm.modAlt) mods.push('alt')
if (this.editForm.modShift) mods.push('shift')
if (this.editForm.modSuper) mods.push('super')
if (this.editForm.mainKey) mods.push(this.editForm.mainKey)
this.editForm.keys = mods.join('+')
if (a.modCtrl) mods.push('ctrl')
if (a.modAlt) mods.push('alt')
if (a.modShift) mods.push('shift')
if (a.modSuper) mods.push('super')
if (a.mainKey) mods.push(a.mainKey)
a.keys = mods.join('+')
},
startKeyCapture() {
this.isCapturingKey = true
this.$nextTick(() => this.$refs.keyCapture?.focus())
this.$nextTick(() => {
this.$el.querySelectorAll('.key-capture').forEach(el => {
if (el.offsetParent !== null) el.focus()
})
})
},
onCaptureKey(e) {
@ -177,7 +250,8 @@ document.addEventListener('alpine:init', () => {
key = e.key.length === 1 ? e.key.toLowerCase() : e.key
}
if (!key) return
this.editForm.mainKey = key.toLowerCase()
const a = this.getCurrentAction()
if (a) a.mainKey = key.toLowerCase()
this.isCapturingKey = false
this.rebuildKeys()
},
@ -189,6 +263,10 @@ document.addEventListener('alpine:init', () => {
},
// ── Edit Key ──
defaultAction(trigger) {
return { trigger, type: 'none', command: '', builtin: '', script: '', page: '', keys: '', background: true, modCtrl: false, modAlt: false, modShift: false, modSuper: false, mainKey: '' }
},
editKey(idx) {
this.isCapturingKey = false
const page = this.currentPage
@ -196,12 +274,36 @@ document.addEventListener('alpine:init', () => {
const kc = page.keys.find(k => k.index === idx)
this.editing = idx
this.showAdvanced = false
this.showDisplay = !!(kc?.display)
const action = kc?.action || {}
const existing = kc?.actions || []
const parts = (action.keys || '').toLowerCase().split('+')
const mainKey = parts.length > 0 ? parts.pop() : ''
const tap = existing.find(a => a.trigger === 'tap') || null
const longPress = existing.find(a => a.trigger === 'long_press') || null
const doubleTap = existing.find(a => a.trigger === 'double_tap') || null
const holdStart = existing.find(a => a.trigger === 'hold_start') || null
const holdEnd = existing.find(a => a.trigger === 'hold_end') || null
const build = (src, trigger) => {
const def = this.defaultAction(trigger)
if (!src) return def
const parts = (src.keys || '').toLowerCase().split('+')
const main = parts.length > 0 ? parts.pop() : ''
return {
...def,
type: src.type || 'none',
command: src.command || '',
builtin: src.builtin || '',
script: src.script || '',
page: src.page || '',
keys: src.keys || '',
background: src.background ?? true,
modCtrl: parts.includes('ctrl'),
modAlt: parts.includes('alt'),
modShift: parts.includes('shift'),
modSuper: parts.includes('super'),
mainKey: main,
}
}
this.editForm = {
icon: kc?.icon || '',
@ -209,18 +311,17 @@ document.addEventListener('alpine:init', () => {
icon_scale: kc?.icon_scale ?? 0.55,
font_size: kc?.font_size ?? 16,
bg_color: kc?.background || '',
action_type: action.type || 'command',
command: action.command || '',
builtin: action.builtin || '',
script: action.script || '',
page: action.page || '',
keys: action.keys || '',
modCtrl: parts.includes('ctrl'),
modAlt: parts.includes('alt'),
modShift: parts.includes('shift'),
modSuper: parts.includes('super'),
mainKey: mainKey,
background: action.background ?? true,
activeTrigger: 'tap',
holdPhase: 'start',
actions: {
tap: build(tap, 'tap'),
long_press: build(longPress, 'long_press'),
double_tap: build(doubleTap, 'double_tap'),
hold: {
start: build(holdStart, 'hold_start'),
end: build(holdEnd, 'hold_end'),
},
},
display_mode: kc?.display ? (kc.display.command ? 'command' : 'script') : 'none',
display_command: kc?.display?.command || '',
display_script: kc?.display?.script || '',
@ -251,7 +352,6 @@ document.addEventListener('alpine:init', () => {
const dm = this.editForm.display_mode
const hasDisplay = dm !== 'none'
const actionType = this.editForm.action_type
if (dm === 'command' && !this.editForm.display_command) {
this.showToast('Display command is required', 'error')
@ -262,49 +362,47 @@ document.addEventListener('alpine:init', () => {
return
}
if (actionType && actionType !== 'none') {
if (actionType === 'command' && !this.editForm.command) {
if (!hasDisplay) { this.showToast('Command is required', 'error'); return }
// Validate and collect actions
const actions = []
const checkAction = (a) => {
if (!a || !a.type || a.type === 'none') return
if (a.type === 'command' && !a.command) return
if (a.type === 'script' && !a.script) return
if (a.type === 'page' && !a.page) return
if (a.type === 'keyboard' && !a.keys) return
const entry = {
trigger: a.trigger,
type: a.type,
command: a.type === 'command' ? a.command : undefined,
builtin: a.type === 'builtin' ? a.builtin : undefined,
script: a.type === 'script' ? a.script : undefined,
page: a.type === 'page' ? a.page : undefined,
keys: a.type === 'keyboard' ? a.keys : undefined,
background: a.background || undefined,
}
if (actionType === 'script' && !this.editForm.script) {
if (!hasDisplay) { this.showToast('Script path is required', 'error'); return }
}
if (actionType === 'page' && !this.editForm.page) {
this.showToast('Target page is required', 'error')
return
}
if (actionType === 'keyboard' && !this.editForm.keys) {
if (!hasDisplay) { this.showToast('Key combination is required', 'error'); return }
// Clean undefined
for (const k of Object.keys(entry)) {
if (entry[k] === undefined) delete entry[k]
}
actions.push(entry)
}
checkAction(this.editForm.actions.tap)
checkAction(this.editForm.actions.long_press)
checkAction(this.editForm.actions.double_tap)
checkAction(this.editForm.actions.hold.start)
checkAction(this.editForm.actions.hold.end)
const kc = {
index: this.editing,
icon: this.editForm.icon,
label: this.editForm.label,
icon_scale: this.editForm.icon_scale,
font_size: this.editForm.font_size,
background: this.editForm.bg_color || '',
icon: this.editForm.icon || undefined,
label: this.editForm.label || undefined,
icon_scale: this.editForm.icon_scale !== 0.55 ? this.editForm.icon_scale : undefined,
font_size: this.editForm.font_size !== 16 ? this.editForm.font_size : undefined,
background: this.editForm.bg_color || undefined,
}
if (actionType && actionType !== 'none') {
if (actionType === 'command' && this.editForm.command) {
kc.action = { type: 'command', command: this.editForm.command, background: this.editForm.background }
} else if (actionType === 'builtin') {
kc.action = { type: 'builtin', builtin: this.editForm.builtin }
} else if (actionType === 'script' && this.editForm.script) {
kc.action = { type: 'script', script: this.editForm.script }
} else if (actionType === 'page' && this.editForm.page) {
kc.action = { type: 'page', page: this.editForm.page }
} else if (actionType === 'keyboard' && this.editForm.keys) {
kc.action = { type: 'keyboard', keys: this.editForm.keys }
} else if (!hasDisplay) {
kc.action = null
} else {
kc.action = null
}
} else {
kc.action = null
if (actions.length > 0) {
kc.actions = actions
}
if (hasDisplay) {
@ -317,8 +415,6 @@ document.addEventListener('alpine:init', () => {
if (this.editForm.display_timeout) {
kc.display.timeout = this.editForm.display_timeout
}
} else {
kc.display = null
}
const existingIdx = page.keys.findIndex(k => k.index === this.editing)
@ -414,10 +510,11 @@ document.addEventListener('alpine:init', () => {
this.config.default_page = newName.trim()
}
// Update page references
for (const p of this.pages) {
for (const k of p.keys) {
if (k.action?.page === oldName) k.action.page = newName.trim()
for (const a of (k.actions || [])) {
if (a.page === oldName) a.page = newName.trim()
}
}
}
for (const r of (this.config.auto_switch || [])) {
@ -458,11 +555,16 @@ document.addEventListener('alpine:init', () => {
this.config.devices = [{ serial: '', brightness: 75 }]
}
this.config.devices[0].brightness = parseInt(this.settingsForm.brightness)
this.config.font = this.settingsForm.font || 'medium'
this.config.screensaver = {
enabled: this.settingsForm.screensaver_enabled,
idle_seconds: parseInt(this.settingsForm.screensaver_idle) || 30,
brightness: parseInt(this.settingsForm.screensaver_brightness) || 10,
}
this.config.timing = {
long_press_ms: parseInt(this.settingsForm.long_press_ms) || 500,
double_tap_ms: parseInt(this.settingsForm.double_tap_ms) || 300,
}
this.config.auto_switch = this.autoSwitchRules
await this.saveConfig()
},
@ -569,7 +671,6 @@ document.addEventListener('alpine:init', () => {
},
backupNameTime(name) {
// Extract date from config.YYYY-MM-DDTHH-MM-SS.json
const m = name.match(/config\.(.+)\.json/)
if (!m) return name
return m[1].replace('T', ' ')

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StreamDeck</title>
<script src="/static/app.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script defer src="/static/alpine.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
<link rel="stylesheet" href="/static/styles.css">
</head>
@ -56,6 +56,7 @@
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', 1fr)'">
<template x-for="key in gridKeys" :key="key.index">
<button class="key-btn"
:style="key.displayBg ? { backgroundColor: key.displayBg } : {}"
:class="{ empty: !key.configured }"
@click="editKey(key.index)">
<img x-show="key.configured && key.background"
@ -68,7 +69,15 @@
style="font-size:12px;color:#fff;opacity:0.75;text-align:center;line-height:1.2;padding:4px;"
x-text="key.label"></span>
<span x-show="!key.configured" style="color:var(--text-muted);font-size:24px;opacity:0.5;">+</span>
<i x-show="key.action?.type === 'keyboard'" class="fas fa-keyboard display-indicator" title="Keyboard shortcut" style="left:4px;right:auto;"></i>
<div class="action-icons" x-show="key.hasAction">
<template x-for="(t, ti) in key.actionTypes.slice(0,3)" :key="ti">
<i :class="'fas fa-' + actionIconMap[t] + ' action-icon type-' + t"
:title="actionTitleMap[t]"></i>
</template>
<span class="action-icon" x-show="key.actionTypes.length > 3"
style="background:var(--text-muted);font-size:7px;font-weight:700;padding:2px 3px;"
x-text="'+' + (key.actionTypes.length - 3)"></span>
</div>
<i x-show="key.hasDisplay" class="fas fa-arrows-rotate display-indicator" title="Periodic display"></i>
</button>
</template>
@ -113,6 +122,13 @@
<span class="value" x-text="settingsForm.brightness + '%'"></span>
</div>
</div>
<div class="form-group">
<label>Default Font</label>
<select x-model="settingsForm.font">
<option value="medium">Medium</option>
<option value="regular">Regular</option>
</select>
</div>
</div>
<!-- Screensaver -->
@ -134,6 +150,25 @@
</div>
</div>
<!-- Gesture Timing -->
<div class="panel-section">
<h4>Gesture Timing</h4>
<div class="form-group">
<label>Long press threshold</label>
<div class="slider-group">
<input type="range" min="200" max="1500" step="50" x-model.number="settingsForm.long_press_ms">
<span class="value" x-text="settingsForm.long_press_ms + 'ms'"></span>
</div>
</div>
<div class="form-group">
<label>Double tap threshold</label>
<div class="slider-group">
<input type="range" min="100" max="600" step="25" x-model.number="settingsForm.double_tap_ms">
<span class="value" x-text="settingsForm.double_tap_ms + 'ms'"></span>
</div>
</div>
</div>
<!-- Auto-switch -->
<div class="panel-section">
<h4>Auto-switch rules</h4>
@ -250,8 +285,214 @@
<input type="text" x-model="editForm.label" placeholder="Optional text">
</div>
<!-- Actions section -->
<h4 style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;margin-top:12px;">Actions</h4>
<!-- Action tabs -->
<div class="action-tabs">
<template x-for="t in ['tap','long_press','double_tap','hold']" :key="t">
<button class="action-tab"
:class="{ active: editForm.activeTrigger === t, configured: (t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t)) }"
@click="editForm.activeTrigger = t">
<span x-text="triggerLabel(t)"></span>
<span class="tab-check" x-show="(t === 'hold' ? (hasHoldAction('start') || hasHoldAction('end')) : hasCurrentAction(t))"></span>
</button>
</template>
</div>
<!-- Non-hold trigger panes -->
<template x-for="t in ['tap','long_press','double_tap']" :key="t">
<div x-show="editForm.activeTrigger === t">
<div class="form-group">
<select x-model="editForm.actions[t].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
</div>
<template x-if="editForm.actions[t].type === 'command'">
<div>
<div class="form-group">
<input type="text" x-model="editForm.actions[t].command" placeholder="firefox">
</div>
<div class="checkbox-row">
<input type="checkbox" :id="'bg-cb-' + t" x-model="editForm.actions[t].background">
<label :for="'bg-cb-' + t">Run in background</label>
</div>
</div>
</template>
<template x-if="editForm.actions[t].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions[t].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
</div>
</template>
<template x-if="editForm.actions[t].type === 'script'">
<div class="form-group">
<input type="text" x-model="editForm.actions[t].script" placeholder="/path/to/script.sh">
</div>
</template>
<template x-if="editForm.actions[t].type === 'page'">
<div class="form-group">
<select x-model="editForm.actions[t].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
</template>
<template x-if="editForm.actions[t].type === 'keyboard'">
<div class="form-group">
<label>Key combination</label>
<div class="mod-row">
<button class="mod-btn" :class="{ active: editForm.actions[t].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
<button class="mod-btn" :class="{ active: editForm.actions[t].modSuper }" @click="toggleMod('super')" type="button">Super</button>
</div>
<div class="key-capture"
x-ref="keyCapture"
:class="{ capturing: isCapturingKey }"
tabindex="0"
@keydown.prevent="onCaptureKey"
@blur="isCapturingKey = false"
@click="startKeyCapture">
<span x-show="!isCapturingKey && editForm.actions[t].mainKey" class="captured-key" x-text="displayKey(editForm.actions[t].mainKey)"></span>
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
<span x-show="!isCapturingKey && !editForm.actions[t].mainKey" class="capture-placeholder">Click to capture</span>
</div>
<div class="key-chips" x-show="editForm.actions[t].keys" style="margin-top:8px;">
<template x-for="(chip, ki) in keyChips" :key="ki">
<span class="key-chip" x-text="chip"></span>
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
</template>
</div>
</div>
</template>
</div>
</template>
<!-- Hold pane -->
<div x-show="editForm.activeTrigger === 'hold'">
<div class="hold-subtabs">
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'start' }"
@click="editForm.holdPhase = 'start'">On press &amp; hold</button>
<button class="hold-stab" :class="{ active: editForm.holdPhase === 'end' }"
@click="editForm.holdPhase = 'end'">On release</button>
</div>
<template x-for="phase in ['start','end']" :key="phase">
<div x-show="editForm.holdPhase === phase">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Switch page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
</div>
<template x-if="editForm.actions.hold[phase].type === 'command'">
<div>
<div class="form-group">
<input type="text" x-model="editForm.actions.hold[phase].command" placeholder="firefox">
</div>
<div class="checkbox-row">
<input type="checkbox" :id="'hold-bg-' + phase" x-model="editForm.actions.hold[phase].background">
<label :for="'hold-bg-' + phase">Run in background</label>
</div>
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'builtin'">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'script'">
<div class="form-group">
<input type="text" x-model="editForm.actions.hold[phase].script" placeholder="/path/to/script.sh">
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'page'">
<div class="form-group">
<select x-model="editForm.actions.hold[phase].page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
</template>
<template x-if="editForm.actions.hold[phase].type === 'keyboard'">
<div class="form-group">
<label>Key combination</label>
<div class="mod-row">
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modShift }" @click="toggleMod('shift')" type="button">Shift</button>
<button class="mod-btn" :class="{ active: editForm.actions.hold[phase].modSuper }" @click="toggleMod('super')" type="button">Super</button>
</div>
<div class="key-capture"
x-ref="keyCapture"
:class="{ capturing: isCapturingKey }"
tabindex="0"
@keydown.prevent="onCaptureKey"
@blur="isCapturingKey = false"
@click="startKeyCapture">
<span x-show="!isCapturingKey && editForm.actions.hold[phase].mainKey" class="captured-key" x-text="displayKey(editForm.actions.hold[phase].mainKey)"></span>
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
<span x-show="!isCapturingKey && !editForm.actions.hold[phase].mainKey" class="capture-placeholder">Click to capture</span>
</div>
<div class="key-chips" x-show="editForm.actions.hold[phase].keys" style="margin-top:8px;">
<template x-for="(chip, ki) in keyChips" :key="ki">
<span class="key-chip" x-text="chip"></span>
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Advanced toggle -->
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced">
<button class="collapse-toggle" :class="{ open: showAdvanced }" @click="showAdvanced = !showAdvanced" style="margin-top:8px;">
<i class="fas fa-chevron-right"></i> More
</button>
@ -277,112 +518,15 @@
placeholder="#rrggbb or #rrggbbaa" maxlength="9"
style="flex:1;">
<button class="icon-btn" style="width:24px;height:24px;font-size:10px;flex-shrink:0;"
@click="editForm.background = ''" title="Clear"
x-show="editForm.background !== ''">
@click="editForm.bg_color = ''" title="Clear"
x-show="editForm.bg_color !== ''">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<div class="form-group">
<label>Action type</label>
<select x-model="editForm.action_type">
<option value="none">None</option>
<option value="command">Command</option>
<option value="builtin">Builtin</option>
<option value="script">Script</option>
<option value="page">Page</option>
<option value="keyboard">Keyboard shortcut</option>
</select>
</div>
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
<!-- Command fields -->
<template x-if="editForm.action_type === 'command'">
<div>
<div class="form-group">
<label>Command</label>
<input type="text" x-model="editForm.command" placeholder="firefox">
</div>
<div class="checkbox-row">
<input type="checkbox" id="bg-cb" x-model="editForm.bg_color">
<label for="bg-cb">Run in background</label>
</div>
</div>
</template>
<template x-if="editForm.action_type === 'builtin'">
<div class="form-group">
<label>Builtin action</label>
<select x-model="editForm.builtin">
<option value="">-- select --</option>
<option value="volume_up">Volume Up</option>
<option value="volume_down">Volume Down</option>
<option value="volume_mute">Volume Mute</option>
<option value="brightness_up">Brightness Up</option>
<option value="brightness_down">Brightness Down</option>
<option value="media_play_pause">Play/Pause</option>
<option value="media_next">Next Track</option>
<option value="media_prev">Previous Track</option>
<option value="media_stop">Stop</option>
</select>
</div>
</template>
<template x-if="editForm.action_type === 'script'">
<div class="form-group">
<label>Script path</label>
<input type="text" x-model="editForm.script" placeholder="/path/to/script.sh">
</div>
</template>
<template x-if="editForm.action_type === 'page'">
<div class="form-group">
<label>Target page</label>
<select x-model="editForm.page">
<option value="">-- select --</option>
<template x-for="p in pages" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
</div>
</template>
<template x-if="editForm.action_type === 'keyboard'">
<div class="form-group">
<label>Key combination</label>
<div class="mod-row">
<button class="mod-btn" :class="{ active: editForm.modCtrl }" @click="toggleMod('ctrl')" type="button">Ctrl</button>
<button class="mod-btn" :class="{ active: editForm.modAlt }" @click="toggleMod('alt')" type="button">Alt</button>
<button class="mod-btn" :class="{ active: editForm.modShift }" @click="toggleMod('shift')" type="button">Shift</button>
<button class="mod-btn" :class="{ active: editForm.modSuper }" @click="toggleMod('super')" type="button">Super</button>
</div>
<div class="key-capture"
x-ref="keyCapture"
:class="{ capturing: isCapturingKey }"
tabindex="0"
@keydown.prevent="onCaptureKey"
@blur="isCapturingKey = false"
@click="startKeyCapture">
<span x-show="!isCapturingKey && editForm.mainKey" class="captured-key" x-text="displayKey(editForm.mainKey)"></span>
<span x-show="isCapturingKey" class="capture-hint">Press any key...</span>
<span x-show="!isCapturingKey && !editForm.mainKey" class="capture-placeholder">Click to capture</span>
</div>
<div class="key-chips" x-show="editForm.keys" style="margin-top:8px;">
<template x-for="(chip, ki) in keyChips" :key="ki">
<span class="key-chip" x-text="chip"></span>
<span x-show="ki < keyChips.length - 1" class="key-plus">+</span>
</template>
</div>
</div>
</template>
</div>
<!-- Display section -->
<button class="collapse-toggle" :class="{ open: showDisplay }" @click="showDisplay = !showDisplay" style="margin-top:8px;">
<i class="fas fa-chevron-right"></i> Display (periodic output)
</button>
<div x-show="showDisplay" x-transition>
<div class="form-group">
<label>Display mode</label>
<div class="tab-row">
@ -397,7 +541,7 @@
<template x-if="editForm.display_mode === 'command'">
<div class="form-group">
<label>Shell command</label>
<label>Command</label>
<input type="text" x-model="editForm.display_command"
placeholder="curl -s http://...">
</div>

View File

@ -596,7 +596,7 @@ main {
color: var(--accent);
}
/* ── Display indicator on grid keys ── */
/* ── Indicators on grid keys ── */
.display-indicator {
position: absolute;
bottom: 4px;
@ -607,6 +607,23 @@ main {
pointer-events: none;
}
.action-icons {
position: absolute;
top: 4px;
left: 4px;
display: flex;
gap: 3px;
pointer-events: none;
flex-wrap: wrap;
max-width: calc(100% - 6px);
}
.action-icon {
font-size: 7px;
color: var(--accent);
opacity: 0.6;
line-height: 1;
}
/* ── Tab buttons ── */
.tab-row {
display: flex;
@ -721,3 +738,72 @@ main {
font-size: 11px;
margin: 0 2px;
}
/* ── Action tabs ── */
.action-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.action-tab {
flex: 1;
padding: 5px 2px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 9px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
text-align: center;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
line-height: 1.3;
}
.action-tab:hover { color: var(--text); border-color: var(--border-light); }
.action-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.action-tab.configured {
border-color: var(--success);
color: var(--success);
}
.action-tab.active.configured {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tab-check { font-size: 8px; }
/* ── Hold sub-tabs ── */
.hold-subtabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.hold-stab {
flex: 1;
padding: 4px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
text-align: center;
transition: all var(--transition);
}
.hold-stab:hover { color: var(--text); border-color: var(--border-light); }
.hold-stab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}