one modifier, and arrows beside the vim keys

Standardised on `ctrl+shift` for all three global bindings, and added arrows everywhere
the vim keys work — `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑` are now interchangeable.

  ctrl+shift+→  or  ctrl+shift+l      next theme
  ctrl+shift+←  or  ctrl+shift+h      previous theme
  ctrl+shift+space                    the list

── Why ctrl+shift and not the alternatives ──

Checked GNOME's actual defaults rather than reasoning from convention:

  <Control><Alt>Left/Right         switch workspace
  <Control><Shift><Alt>Left/Right  move window to workspace
  <Super><Alt>Left/Right           switch workspace

So ctrl+alt is unusable — those arrows change your workspace, and `ctrl+alt+l` locks the
screen, which is presumably why the original bindings put the letters on ctrl+shift in
the first place. `shift+alt` is free on Linux but is the keyboard-layout switcher on
Windows and `alt+space` opens the window menu.

`ctrl+shift` has no OS binding at all. Its one collision is select-by-word inside a text
field — which is now explicitly protected.

── The guard moved earlier ──

Typing was only checked once the list was OPEN. With arrows on the global cycle that was
no longer enough: `ctrl+shift+←/→` is select-by-word in every text field, and stealing it
mid-sentence to change a colour scheme would be indefensible. The check now runs before
the global cycle too.

Verified against a focused textarea: theme unchanged, and the word selection still
happened. The first run of that test reported a hijack, and the test was wrong — it
clicked a selector that matched nothing, swallowed the failure, and read body-focus as
a text field.

`ctrl+alt+space` still opens the list. It shipped an hour ago and fingers learn faster
than they unlearn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 01:30:33 +00:00
co-authored by Claude Opus 5
parent 4ecd45c4a9
commit f492905023
2 changed files with 40 additions and 23 deletions
@@ -44,7 +44,7 @@ export const ThemeSwitcher = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+alt+space for the list, ctrl+shift+h/l to cycle`}
title={`Theme: ${theme.name} · ctrl+shift+space for the list, ctrl+shift+←/→ to cycle`}
aria-label="Theme"
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
style={{ color: 'var(--header-fg)' }}
@@ -54,7 +54,7 @@ export const ThemeSwitcher = () => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
ctrl+alt+space · ctrl+shift+h/l
ctrl+shift+space · ctrl+shift+←/→
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(families).map(([family, entries]) => (
@@ -110,7 +110,7 @@ export const ThemeSwitcher = () => {
})}
</ul>
<p className="mt-6 text-center text-xs opacity-60">
j/k move · l preview · h reset · enter keep · esc cancel
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
</p>
</div>
</div>,
+37 -20
View File
@@ -7,8 +7,16 @@ import type { ThemeId } from './themes';
*
* Two modes, and the second is the good one:
*
* ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI
* ctrl+alt+space open the list, then browse it with j/k
* ctrl+shift+l / ctrl+shift+→ next theme, from anywhere, no UI
* ctrl+shift+h / ctrl+shift+← previous theme
* ctrl+shift+space open the list, then browse it
*
* One modifier for all three, which took some choosing. `ctrl+alt+arrows` switches WORKSPACE on GNOME and
* `ctrl+alt+l` locks the screen, so ctrl+alt is unusable for this. `shift+alt` is free on Linux but is the
* keyboard-layout switcher on Windows. `ctrl+shift` has no OS binding at all; its only collision is
* word-selection inside a text field, which the guard below already prevents.
*
* Vim keys and arrows are interchangeable everywhere: `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑`.
*
* ── Why browsing applies the theme ──
*
@@ -48,8 +56,9 @@ export function useThemeKeys() {
const onKeyDown = (event: KeyboardEvent) => {
const { themeId: current, focused: focus, open: isOpen } = state.current;
// Toggle the list.
if (event.ctrlKey && event.altKey && event.code === 'Space') {
// Toggle the list. `ctrl+alt+space` still works — it is what this shipped with an hour ago and
// fingers are quicker to learn than to unlearn.
if (event.ctrlKey && (event.shiftKey || event.altKey) && event.code === 'Space') {
event.preventDefault();
setOpen((was) => {
if (!was) {
@@ -61,23 +70,29 @@ export function useThemeKeys() {
return;
}
// Cycle from anywhere, list or no list.
if (event.ctrlKey && event.shiftKey && (event.key === 'L' || event.key === 'l')) {
event.preventDefault();
setTheme(step(current, 1));
return;
}
if (event.ctrlKey && event.shiftKey && (event.key === 'H' || event.key === 'h')) {
event.preventDefault();
setTheme(step(current, -1));
return;
}
if (!isOpen) return;
// Typing in a field should still be typing, even with the list up.
/*
Typing is typing. Checked BEFORE the global cycle, not just inside the list: `ctrl+shift+←/→` is
select-by-word in every text field on earth, and stealing it to change a colour scheme mid-sentence
would be indefensible.
*/
const target = event.target as HTMLElement | null;
if (target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) return;
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
// Cycle from anywhere, list open or not.
if (event.ctrlKey && event.shiftKey && !typing) {
if (event.key === 'L' || event.key === 'l' || event.key === 'ArrowRight') {
event.preventDefault();
setTheme(step(current, 1));
return;
}
if (event.key === 'H' || event.key === 'h' || event.key === 'ArrowLeft') {
event.preventDefault();
setTheme(step(current, -1));
return;
}
}
if (!isOpen || typing) return;
switch (event.key) {
case 'j':
@@ -94,6 +109,7 @@ export function useThemeKeys() {
return;
case 'l':
case 'L':
case 'ArrowRight':
case ' ':
// Apply what is focused, without closing — the browse.
event.preventDefault();
@@ -101,6 +117,7 @@ export function useThemeKeys() {
return;
case 'h':
case 'H':
case 'ArrowLeft':
event.preventDefault();
setTheme(startedOn.current);
setFocused(startedOn.current);