1import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
2import { MonitorIcon, MoonIcon, PaletteIcon, SunIcon } from "lucide-react";
3
4import { useCustomThemes } from "../hooks/useCustomThemes";
5import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme";
6import { useTheme } from "../hooks/useTheme";
7import { getThemeDefinition } from "../themePalette";
8import {
9 ADDON_ICON_CLASS,
10 ITEM_ICON_CLASS,
11 type CommandPaletteActionItem,
12 type CommandPaletteSubmenuItem,
13} from "./CommandPalette.logic";
14import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
15import {
16 STANDARD_THEME_CARDS,
17 getThemeCardDefinition,
18 ThemePreviewCircle,
19} from "./settings/ThemePreviewCircles";
20import { stackedThreadToast, toastManager } from "./ui/toast";
21
22export const APPEARANCE_OPTIONS = [
23 { mode: "system", label: "System", icon: MonitorIcon },
24 { mode: "light", label: "Light", icon: SunIcon },
25 { mode: "dark", label: "Dark", icon: MoonIcon },
26] as const;
27
28export function notifyThemeSaveFailure(): void {
29 toastManager.add(
30 stackedThreadToast({
31 type: "error",
32 title: "Couldn't save theme selection",
33 description: "Try again.",
34 }),
35 );
36}
37
38// Reads its own source hooks so the compiler caches the cards on them. Taking the
39// sources as arguments would leave it hook-free, and the compiler would cache nothing.
40function useThemeCards() {
41 const customThemes = useCustomThemes();
42 const environmentThemes = useEnvironmentThemeDefinitions();
43 const seen = new Set<string>();
44 return [
45 ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })),
46 ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes]
47 .filter((definition) => {
48 if (seen.has(definition.id)) return false;
49 seen.add(definition.id);
50 return true;
51 })
52 .map(getThemeCardDefinition),
53 ];
54}
55
56export function useCommandPaletteThemeItems() {
57 const {
58 theme,
59 themeHalves,
60 resolvedTheme,
61 appearanceMode,
62 setAppearanceMode,
63 setTheme,
64 setThemeHalf,
65 } = useTheme();
66 const themeCards = useThemeCards();
67
68 const changeThemeItem: CommandPaletteSubmenuItem = {
69 kind: "submenu",
70 value: "action:change-theme",
71 searchTerms: ["change theme", "appearance", "colors", "palette"],
72 title: "Change theme",
73 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
74 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
75 shortcutCommand: "theme.select",
76 groups: [
77 {
78 value: "themes",
79 label: "Change theme",
80 items: themeCards.map(({ id, label, previews }) => ({
81 kind: "action",
82 value: id === null ? "theme:standard" : `theme:palette:${id}`,
83 title: label,
84 description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined,
85 searchTerms: [label, "theme", "appearance"],
86 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
87 titleTrailingContent: (
88 <span className="flex shrink-0 items-center gap-2">
89 {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? (
90 <span className="text-xs text-muted-foreground/70">Current</span>
91 ) : null}
92 <span className="flex items-center gap-1" aria-hidden>
93 {previews.map((preview) => (
94 <ThemePreviewCircle
95 key={preview.mode}
96 colors={preview.colors}
97 mode={preview.mode}
98 className="size-3 border-0"
99 />
100 ))}
101 </span>
102 </span>
103 ),
104 run: async () => {
105 const saved =
106 previews.length === 1 && id !== null
107 ? setThemeHalf(previews[0]!.mode, id)
108 : setTheme(id ?? appearanceMode);
109 if (!saved) notifyThemeSaveFailure();
110 },
111 })),
112 },
113 ],
114 };
115
116 const changeAppearanceItem: CommandPaletteSubmenuItem = {
117 kind: "submenu",
118 value: "action:change-appearance",
119 searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"],
120 title: "Change appearance",
121 icon: <MonitorIcon className={ITEM_ICON_CLASS} />,
122 addonIcon: <MonitorIcon className={ADDON_ICON_CLASS} />,
123 shortcutCommand: "appearance.cycle",
124 groups: [
125 {
126 value: "appearance",
127 label: "Change appearance",
128 items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({
129 kind: "action",
130 value: `appearance:${mode}`,
131 title: label,
132 searchTerms: [label, "appearance", "mode"],
133 icon: <Icon className={ITEM_ICON_CLASS} />,
134 titleTrailingContent:
135 appearanceMode === mode ? (
136 <span className="text-xs text-muted-foreground/70">Current</span>
137 ) : undefined,
138 run: async () => {
139 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
140 },
141 })),
142 },
143 ],
144 };
145
146 const themeEditorItem: CommandPaletteActionItem = {
147 kind: "action",
148 value: "action:theme-editor",
149 searchTerms: ["theme", "appearance", "colors", "palette", "customize"],
150 title: "Toggle theme editor",
151 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
152 shortcutCommand: "themeEditor.toggle",
153 run: async () => {
154 toggleThemeEditorForTheme({
155 theme,
156 themeHalves,
157 initialAppearance: resolvedTheme,
158 });
159 },
160 };
161
162 return { changeThemeItem, changeAppearanceItem, themeEditorItem };
163}