import { useEffect, 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(); const [onlyFavorites, setOnlyFavorites] = useState(false); /* The DROPDOWN's ordering. 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(',')]); /* The OVERLAY's ordering: the same rotation, favourites IN PLACE. The dropdown lifts favourites to a top block, and the overlay used to as well — which made keyboard browsing hop into the block and back out, because the rotation and the display disagreed about what "next" meant. In the overlay you are browsing, not picking from a menu, so it shows ONE list in rotation order and the favourites filter (`tab`, or the star button) is how the shortlist is reached. */ const overlayList = useMemo(() => { const rotated = rotateToSelected(themes, themeId); return onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated; }, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]); const browseOrder = useMemo(() => overlayList.map((entry) => entry.id), [overlayList]); const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle, browseOrder, onToggleFilter: () => setOnlyFavorites((was) => !was), }); // The filter can pull the focused theme out from under the browse — flipping to favourites-only while // an unstarred one is focused. Land on the first of what is now shown rather than focusing nothing. useEffect(() => { if (!open) return; if (overlayList.some((entry) => entry.id === focused)) return; const first = overlayList[0]; if (first) setFocused(first.id); }, [open, overlayList, focused, setFocused]); 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 filter, mirrored from the dropdown — same state, so the two surfaces never disagree about what "the list" currently is. `f` does the same thing from the keyboard. */}
{overlayList.length === 0 && (

{t('theme.noFavourites')}

)} {/* FIXED dimensions, not max/content: flipping the favourites filter must not resize the panel — the list is 200+ themes on one side of the flip and a handful on the other, and a modal that jumps between two sizes on every `f` reads as broken. Long names truncate; they already did when the widest name happened to be filtered out. */} {/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's header does. */}

{t('theme.browseHint')}

, document.body, )} ); };