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