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'; import { useTranslation } from '@/lib/i18n'; 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+↑/↓` 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 with no * backdrop rather than a modal dialog: you are meant to watch the application change while you browse, * so nothing of it should be covered. * * Ported from the owner's `vimazing`, keys and all. */ export const ThemeSwitcher = () => { const { theme, themeId, setTheme, themes } = useTheme(); const { t } = useTranslation(); 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((entry) => favorites.has(entry.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 ( <> {/* 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. */}
{t('theme.cycleHint')} {t('theme.everyShortcutHint')}
{ordered.length === 0 ? (

{t('theme.noFavourites')}

) : ( families.map(([family, entries]) => (
{entries.map((entry) => ( setTheme(entry.id)} className="cursor-pointer pr-1"> {entry.name} {/* A sibling of the row's action, never nested inside it — starring is not choosing. */} ))}
)) )}
{open && createPortal(
setOpen(false)} >
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. */} {/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's header does. */}

{t('theme.browseHint')}

, document.body, )} ); };