diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index ecfb1cc..598ba74 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -531,6 +531,16 @@ const SCENES = [ await sleep(500) } }, + { + name: 'settings-keybinds', + setup: async (p) => { + await openChannel(p, '#general') + await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) + await sleep(300) + await p.evaluate(click('.settings-rail-item', 'Keybinds')) + await sleep(500) + } + }, { name: 'settings-accounts', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('accounts')`); await sleep(400) } }, { name: 'discord-guild', setup: discord(false) }, { diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 06a2888..0b3cd14 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -9946,3 +9946,33 @@ html[data-reduce-motion] *::after { animation-iteration-count: 1 !important; scroll-behavior: auto !important; } + + +/* --- keybinds ------------------------------------------------------------- */ +.keys { + display: inline-flex; + gap: var(--spacing-xxs); + flex: none; +} + +.key { + min-width: 24px; + padding: 2px var(--spacing-s); + border: 1px solid var(--outline-medium); + border-bottom-width: 2px; + border-radius: var(--corner-radius-small); + background: var(--surface-container-high); + color: var(--surface-text); + font-family: inherit; + font-size: var(--font-size-small); + text-align: center; +} + +.hotkey-recorder { + min-width: 140px; +} + +.hotkey-recorder.recording { + border-color: var(--primary); + color: var(--primary); +} diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index 6cc1f5f..a9dd23d 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -13,6 +13,7 @@ import { } from './controls' import { useChat, usePref, useStore } from '../../state/hooks' import { Icon } from '../Icon' +import { acceleratorLabel, acceleratorOf } from '../../lib/accelerator' import { AccountsPanel } from '../AccountsPanel' import { DownloadsPanel } from '../DownloadsPanel' import { humanBytes } from '../../lib/util' @@ -117,6 +118,7 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser { id: 'general', label: 'General', group: 'App' }, { id: 'appearance', label: 'Appearance', group: 'App' }, { id: 'notifications', label: 'Notifications', group: 'App' }, + { id: 'keybinds', label: 'Keybinds', group: 'App' }, // One page per network, each only while there is an account on it. { id: 'irc', label: 'IRC', group: 'Networks', service: 'irc' }, { id: 'sneedchat', label: 'Sneedchat', group: 'Networks', service: 'sneedchat' }, @@ -223,6 +225,8 @@ function Page({ id }: { id: string }): JSX.Element | null { return case 'notifications': return + case 'keybinds': + return case 'irc': return case 'sneedchat': @@ -581,6 +585,131 @@ function StorageSettings(): JSX.Element { ) } +/** One key cap, or several for a combination. */ +function Keys({ keys }: { keys: string[] }): JSX.Element { + return ( + + {keys.map((k, i) => ( + + {k} + + ))} + + ) +} + +/** + * The global show/hide hotkey, recorded by pressing it rather than typed as an + * Electron accelerator. Clicking starts listening; the first combination with + * a modifier in it is taken, and Escape gives up. + */ +function HotkeyRecorder(): JSX.Element { + const [value, setValue] = usePref('hotkey.toggle', 'Control+Shift+M') + const [recording, setRecording] = useState(false) + + useEffect(() => { + if (!recording) return + const onKey = (e: KeyboardEvent): void => { + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape' && !e.ctrlKey && !e.altKey && !e.metaKey) { + setRecording(false) + return + } + const accelerator = acceleratorOf(e) + if (!accelerator) return + setValue(accelerator) + setRecording(false) + } + // Captured, ahead of the app's own shortcuts: what is pressed now is the answer. + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [recording, setValue]) + + return ( +
+
+
Show or hide moho
+
+ Works from any program. Wayland compositors do not let an ordinary app grab keys globally, so this only takes + effect under X11; on Wayland, bind your compositor to focus moho instead. +
+
+ + {value && !recording && ( + + )} +
+ ) +} + +/** Every shortcut the app has, by what it is for. The one that can be changed is at the end. */ +const SHORTCUTS: { title: string; rows: { keys: string[]; what: string }[] }[] = [ + { + title: 'Moving around', + rows: [ + { keys: ['Ctrl', 'K'], what: 'Jump to a conversation: search across every account' }, + { keys: ['Alt', '↑ / ↓'], what: 'The previous or next conversation in the list' }, + { keys: ['Alt', 'Shift', '↑ / ↓'], what: 'The previous or next one with something unread' }, + { keys: ['Ctrl', ','], what: 'Open Settings' }, + { keys: ['Esc'], what: 'Mark this conversation read and go to the latest message' }, + { keys: ['Shift', 'Esc'], what: 'Mark every conversation on this server read' } + ] + }, + { + title: 'Writing', + rows: [ + { keys: ['Enter'], what: 'Send' }, + { keys: ['Shift', 'Enter'], what: 'A new line' }, + { keys: ['↑'], what: 'In an empty box, edit your last message' }, + { keys: ['Tab'], what: 'Complete the name being typed; again for the next one' }, + { keys: ['Ctrl', 'B / I / U'], what: 'Bold, italic or underline, where the service has it' }, + { keys: ['@'], what: 'Name somebody: arrows to choose, Tab or Enter to take it' }, + { keys: [':'], what: 'An emoji by name, such as :fire' }, + { keys: ['/'], what: 'A command, at the start of a message' }, + { keys: ['Esc'], what: 'Put a reply down, or close a list that is open' } + ] + }, + { + title: 'Emoji picker and viewer', + rows: [ + { keys: ['↑ ↓ ← →'], what: 'Walk the picker; Enter picks, and picks the top result while searching' }, + { keys: ['← / →'], what: 'The previous or next picture in the viewer' }, + { keys: ['+', '-', '0'], what: 'Zoom the viewer in, out, and back to fit' } + ] + } +] + +/** Every shortcut, and the one that can be changed. */ +function KeybindSettings(): JSX.Element { + return ( + <> + {SHORTCUTS.map((group) => ( + + {group.rows.map((row) => ( +
+
{row.what}
+ +
+ ))} +
+ ))} + + + + + ) +} + /** * What the app does to get your attention. Which conversations may is decided * where it always was - muting a channel or a server from its menu - and these @@ -768,18 +897,6 @@ function GeneralSettings(): JSX.Element { - - - - ) } diff --git a/src/renderer/src/lib/accelerator.test.ts b/src/renderer/src/lib/accelerator.test.ts new file mode 100644 index 0000000..950a34c --- /dev/null +++ b/src/renderer/src/lib/accelerator.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { acceleratorLabel, acceleratorOf } from './accelerator' + +const press = (key: string, code: string, mods: Partial> = {}) => ({ + key, + code, + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + ...mods +}) + +describe('acceleratorOf', () => { + it('writes the usual one the way Electron reads it', () => { + expect(acceleratorOf(press('M', 'KeyM', { ctrlKey: true, shiftKey: true }))).toBe('Control+Shift+M') + }) + it('waits while only modifiers are down', () => { + expect(acceleratorOf(press('Control', 'ControlLeft', { ctrlKey: true }))).toBeNull() + }) + it('refuses a bare key, which would be taken from every other program', () => { + expect(acceleratorOf(press('m', 'KeyM'))).toBeNull() + }) + it('names the key and not what shift makes of it', () => { + expect(acceleratorOf(press('!', 'Digit1', { ctrlKey: true, shiftKey: true }))).toBe('Control+Shift+1') + }) + it('knows arrows, function keys and the meta key', () => { + expect(acceleratorOf(press('ArrowUp', 'ArrowUp', { altKey: true }))).toBe('Alt+Up') + expect(acceleratorOf(press('F9', 'F9', { ctrlKey: true }))).toBe('Control+F9') + expect(acceleratorOf(press('k', 'KeyK', { metaKey: true }))).toBe('Super+K') + }) +}) + +describe('acceleratorLabel', () => { + it('splits it into the keys to draw', () => { + expect(acceleratorLabel('Control+Shift+M')).toEqual(['Control', 'Shift', 'M']) + expect(acceleratorLabel('Super+Return')).toEqual(['Meta', 'Enter']) + }) +}) diff --git a/src/renderer/src/lib/accelerator.ts b/src/renderer/src/lib/accelerator.ts new file mode 100644 index 0000000..7484f2c --- /dev/null +++ b/src/renderer/src/lib/accelerator.ts @@ -0,0 +1,47 @@ +/** + * Turning a key press into the Electron accelerator the global hotkey is + * stored as ("Control+Shift+M"), and back into something to read. + */ + +const NAMED: Record = { + ' ': 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Escape', + Enter: 'Return', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + Insert: 'Insert' +} + +/** + * The accelerator for a key press, or null while it is only modifiers so far + * or is not one a global shortcut can be: it needs a key that is not a + * modifier, and at least one modifier, or it would take that key from every + * other program. + */ +export function acceleratorOf(e: Pick): string | null { + if (['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'Dead'].includes(e.key)) return null + const mods = [e.ctrlKey && 'Control', e.altKey && 'Alt', e.shiftKey && 'Shift', e.metaKey && 'Super'].filter(Boolean) as string[] + if (mods.length === 0) return null + let key: string | undefined = NAMED[e.key] + if (!key && /^F\d{1,2}$/.test(e.key)) key = e.key + // The physical key, not what it types: Shift+1 is "1" here and "!" in the + // event, and an accelerator naming "!" is one the system does not know. + if (!key && /^Key[A-Z]$/.test(e.code)) key = e.code.slice(3) + if (!key && /^Digit\d$/.test(e.code)) key = e.code.slice(5) + if (!key && e.key.length === 1) key = e.key.toUpperCase() + return key ? [...mods, key].join('+') : null +} + +/** "Control+Shift+M" as the keys read on a cap. */ +export function acceleratorLabel(accelerator: string): string[] { + return accelerator.split('+').map((part) => (part === 'Super' ? 'Meta' : part === 'Return' ? 'Enter' : part)) +}