@tooee/themes 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/theme.tsx DELETED
@@ -1,727 +0,0 @@
1
- import {
2
- createContext,
3
- useContext,
4
- useState,
5
- useEffect,
6
- useCallback,
7
- useRef,
8
- type ReactNode,
9
- } from "react"
10
- import { RGBA, SyntaxStyle } from "@opentui/core"
11
- import { writeGlobalConfig } from "@tooee/config"
12
- import { readFileSync, readdirSync, existsSync } from "fs"
13
- import { join, basename, dirname } from "path"
14
-
15
- // ---------------------------------------------------------------------------
16
- // Theme JSON format (OpenCode-compatible)
17
- // ---------------------------------------------------------------------------
18
-
19
- type HexColor = `#${string}`
20
- type RefName = string
21
- type Variant = { dark: HexColor | RefName; light: HexColor | RefName }
22
- type ColorValue = HexColor | RefName | Variant
23
-
24
- export interface ThemeJSON {
25
- $schema?: string
26
- defs?: Record<string, HexColor | RefName>
27
- theme: Record<string, ColorValue>
28
- }
29
-
30
- // ---------------------------------------------------------------------------
31
- // Resolved theme — all colors resolved to hex strings
32
- // ---------------------------------------------------------------------------
33
-
34
- export interface ResolvedTheme {
35
- // UI
36
- primary: string
37
- secondary: string
38
- accent: string
39
- error: string
40
- warning: string
41
- success: string
42
- info: string
43
- text: string
44
- textMuted: string
45
- background: string
46
- backgroundPanel: string
47
- backgroundElement: string
48
- border: string
49
- borderActive: string
50
- borderSubtle: string
51
- // Diff
52
- diffAdded: string
53
- diffRemoved: string
54
- diffContext: string
55
- diffHunkHeader: string
56
- diffHighlightAdded: string
57
- diffHighlightRemoved: string
58
- diffAddedBg: string
59
- diffRemovedBg: string
60
- diffContextBg: string
61
- diffLineNumber: string
62
- diffAddedLineNumberBg: string
63
- diffRemovedLineNumberBg: string
64
- // Markdown
65
- markdownText: string
66
- markdownHeading: string
67
- markdownLink: string
68
- markdownLinkText: string
69
- markdownCode: string
70
- markdownBlockQuote: string
71
- markdownEmph: string
72
- markdownStrong: string
73
- markdownHorizontalRule: string
74
- markdownListItem: string
75
- markdownListEnumeration: string
76
- markdownImage: string
77
- markdownImageText: string
78
- markdownCodeBlock: string
79
- // Cursor/Selection
80
- cursorLine: string
81
- selection: string
82
- // Syntax
83
- syntaxComment: string
84
- syntaxKeyword: string
85
- syntaxFunction: string
86
- syntaxVariable: string
87
- syntaxString: string
88
- syntaxNumber: string
89
- syntaxType: string
90
- syntaxOperator: string
91
- syntaxPunctuation: string
92
- }
93
-
94
- // All keys of ResolvedTheme for iteration
95
- const RESOLVED_KEYS: (keyof ResolvedTheme)[] = [
96
- "primary",
97
- "secondary",
98
- "accent",
99
- "error",
100
- "warning",
101
- "success",
102
- "info",
103
- "text",
104
- "textMuted",
105
- "background",
106
- "backgroundPanel",
107
- "backgroundElement",
108
- "border",
109
- "borderActive",
110
- "borderSubtle",
111
- "cursorLine",
112
- "selection",
113
- "diffAdded",
114
- "diffRemoved",
115
- "diffContext",
116
- "diffHunkHeader",
117
- "diffHighlightAdded",
118
- "diffHighlightRemoved",
119
- "diffAddedBg",
120
- "diffRemovedBg",
121
- "diffContextBg",
122
- "diffLineNumber",
123
- "diffAddedLineNumberBg",
124
- "diffRemovedLineNumberBg",
125
- "markdownText",
126
- "markdownHeading",
127
- "markdownLink",
128
- "markdownLinkText",
129
- "markdownCode",
130
- "markdownBlockQuote",
131
- "markdownEmph",
132
- "markdownStrong",
133
- "markdownHorizontalRule",
134
- "markdownListItem",
135
- "markdownListEnumeration",
136
- "markdownImage",
137
- "markdownImageText",
138
- "markdownCodeBlock",
139
- "syntaxComment",
140
- "syntaxKeyword",
141
- "syntaxFunction",
142
- "syntaxVariable",
143
- "syntaxString",
144
- "syntaxNumber",
145
- "syntaxType",
146
- "syntaxOperator",
147
- "syntaxPunctuation",
148
- ]
149
-
150
- // Fallbacks used when a theme key is missing
151
- const FALLBACKS: Record<string, string> = {
152
- primary: "#808080",
153
- secondary: "#808080",
154
- accent: "#808080",
155
- error: "#808080",
156
- warning: "#808080",
157
- success: "#808080",
158
- info: "#808080",
159
- text: "#d4d4d4",
160
- textMuted: "#808080",
161
- background: "#1e1e1e",
162
- backgroundPanel: "#1e1e1e",
163
- backgroundElement: "#1e1e1e",
164
- cursorLine: "#1e1e1e",
165
- selection: "#1e1e1e",
166
- border: "#808080",
167
- borderActive: "#808080",
168
- borderSubtle: "#808080",
169
- diffAdded: "#4fd6be",
170
- diffRemoved: "#c53b53",
171
- diffContext: "#808080",
172
- diffHunkHeader: "#808080",
173
- diffHighlightAdded: "#4fd6be",
174
- diffHighlightRemoved: "#c53b53",
175
- diffAddedBg: "#1e3a1e",
176
- diffRemovedBg: "#3a1e1e",
177
- diffContextBg: "#1e1e1e",
178
- diffLineNumber: "#808080",
179
- diffAddedLineNumberBg: "#1e3a1e",
180
- diffRemovedLineNumberBg: "#3a1e1e",
181
- markdownText: "#d4d4d4",
182
- markdownHeading: "#808080",
183
- markdownLink: "#808080",
184
- markdownLinkText: "#808080",
185
- markdownCode: "#808080",
186
- markdownBlockQuote: "#808080",
187
- markdownEmph: "#808080",
188
- markdownStrong: "#808080",
189
- markdownHorizontalRule: "#808080",
190
- markdownListItem: "#808080",
191
- markdownListEnumeration: "#808080",
192
- markdownImage: "#808080",
193
- markdownImageText: "#808080",
194
- markdownCodeBlock: "#d4d4d4",
195
- syntaxComment: "#808080",
196
- syntaxKeyword: "#808080",
197
- syntaxFunction: "#808080",
198
- syntaxVariable: "#808080",
199
- syntaxString: "#808080",
200
- syntaxNumber: "#808080",
201
- syntaxType: "#808080",
202
- syntaxOperator: "#808080",
203
- syntaxPunctuation: "#808080",
204
- }
205
-
206
- // ---------------------------------------------------------------------------
207
- // Resolution
208
- // ---------------------------------------------------------------------------
209
-
210
- export function resolveTheme(json: ThemeJSON, mode: "dark" | "light"): ResolvedTheme {
211
- const defs = json.defs ?? {}
212
-
213
- function resolveColor(c: ColorValue): string {
214
- if (typeof c === "string") {
215
- if (c === "transparent" || c === "none") return "#00000000"
216
- if (c.startsWith("#")) return c
217
- if (defs[c] != null) return resolveColor(defs[c] as ColorValue)
218
- if (json.theme[c] !== undefined) return resolveColor(json.theme[c] as ColorValue)
219
- return "#808080"
220
- }
221
- return resolveColor(c[mode])
222
- }
223
-
224
- const result = {} as Record<string, string>
225
- for (const key of RESOLVED_KEYS) {
226
- const val = json.theme[key]
227
- result[key] = val !== undefined ? resolveColor(val) : (FALLBACKS[key] ?? "#808080")
228
- }
229
- // Dynamic fallbacks that reference other resolved keys
230
- if (json.theme["cursorLine"] === undefined) result.cursorLine = result.backgroundElement
231
- if (json.theme["selection"] === undefined) result.selection = result.backgroundPanel
232
- return result as unknown as ResolvedTheme
233
- }
234
-
235
- // ---------------------------------------------------------------------------
236
- // SyntaxStyle builder
237
- // ---------------------------------------------------------------------------
238
-
239
- function getSyntaxRules(resolved: ResolvedTheme) {
240
- return [
241
- { scope: ["default"], style: { foreground: RGBA.fromHex(resolved.text) } },
242
- { scope: ["prompt"], style: { foreground: RGBA.fromHex(resolved.accent) } },
243
- {
244
- scope: ["comment", "comment.documentation"],
245
- style: { foreground: RGBA.fromHex(resolved.syntaxComment), italic: true },
246
- },
247
- { scope: ["string", "symbol"], style: { foreground: RGBA.fromHex(resolved.syntaxString) } },
248
- { scope: ["number", "boolean"], style: { foreground: RGBA.fromHex(resolved.syntaxNumber) } },
249
- { scope: ["character.special"], style: { foreground: RGBA.fromHex(resolved.syntaxString) } },
250
- {
251
- scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
252
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword), italic: true },
253
- },
254
- {
255
- scope: ["keyword.type"],
256
- style: { foreground: RGBA.fromHex(resolved.syntaxType), bold: true, italic: true },
257
- },
258
- {
259
- scope: ["keyword.function", "function.method"],
260
- style: { foreground: RGBA.fromHex(resolved.syntaxFunction) },
261
- },
262
- {
263
- scope: ["keyword"],
264
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword), italic: true },
265
- },
266
- { scope: ["keyword.import"], style: { foreground: RGBA.fromHex(resolved.syntaxKeyword) } },
267
- {
268
- scope: ["operator", "keyword.operator", "punctuation.delimiter"],
269
- style: { foreground: RGBA.fromHex(resolved.syntaxOperator) },
270
- },
271
- {
272
- scope: ["keyword.conditional.ternary"],
273
- style: { foreground: RGBA.fromHex(resolved.syntaxOperator) },
274
- },
275
- {
276
- scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
277
- style: { foreground: RGBA.fromHex(resolved.syntaxVariable) },
278
- },
279
- {
280
- scope: ["variable.member", "function", "constructor"],
281
- style: { foreground: RGBA.fromHex(resolved.syntaxFunction) },
282
- },
283
- { scope: ["type", "module"], style: { foreground: RGBA.fromHex(resolved.syntaxType) } },
284
- { scope: ["constant"], style: { foreground: RGBA.fromHex(resolved.syntaxNumber) } },
285
- { scope: ["property"], style: { foreground: RGBA.fromHex(resolved.syntaxVariable) } },
286
- { scope: ["class"], style: { foreground: RGBA.fromHex(resolved.syntaxType) } },
287
- { scope: ["parameter"], style: { foreground: RGBA.fromHex(resolved.syntaxVariable) } },
288
- {
289
- scope: ["punctuation", "punctuation.bracket"],
290
- style: { foreground: RGBA.fromHex(resolved.syntaxPunctuation) },
291
- },
292
- {
293
- scope: [
294
- "variable.builtin",
295
- "type.builtin",
296
- "function.builtin",
297
- "module.builtin",
298
- "constant.builtin",
299
- ],
300
- style: { foreground: RGBA.fromHex(resolved.error) },
301
- },
302
- { scope: ["variable.super"], style: { foreground: RGBA.fromHex(resolved.error) } },
303
- {
304
- scope: ["string.escape", "string.regexp"],
305
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword) },
306
- },
307
- {
308
- scope: ["keyword.directive"],
309
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword), italic: true },
310
- },
311
- {
312
- scope: ["punctuation.special"],
313
- style: { foreground: RGBA.fromHex(resolved.syntaxOperator) },
314
- },
315
- {
316
- scope: ["keyword.modifier"],
317
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword), italic: true },
318
- },
319
- {
320
- scope: ["keyword.exception"],
321
- style: { foreground: RGBA.fromHex(resolved.syntaxKeyword), italic: true },
322
- },
323
- // Markdown
324
- {
325
- scope: [
326
- "markup.heading",
327
- "markup.heading.1",
328
- "markup.heading.2",
329
- "markup.heading.3",
330
- "markup.heading.4",
331
- "markup.heading.5",
332
- "markup.heading.6",
333
- ],
334
- style: { foreground: RGBA.fromHex(resolved.markdownHeading), bold: true },
335
- },
336
- {
337
- scope: ["markup.bold", "markup.strong"],
338
- style: { foreground: RGBA.fromHex(resolved.markdownStrong), bold: true },
339
- },
340
- {
341
- scope: ["markup.italic"],
342
- style: { foreground: RGBA.fromHex(resolved.markdownEmph), italic: true },
343
- },
344
- { scope: ["markup.list"], style: { foreground: RGBA.fromHex(resolved.markdownListItem) } },
345
- {
346
- scope: ["markup.quote"],
347
- style: { foreground: RGBA.fromHex(resolved.markdownBlockQuote), italic: true },
348
- },
349
- {
350
- scope: ["markup.raw", "markup.raw.block"],
351
- style: { foreground: RGBA.fromHex(resolved.markdownCode) },
352
- },
353
- {
354
- scope: ["markup.raw.inline"],
355
- style: {
356
- foreground: RGBA.fromHex(resolved.markdownCode),
357
- background: RGBA.fromHex(resolved.background),
358
- },
359
- },
360
- {
361
- scope: ["markup.link"],
362
- style: { foreground: RGBA.fromHex(resolved.markdownLink), underline: true },
363
- },
364
- {
365
- scope: ["markup.link.label"],
366
- style: { foreground: RGBA.fromHex(resolved.markdownLinkText), underline: true },
367
- },
368
- {
369
- scope: ["markup.link.url"],
370
- style: { foreground: RGBA.fromHex(resolved.markdownLink), underline: true },
371
- },
372
- { scope: ["label"], style: { foreground: RGBA.fromHex(resolved.markdownLinkText) } },
373
- { scope: ["spell", "nospell"], style: { foreground: RGBA.fromHex(resolved.text) } },
374
- { scope: ["conceal"], style: { foreground: RGBA.fromHex(resolved.textMuted) } },
375
- {
376
- scope: ["string.special", "string.special.url"],
377
- style: { foreground: RGBA.fromHex(resolved.markdownLink), underline: true },
378
- },
379
- { scope: ["character"], style: { foreground: RGBA.fromHex(resolved.syntaxString) } },
380
- { scope: ["float"], style: { foreground: RGBA.fromHex(resolved.syntaxNumber) } },
381
- {
382
- scope: ["comment.error"],
383
- style: { foreground: RGBA.fromHex(resolved.error), italic: true, bold: true },
384
- },
385
- {
386
- scope: ["comment.warning"],
387
- style: { foreground: RGBA.fromHex(resolved.warning), italic: true, bold: true },
388
- },
389
- {
390
- scope: ["comment.todo", "comment.note"],
391
- style: { foreground: RGBA.fromHex(resolved.info), italic: true, bold: true },
392
- },
393
- { scope: ["namespace"], style: { foreground: RGBA.fromHex(resolved.syntaxType) } },
394
- { scope: ["field"], style: { foreground: RGBA.fromHex(resolved.syntaxVariable) } },
395
- {
396
- scope: ["type.definition"],
397
- style: { foreground: RGBA.fromHex(resolved.syntaxType), bold: true },
398
- },
399
- { scope: ["keyword.export"], style: { foreground: RGBA.fromHex(resolved.syntaxKeyword) } },
400
- { scope: ["attribute", "annotation"], style: { foreground: RGBA.fromHex(resolved.warning) } },
401
- { scope: ["tag"], style: { foreground: RGBA.fromHex(resolved.error) } },
402
- { scope: ["tag.attribute"], style: { foreground: RGBA.fromHex(resolved.syntaxKeyword) } },
403
- { scope: ["tag.delimiter"], style: { foreground: RGBA.fromHex(resolved.syntaxOperator) } },
404
- { scope: ["markup.strikethrough"], style: { foreground: RGBA.fromHex(resolved.textMuted) } },
405
- {
406
- scope: ["markup.underline"],
407
- style: { foreground: RGBA.fromHex(resolved.text), underline: true },
408
- },
409
- { scope: ["markup.list.checked"], style: { foreground: RGBA.fromHex(resolved.success) } },
410
- { scope: ["markup.list.unchecked"], style: { foreground: RGBA.fromHex(resolved.textMuted) } },
411
- {
412
- scope: ["diff.plus"],
413
- style: {
414
- foreground: RGBA.fromHex(resolved.diffAdded),
415
- background: RGBA.fromHex(resolved.diffAddedBg),
416
- },
417
- },
418
- {
419
- scope: ["diff.minus"],
420
- style: {
421
- foreground: RGBA.fromHex(resolved.diffRemoved),
422
- background: RGBA.fromHex(resolved.diffRemovedBg),
423
- },
424
- },
425
- {
426
- scope: ["diff.delta"],
427
- style: {
428
- foreground: RGBA.fromHex(resolved.diffContext),
429
- background: RGBA.fromHex(resolved.diffContextBg),
430
- },
431
- },
432
- { scope: ["error"], style: { foreground: RGBA.fromHex(resolved.error), bold: true } },
433
- { scope: ["warning"], style: { foreground: RGBA.fromHex(resolved.warning), bold: true } },
434
- { scope: ["info"], style: { foreground: RGBA.fromHex(resolved.info) } },
435
- { scope: ["debug"], style: { foreground: RGBA.fromHex(resolved.textMuted) } },
436
- ]
437
- }
438
-
439
- export function buildSyntaxStyle(resolved: ResolvedTheme): SyntaxStyle {
440
- return SyntaxStyle.fromTheme(getSyntaxRules(resolved))
441
- }
442
-
443
- // ---------------------------------------------------------------------------
444
- // Theme loading
445
- // ---------------------------------------------------------------------------
446
-
447
- /** Cache of loaded theme JSONs by name */
448
- const themeJsonCache = new Map<string, ThemeJSON>()
449
-
450
- function loadJsonThemesFromDir(dir: string, target: Map<string, ThemeJSON>) {
451
- try {
452
- if (!existsSync(dir)) return
453
- for (const file of readdirSync(dir)) {
454
- if (!file.endsWith(".json")) continue
455
- const name = basename(file, ".json")
456
- try {
457
- const content = readFileSync(join(dir, file), "utf-8")
458
- target.set(name, JSON.parse(content) as ThemeJSON)
459
- } catch {
460
- // skip invalid files
461
- }
462
- }
463
- } catch {
464
- // dir not readable
465
- }
466
- }
467
-
468
- /** Load all bundled themes from packages/themes/themes/ */
469
- function loadBundledThemes(): Map<string, ThemeJSON> {
470
- if (themeJsonCache.size > 0) return themeJsonCache
471
-
472
- // Bundled themes
473
- const bundledDir = join(dirname(new URL(import.meta.url).pathname), "..", "themes")
474
- loadJsonThemesFromDir(bundledDir, themeJsonCache)
475
-
476
- // XDG config: ~/.config/tooee/themes/
477
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config")
478
- loadJsonThemesFromDir(join(xdgConfig, "tooee", "themes"), themeJsonCache)
479
-
480
- // Project-local: search upward for .tooee/themes/
481
- let dir = process.cwd()
482
- const seen = new Set<string>()
483
- while (dir && !seen.has(dir)) {
484
- seen.add(dir)
485
- loadJsonThemesFromDir(join(dir, ".tooee", "themes"), themeJsonCache)
486
- const parent = dirname(dir)
487
- if (parent === dir) break
488
- dir = parent
489
- }
490
-
491
- return themeJsonCache
492
- }
493
-
494
- export function loadThemes(): Map<string, ThemeJSON> {
495
- return loadBundledThemes()
496
- }
497
-
498
- export function getThemeNames(): string[] {
499
- return Array.from(loadThemes().keys()).sort()
500
- }
501
-
502
- export interface Theme {
503
- name: string
504
- mode: "dark" | "light"
505
- colors: ResolvedTheme
506
- syntax: SyntaxStyle
507
- }
508
-
509
- // ---------------------------------------------------------------------------
510
- // Default theme
511
- // ---------------------------------------------------------------------------
512
-
513
- const DEFAULT_THEME_NAME = "tokyonight"
514
- const DEFAULT_MODE: "dark" | "light" = "dark"
515
-
516
- function buildTheme(name: string, mode: "dark" | "light"): Theme {
517
- const themes = loadThemes()
518
- const json = themes.get(name)
519
- if (!json) {
520
- // Fall back to tokyonight, then first available, then hardcoded
521
- const fallbackJson = themes.get(DEFAULT_THEME_NAME) ?? themes.values().next().value
522
- if (fallbackJson) {
523
- const resolved = resolveTheme(fallbackJson, mode)
524
- return { name, mode, colors: resolved, syntax: buildSyntaxStyle(resolved) }
525
- }
526
- // Absolute fallback — hardcoded Tokyo Night colors
527
- return hardcodedDefaultTheme
528
- }
529
- const resolved = resolveTheme(json, mode)
530
- return { name, mode, colors: resolved, syntax: buildSyntaxStyle(resolved) }
531
- }
532
-
533
- const hardcodedDefaultTheme: Theme = (() => {
534
- const colors: ResolvedTheme = {
535
- primary: "#7aa2f7",
536
- secondary: "#bb9af7",
537
- accent: "#7dcfff",
538
- error: "#f7768e",
539
- warning: "#e0af68",
540
- success: "#9ece6a",
541
- info: "#7aa2f7",
542
- text: "#c0caf5",
543
- textMuted: "#565f89",
544
- background: "#1a1b26",
545
- backgroundPanel: "#1e2030",
546
- backgroundElement: "#222436",
547
- cursorLine: "#222436",
548
- selection: "#1e2030",
549
- border: "#565f89",
550
- borderActive: "#737aa2",
551
- borderSubtle: "#414868",
552
- diffAdded: "#4fd6be",
553
- diffRemoved: "#c53b53",
554
- diffContext: "#828bb8",
555
- diffHunkHeader: "#828bb8",
556
- diffHighlightAdded: "#b8db87",
557
- diffHighlightRemoved: "#e26a75",
558
- diffAddedBg: "#20303b",
559
- diffRemovedBg: "#37222c",
560
- diffContextBg: "#1e2030",
561
- diffLineNumber: "#222436",
562
- diffAddedLineNumberBg: "#1b2b34",
563
- diffRemovedLineNumberBg: "#2d1f26",
564
- markdownText: "#c0caf5",
565
- markdownHeading: "#bb9af7",
566
- markdownLink: "#7aa2f7",
567
- markdownLinkText: "#7dcfff",
568
- markdownCode: "#9ece6a",
569
- markdownBlockQuote: "#e0af68",
570
- markdownEmph: "#e0af68",
571
- markdownStrong: "#ff966c",
572
- markdownHorizontalRule: "#565f89",
573
- markdownListItem: "#7aa2f7",
574
- markdownListEnumeration: "#7dcfff",
575
- markdownImage: "#7aa2f7",
576
- markdownImageText: "#7dcfff",
577
- markdownCodeBlock: "#c0caf5",
578
- syntaxComment: "#565f89",
579
- syntaxKeyword: "#bb9af7",
580
- syntaxFunction: "#7aa2f7",
581
- syntaxVariable: "#c0caf5",
582
- syntaxString: "#9ece6a",
583
- syntaxNumber: "#ff9e64",
584
- syntaxType: "#2ac3de",
585
- syntaxOperator: "#89ddff",
586
- syntaxPunctuation: "#a9b1d6",
587
- }
588
- return { name: DEFAULT_THEME_NAME, mode: DEFAULT_MODE, colors, syntax: buildSyntaxStyle(colors) }
589
- })()
590
-
591
- export const defaultTheme: Theme = hardcodedDefaultTheme
592
-
593
- // ---------------------------------------------------------------------------
594
- // Context
595
- // ---------------------------------------------------------------------------
596
-
597
- interface ThemeContextValue {
598
- theme: ResolvedTheme
599
- syntax: SyntaxStyle
600
- name: string
601
- mode: "dark" | "light"
602
- }
603
-
604
- const ThemeContext = createContext<ThemeContextValue>({
605
- theme: defaultTheme.colors,
606
- syntax: defaultTheme.syntax,
607
- name: defaultTheme.name,
608
- mode: defaultTheme.mode,
609
- })
610
-
611
- export interface ThemeProviderProps {
612
- /** Theme name (e.g. "tokyonight", "catppuccin", "dracula") */
613
- name?: string
614
- /** Color mode */
615
- mode?: "dark" | "light"
616
- /** Full Theme object (overrides name/mode if provided) */
617
- theme?: Theme
618
- children: ReactNode
619
- }
620
-
621
- export function ThemeProvider({ name, mode, theme: themeProp, children }: ThemeProviderProps) {
622
- const resolved = themeProp
623
- ? {
624
- theme: themeProp.colors,
625
- syntax: themeProp.syntax,
626
- name: themeProp.name,
627
- mode: themeProp.mode,
628
- }
629
- : (() => {
630
- const t = buildTheme(name ?? DEFAULT_THEME_NAME, mode ?? DEFAULT_MODE)
631
- return { theme: t.colors, syntax: t.syntax, name: t.name, mode: t.mode }
632
- })()
633
-
634
- return <ThemeContext.Provider value={resolved}>{children}</ThemeContext.Provider>
635
- }
636
-
637
- export function useTheme(): ThemeContextValue {
638
- return useContext(ThemeContext)
639
- }
640
-
641
- // ---------------------------------------------------------------------------
642
- // ThemeSwitcherProvider + useThemeSwitcher
643
- // ---------------------------------------------------------------------------
644
-
645
- interface ThemeSwitcherContextValue extends ThemeContextValue {
646
- nextTheme: () => void
647
- prevTheme: () => void
648
- setTheme: (name: string) => void
649
- allThemes: string[]
650
- }
651
-
652
- const ThemeSwitcherContext = createContext<ThemeSwitcherContextValue | null>(null)
653
-
654
- export interface ThemeSwitcherProviderProps {
655
- initialTheme?: string
656
- initialMode?: "dark" | "light"
657
- children: ReactNode
658
- }
659
-
660
- export function ThemeSwitcherProvider({
661
- initialTheme,
662
- initialMode,
663
- children,
664
- }: ThemeSwitcherProviderProps) {
665
- const allThemes = getThemeNames()
666
- const [themeName, setThemeName] = useState(initialTheme ?? DEFAULT_THEME_NAME)
667
- const [mode, _setMode] = useState<"dark" | "light">(initialMode ?? DEFAULT_MODE)
668
-
669
- const theme = buildTheme(themeName, mode)
670
-
671
- const nextTheme = useCallback(() => {
672
- setThemeName((current) => {
673
- const idx = allThemes.indexOf(current)
674
- const next = allThemes[(idx + 1) % allThemes.length]
675
- return next
676
- })
677
- }, [allThemes])
678
-
679
- const prevTheme = useCallback(() => {
680
- setThemeName((current) => {
681
- const idx = allThemes.indexOf(current)
682
- const prev = allThemes[(idx - 1 + allThemes.length) % allThemes.length]
683
- return prev
684
- })
685
- }, [allThemes])
686
-
687
- const setThemeByName = useCallback((name: string) => {
688
- setThemeName(name)
689
- }, [])
690
-
691
- // Persist when theme changes (but not on initial load)
692
- const isInitial = useRef(true)
693
- useEffect(() => {
694
- if (isInitial.current) {
695
- isInitial.current = false
696
- return
697
- }
698
- writeGlobalConfig({ theme: { name: themeName, mode } })
699
- }, [themeName, mode])
700
-
701
- const value: ThemeSwitcherContextValue = {
702
- theme: theme.colors,
703
- syntax: theme.syntax,
704
- name: theme.name,
705
- mode,
706
- nextTheme,
707
- prevTheme,
708
- setTheme: setThemeByName,
709
- allThemes,
710
- }
711
-
712
- return (
713
- <ThemeSwitcherContext.Provider value={value}>
714
- <ThemeContext.Provider
715
- value={{ theme: theme.colors, syntax: theme.syntax, name: theme.name, mode }}
716
- >
717
- {children}
718
- </ThemeContext.Provider>
719
- </ThemeSwitcherContext.Provider>
720
- )
721
- }
722
-
723
- export function useThemeSwitcher(): ThemeSwitcherContextValue {
724
- const ctx = useContext(ThemeSwitcherContext)
725
- if (!ctx) throw new Error("useThemeSwitcher must be used within ThemeSwitcherProvider")
726
- return ctx
727
- }