Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space → ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction — alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and would rather move the app than the window manager. ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches the handler and nothing in this repository can help — the fix would be in Brave. `!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the two conditions are disjoint. CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the value is in state/shortcuts.ts. The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now would rewrite three handlers for nothing visible. Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Keyboard } from 'lucide-react';
|
|
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
|
|
|
/*
|
|
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
|
|
|
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
|
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
|
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
|
interesting part is `state/shortcuts.ts`.
|
|
|
|
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
|
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
|
*/
|
|
|
|
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
|
<div className="flex items-baseline gap-3 py-1">
|
|
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
|
putting them on the left. */}
|
|
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
|
{keys}
|
|
</kbd>
|
|
<span className="text-sm text-muted-foreground">{description}</span>
|
|
</div>
|
|
);
|
|
|
|
export const ShortcutsModal = () => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const onKey = (ev: KeyboardEvent) => {
|
|
if (isShortcutsChord(ev)) {
|
|
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
|
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
|
ev.preventDefault();
|
|
setOpen((was) => !was);
|
|
return;
|
|
}
|
|
if (ev.key === 'Escape' && open) setOpen(false);
|
|
};
|
|
// Capture, so a panel with its own key handling cannot swallow it first.
|
|
window.addEventListener('keydown', onKey, true);
|
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
|
onMouseDown={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
|
onMouseDown={(ev) => ev.stopPropagation()}
|
|
>
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
|
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
|
</div>
|
|
|
|
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
|
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
|
{SHORTCUT_SECTIONS.map((section) => (
|
|
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
|
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
|
<div className="mt-2">
|
|
{section.shortcuts.map((shortcut) => (
|
|
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|