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:
Vendored
+5
File diff suppressed because one or more lines are too long
+178
-77
@@ -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', ' ')
|
||||
|
||||
+248
-104
@@ -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 & 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>
|
||||
|
||||
+87
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user