Settings: a Keybinds page - every shortcut listed, and the global hotkey is recorded by pressing it (#322)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 21:48:22 -04:00
co-authored by Claude Sonnet 5.5
parent adca84d657
commit 82a0bdf8a8
5 changed files with 255 additions and 12 deletions
+10
View File
@@ -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) },
{
+30
View File
@@ -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);
}
@@ -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 <AppearanceSettings />
case 'notifications':
return <NotificationSettings />
case 'keybinds':
return <KeybindSettings />
case 'irc':
return <IrcSettings />
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 (
<span className="keys">
{keys.map((k, i) => (
<kbd key={i} className="key">
{k}
</kbd>
))}
</span>
)
}
/**
* 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<string>('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 (
<div className="setting-row">
<div className="setting-text">
<div>Show or hide moho</div>
<div className="small muted">
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.
</div>
</div>
<button
type="button"
className={`button hotkey-recorder${recording ? ' recording' : ''}`}
onClick={() => setRecording(!recording)}
aria-label="Record a new hotkey"
>
{recording ? 'Press the keys…' : value ? <Keys keys={acceleratorLabel(value)} /> : 'Not set'}
</button>
{value && !recording && (
<button type="button" className="button subtle small" onClick={() => setValue('')}>
Clear
</button>
)}
</div>
)
}
/** 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) => (
<SettingsSection key={group.title} title={group.title}>
{group.rows.map((row) => (
<div key={row.what} className="setting-row">
<div className="setting-text">{row.what}</div>
<Keys keys={row.keys} />
</div>
))}
</SettingsSection>
))}
<SettingsSection title="Anywhere on your computer">
<HotkeyRecorder />
</SettingsSection>
</>
)
}
/**
* 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 {
<VoiceSettings />
<StorageSettings />
<SettingsSection
title="Window"
description="Wayland compositors don't allow an ordinary app to grab keys globally, so this only takes effect under X11. On Wayland, bind your compositor to focus moho instead."
>
<StringSetting
settingKey="hotkey.toggle"
label="Show/hide hotkey"
defaultValue="Control+Shift+M"
placeholder="Control+Shift+M"
/>
</SettingsSection>
</>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { acceleratorLabel, acceleratorOf } from './accelerator'
const press = (key: string, code: string, mods: Partial<Record<'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey', boolean>> = {}) => ({
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'])
})
})
+47
View File
@@ -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<string, string> = {
' ': '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<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'>): 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))
}