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>
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Check, Star } from 'lucide-react';
|
|
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
|
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
|
|
|
const {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} = Dropdown;
|
|
|
|
/**
|
|
* Themes, out of the avatar menu and into the header.
|
|
*
|
|
* They were buried under an avatar next to "Sign out", which is where a preference goes when nobody has
|
|
* decided it matters. With fourteen of them it is a thing you use, so it gets its own control.
|
|
*
|
|
* ── The overlay ──
|
|
*
|
|
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
|
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
|
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
|
* you browse, so as little of it as possible should be covered.
|
|
*
|
|
* Ported from the owner's `vimazing`, keys and all.
|
|
*/
|
|
export const ThemeSwitcher = () => {
|
|
const { theme, themeId, setTheme, themes } = useTheme();
|
|
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
|
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
|
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
|
|
|
/*
|
|
One ordering, used by both lists.
|
|
|
|
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
|
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
|
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
|
`theme/rotate.ts`.
|
|
|
|
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
|
the order it was given, so the theme you are wearing still leads.
|
|
*/
|
|
const ordered = useMemo(() => {
|
|
const rotated = rotateToSelected(themes, themeId);
|
|
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
|
return favouritesFirst(listed, favorites);
|
|
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
|
|
|
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
|
isFavorite(themeId) ? 'unstar' : 'star'
|
|
}`}
|
|
aria-label={`Theme: ${theme.name}`}
|
|
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
|
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
|
// the cluster stays one shape.
|
|
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
|
|
style={{ color: 'var(--header-fg)' }}
|
|
>
|
|
{theme.name}
|
|
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
|
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
|
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
{/*
|
|
The CONTENT does not scroll — the list inside it does.
|
|
|
|
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
|
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
|
visible for the first half-second and then gone for good, which is the same as not having written
|
|
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
|
*/}
|
|
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
|
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
|
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
|
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
|
<span className="block">ctrl+? for every shortcut</span>
|
|
</DropdownMenuLabel>
|
|
<button
|
|
type="button"
|
|
onClick={(ev) => {
|
|
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setOnlyFavorites((was) => !was);
|
|
}}
|
|
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
|
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
|
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
{ordered.length === 0 ? (
|
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
|
No favourites yet — star one with the button on its row.
|
|
</p>
|
|
) : (
|
|
families.map(([family, entries]) => (
|
|
<div key={family}>
|
|
{entries.map((t) => (
|
|
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
|
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
|
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
|
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
|
<button
|
|
type="button"
|
|
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
|
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
|
onClick={(ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(t.id);
|
|
}}
|
|
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[900] flex items-center justify-center"
|
|
// Deliberately light: the point is to see the app change behind it.
|
|
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="rounded-2xl border px-10 py-8 shadow-2xl"
|
|
style={{
|
|
backgroundColor: 'var(--popover)',
|
|
borderColor: 'var(--border)',
|
|
color: 'var(--popover-foreground)',
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
>
|
|
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
|
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
|
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
|
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
|
{ordered.map((t) => {
|
|
const isFocused = t.id === focused;
|
|
return (
|
|
<li key={t.id}>
|
|
<button
|
|
onMouseEnter={() => setFocused(t.id)}
|
|
onClick={() => {
|
|
setTheme(t.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
|
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
|
{/*
|
|
A span, not a button: this sits inside the row's own <button>, and a button inside
|
|
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
|
handler and the role are set explicitly instead, and `f` does the same thing from
|
|
the keyboard.
|
|
*/}
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
|
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
|
onClick={(ev) => {
|
|
// Or the row's own onClick applies the theme and closes the overlay, which is
|
|
// the opposite of what starring is for.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(t.id);
|
|
}}
|
|
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
|
>
|
|
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
|
header does. */}
|
|
<p className="mt-6 text-center text-xs opacity-60">
|
|
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
|
</p>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
};
|