From 46d0d622cac6f3f89d49c37eefaeebf2d10ca2a2 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 17:59:08 -0500 Subject: [PATCH] Redesign hotkey settings as press-to-record keycap widgets Replace the raw accelerator-string text inputs in Settings > Capture with a polished "click and press a key combination" control that renders the shortcut as keycap chips with a clear button. --- app/renderer/dialogs.js | 149 +++++++++++++++++++++++++++++++++++++++- app/renderer/style.css | 71 +++++++++++++++++++ 2 files changed, 218 insertions(+), 2 deletions(-) diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index de99f9d..272c811 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -24,6 +24,151 @@ function makeSelect(value, options) { ); } +const HOTKEY_LABELS = { + CommandOrControl: 'Ctrl', + Control: 'Ctrl', + Command: 'Cmd', + Alt: 'Alt', + Shift: 'Shift', + Super: 'Super', + Up: '↑', + Down: '↓', + Left: '←', + Right: '→', + Space: 'Space', + Escape: 'Esc', + Return: 'Enter', +}; + +function hotkeyLabel(part) { + return HOTKEY_LABELS[part] || part; +} + +/** Turn a keydown event into accelerator parts, or null if it's a bare modifier. */ +function hotkeyFromEvent(e) { + const modifiers = []; + if (e.ctrlKey || e.metaKey) modifiers.push('CommandOrControl'); + if (e.altKey) modifiers.push('Alt'); + if (e.shiftKey) modifiers.push('Shift'); + + const { key } = e; + if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') { + return { modifiers, key: null }; + } + + const SPECIAL_KEYS = { + ' ': 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Escape', + Enter: 'Return', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + }; + + let keyName = SPECIAL_KEYS[key]; + if (!keyName) { + if (/^[a-zA-Z0-9]$/.test(key)) keyName = key.toUpperCase(); + else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) keyName = key; + else return { modifiers, key: null }; + } + return { modifiers, key: keyName }; +} + +/** + * A "press to record" shortcut field, styled like a row of keycaps. Exposes + * a `.value` getter/setter holding an Electron accelerator string (e.g. + * "CommandOrControl+Shift+1"), so it slots in like a text input. + */ +function makeHotkeyInput(value = '') { + let current = value || ''; + + const keys = el('div.hotkey-keys'); + const clearBtn = el('button.hotkey-clear', { + type: 'button', + title: 'Clear shortcut', + onClick: (e) => { + e.stopPropagation(); + current = ''; + render(); + }, + }, '×'); + + const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: 'Click, then press a key combination' }, + keys, clearBtn); + + function renderKeys(parts) { + keys.replaceChildren(); + parts.forEach((part, i) => { + if (i > 0) keys.append(el('span.hotkey-sep', {}, '+')); + keys.append(el('kbd', {}, hotkeyLabel(part))); + }); + } + + function render() { + if (wrap.classList.contains('recording')) { + keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Press a key combination…')); + clearBtn.hidden = true; + return; + } + if (!current) { + keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Click to set shortcut')); + clearBtn.hidden = true; + return; + } + renderKeys(current.split('+').filter(Boolean)); + clearBtn.hidden = false; + } + + // While recording, a window-level capturing listener intercepts keydown + // before the modal's own Escape handler can see it (capture order is + // window -> document -> ... -> target), so Escape cancels recording + // instead of closing the whole dialog. + let recordingKeyHandler = null; + + wrap.addEventListener('focus', () => { + wrap.classList.add('recording'); + render(); + recordingKeyHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + wrap.blur(); + return; + } + const { modifiers, key } = hotkeyFromEvent(e); + if (key) { + current = [...modifiers, key].join('+'); + wrap.blur(); + } else if (modifiers.length) { + renderKeys([...modifiers, '…']); + } else { + render(); + } + }; + window.addEventListener('keydown', recordingKeyHandler, true); + }); + wrap.addEventListener('blur', () => { + wrap.classList.remove('recording'); + if (recordingKeyHandler) { + window.removeEventListener('keydown', recordingKeyHandler, true); + recordingKeyHandler = null; + } + render(); + }); + + Object.defineProperty(wrap, 'value', { + get() { return current; }, + set(v) { current = v || ''; render(); }, + }); + + render(); + return wrap; +} + async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) { return new Promise((resolve) => { const field = multiline @@ -160,8 +305,8 @@ function showSettingsDialog({ { value: 'region', label: 'Region' }, ]); const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) }); - const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text'); - const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text'); + const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || ''); + const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || ''); const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) }); const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 }); const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) }); diff --git a/app/renderer/style.css b/app/renderer/style.css index 4e2bcca..6842b29 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -599,6 +599,77 @@ fieldset legend { } .placeholder-row input { width: 100%; } +.hotkey-input { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 190px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-2); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} +.hotkey-input:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); +} +.hotkey-input:focus-visible, +.hotkey-input.recording { + outline: none; + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--panel-2)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} +.hotkey-keys { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + flex: 1; +} +.hotkey-keys kbd { + min-width: 22px; + justify-content: center; + padding: 3px 7px; + font-size: 11px; + font-weight: 650; + background: var(--panel-solid); + box-shadow: 0 1px 0 0 color-mix(in srgb, var(--text) 8%, transparent); +} +.hotkey-sep { + color: var(--muted); + font-size: 11px; +} +.hotkey-placeholder { + color: var(--muted); + font-size: 12px; +} +.hotkey-clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 14px; + line-height: 1; + cursor: pointer; +} +.hotkey-clear:hover { + background: var(--panel-solid); + color: var(--text); +} +.hotkey-clear[hidden] { + display: none; +} + .quick-actions { width: min(760px, 92vw); display: flex;