themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,5 +6,6 @@
|
||||
*/
|
||||
export { ThemeProvider, useTheme, useColorMode } from './ThemeProvider';
|
||||
export { useThemeAsset } from './useThemeAsset';
|
||||
export { useThemeKeys } from './useThemeKeys';
|
||||
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
|
||||
export type { Theme, ThemeId } from './themes';
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import type { ThemeId } from './themes';
|
||||
|
||||
/**
|
||||
* Keyboard theme switching, ported from the owner's `vimazing`.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* ── Why browsing applies the theme ──
|
||||
*
|
||||
* With the list open, `j`/`k` move the focus and `l` or `space` APPLIES the focused theme to the whole
|
||||
* app behind the overlay. That is the point of it: you are not reading names, you are watching the
|
||||
* application change. `h` puts back what you started with, `Enter` keeps what you are looking at, `Escape`
|
||||
* restores and closes.
|
||||
*
|
||||
* The starting theme is captured when the list opens, so `h` and `Escape` can undo an entire browse
|
||||
* regardless of how many previews happened in between.
|
||||
*
|
||||
* ── Not a UI ──
|
||||
*
|
||||
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
|
||||
* because the behaviour belongs to themes, while a dialog would drag a component library in behind it.
|
||||
*/
|
||||
export function useThemeKeys() {
|
||||
const { themeId, setTheme, themes } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState<ThemeId>(themeId);
|
||||
const startedOn = useRef<ThemeId>(themeId);
|
||||
|
||||
// A ref, so the handler does not need re-binding on every theme change.
|
||||
const state = useRef({ themeId, focused, open });
|
||||
state.current = { themeId, focused, open };
|
||||
|
||||
const step = useCallback(
|
||||
(from: ThemeId, delta: number): ThemeId => {
|
||||
const ids = themes.map((t) => t.id);
|
||||
const at = ids.indexOf(from);
|
||||
return ids[(at + delta + ids.length) % ids.length]!;
|
||||
},
|
||||
[themes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const { themeId: current, focused: focus, open: isOpen } = state.current;
|
||||
|
||||
// Toggle the list.
|
||||
if (event.ctrlKey && event.altKey && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
setOpen((was) => {
|
||||
if (!was) {
|
||||
startedOn.current = current;
|
||||
setFocused(current);
|
||||
}
|
||||
return !was;
|
||||
});
|
||||
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.
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'J':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocused((prev) => step(prev, 1));
|
||||
return;
|
||||
case 'k':
|
||||
case 'K':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocused((prev) => step(prev, -1));
|
||||
return;
|
||||
case 'l':
|
||||
case 'L':
|
||||
case ' ':
|
||||
// Apply what is focused, without closing — the browse.
|
||||
event.preventDefault();
|
||||
setTheme(focus);
|
||||
return;
|
||||
case 'h':
|
||||
case 'H':
|
||||
event.preventDefault();
|
||||
setTheme(startedOn.current);
|
||||
setFocused(startedOn.current);
|
||||
return;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
setTheme(focus);
|
||||
setOpen(false);
|
||||
return;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setTheme(startedOn.current);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [setTheme, step]);
|
||||
|
||||
return { open, setOpen, focused, setFocused };
|
||||
}
|
||||
Reference in New Issue
Block a user