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