1package tui
2
3import (
4 "crypto/sha256"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/alecthomas/chroma/v2"
15 "github.com/alecthomas/chroma/v2/styles"
16)
17
18// ThemeOption is one selectable theme discovered under $ZUT_HOME/themes.
19// Value is stored in config.json.
20type ThemeOption struct {
21 Value string
22 Label string
23 Description string
24 Path string
25 Builtin bool
26}
27
28// ThemeFile is the user-editable JSON shape loaded from
29// $ZUT_HOME/themes/*.json. It carries metadata plus separate overrides
30// for dark and light terminals.
31type ThemeFile struct {
32 Name string `json:"name"`
33 Description string `json:"description"`
34 Colors ThemeFileColorModes `json:"colors"`
35 Overrides ThemeOverrides `json:"-"`
36}
37
38func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
39 type alias ThemeFile
40 var a alias
41 if err := json.Unmarshal(data, &a); err != nil {
42 return err
43 }
44 // Allow a tiny theme file with overrides at the top level, e.g.
45 // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
46 // Metadata fields are ignored by ThemeOverrides because they do not
47 // have matching json tags.
48 if err := json.Unmarshal(data, &a.Overrides); err != nil {
49 return fmt.Errorf("theme overrides: %w", err)
50 }
51 *tf = ThemeFile(a)
52 return validateThemeFile(*tf)
53}
54
55type ThemeFileColorModes struct {
56 Base ThemeOverrides `json:"-"`
57 Dark ThemeOverrides `json:"dark"`
58 Light ThemeOverrides `json:"light"`
59 HasDark bool `json:"-"`
60 HasLight bool `json:"-"`
61}
62
63func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
64 var raw map[string]json.RawMessage
65 if err := json.Unmarshal(data, &raw); err != nil {
66 return err
67 }
68 if v, ok := raw["dark"]; ok {
69 m.HasDark = true
70 if err := json.Unmarshal(v, &m.Dark); err != nil {
71 return fmt.Errorf("dark overrides: %w", err)
72 }
73 }
74 if v, ok := raw["light"]; ok {
75 m.HasLight = true
76 if err := json.Unmarshal(v, &m.Light); err != nil {
77 return fmt.Errorf("light overrides: %w", err)
78 }
79 }
80 // Also allow colors to directly contain overrides shared by both
81 // modes, e.g. {"colors":{"accent":204}}.
82 var base ThemeOverrides
83 if err := json.Unmarshal(data, &base); err != nil {
84 return fmt.Errorf("shared overrides: %w", err)
85 }
86 m.Base = base
87 return nil
88}
89
90// ThemeOverrides is intentionally pointer-based so a theme file can
91// override only the colors it cares about and inherit the built-in
92// dark/light defaults for everything else.
93type ThemeOverrides struct {
94 FG *TerminalColorValue `json:"fg,omitempty"`
95 Muted *TerminalColorValue `json:"muted,omitempty"`
96 Accent *TerminalColorValue `json:"accent,omitempty"`
97 Background *TerminalColorValue `json:"background,omitempty"`
98 User *TerminalColorValue `json:"user,omitempty"`
99 UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`
100 UserBubbleFG *TerminalColorValue `json:"user_bubble_fg,omitempty"`
101 Assistant *TerminalColorValue `json:"assistant,omitempty"`
102 Tool *TerminalColorValue `json:"tool,omitempty"`
103 ToolOut *TerminalColorValue `json:"tool_out,omitempty"`
104 Error *TerminalColorValue `json:"error,omitempty"`
105 Warning *TerminalColorValue `json:"warning,omitempty"`
106 Spinner *TerminalColorValue `json:"spinner,omitempty"`
107 ThinkingMax *TerminalColorValue `json:"thinking_max,omitempty"`
108 ThinkingMaxCamel *TerminalColorValue `json:"thinkingMax,omitempty"`
109 SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
110 SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
111 SpinnerFrames []string `json:"spinner_frames,omitempty"`
112 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
113 SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
114 Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
115}
116
117type SyntaxThemeOverrides struct {
118 Keyword *string `json:"keyword,omitempty"`
119 KeywordConstant *string `json:"keyword_constant,omitempty"`
120 KeywordDeclaration *string `json:"keyword_declaration,omitempty"`
121 KeywordNamespace *string `json:"keyword_namespace,omitempty"`
122 KeywordReserved *string `json:"keyword_reserved,omitempty"`
123 KeywordType *string `json:"keyword_type,omitempty"`
124 NameBuiltin *string `json:"name_builtin,omitempty"`
125 NameFunction *string `json:"name_function,omitempty"`
126 NameClass *string `json:"name_class,omitempty"`
127 NameDecorator *string `json:"name_decorator,omitempty"`
128 LiteralString *string `json:"literal_string,omitempty"`
129 LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
130 LiteralNumber *string `json:"literal_number,omitempty"`
131 Comment *string `json:"comment,omitempty"`
132 CommentPreproc *string `json:"comment_preproc,omitempty"`
133 Operator *string `json:"operator,omitempty"`
134 Punctuation *string `json:"punctuation,omitempty"`
135 Text *string `json:"text,omitempty"`
136}
137
138// TerminalColorValue accepts any of these JSON forms:
139//
140// 24 // xterm-256 color index
141// "#42454b" // RGB hex
142// {"mode":"ansi","index":100}
143// {"mode":"rgb","r":66,"g":69,"b":75}
144// {"mode":"256","index":254}
145type TerminalColorValue struct {
146 TerminalColor
147}
148
149func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
150 var index int
151 if err := json.Unmarshal(data, &index); err == nil {
152 c.TerminalColor = Color256(index)
153 return validateExplicitTerminalColor(c.TerminalColor)
154 }
155 var s string
156 if err := json.Unmarshal(data, &s); err == nil {
157 if rgb, ok := parseHexColor(s); ok {
158 c.TerminalColor = rgb
159 return validateExplicitTerminalColor(c.TerminalColor)
160 }
161 return fmt.Errorf("invalid terminal color %q", s)
162 }
163 var obj struct {
164 Mode string `json:"mode"`
165 Index int `json:"index"`
166 R int `json:"r"`
167 G int `json:"g"`
168 B int `json:"b"`
169 }
170 if err := json.Unmarshal(data, &obj); err != nil {
171 return err
172 }
173 switch strings.ToLower(obj.Mode) {
174 case "", "256", "color256", "xterm256":
175 c.TerminalColor = Color256(obj.Index)
176 case "ansi":
177 c.TerminalColor = ColorANSI(obj.Index)
178 case "rgb", "truecolor":
179 c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
180 default:
181 return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
182 }
183 return validateExplicitTerminalColor(c.TerminalColor)
184}
185
186// TerminalTheme constructs zut's adaptive default from terminal-owned
187// defaults and ANSI slots. It never chooses a fixed built-in palette merely
188// because a terminal reports a light background.
189func TerminalTheme(profile TerminalProfile) Theme {
190 t := Theme{
191 FG: TerminalDefault(),
192 Muted: TerminalPaletteSlot(8),
193 Accent: TerminalPaletteSlot(12),
194 User: TerminalPaletteSlot(13),
195 UserBubbleBG: TerminalPaletteSlot(8),
196 UserBubbleFG: TerminalDefault(),
197 Assistant: TerminalPaletteSlot(12),
198 Tool: TerminalPaletteSlot(10),
199 ToolOut: TerminalPaletteSlot(8),
200 Error: TerminalPaletteSlot(9),
201 Warning: TerminalPaletteSlot(11),
202 Spinner: TerminalPaletteSlot(13),
203 ThinkingMax: TerminalPaletteSlot(13),
204 SelectionBG: TerminalPaletteSlot(4),
205 SelectionFG: TerminalDefault(),
206 UseTerminalPalette: true,
207 Terminal: profile,
208 SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),
209 SpinnerIntervalMS: 80,
210 // Terminal syntax is resolved directly from semantic terminal roles.
211 // Keep JSON syntax fields empty until a custom theme explicitly sets one.
212 Syntax: SyntaxTheme{},
213 }
214 if profile.HasForeground && profile.HasBackground {
215 t.Muted = t.DimColor(profile.Foreground, 45)
216 t.ToolOut = t.Muted
217 t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
218 t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
219 }
220 // Auto must leave both the terminal background and cursor color owned by
221 // the terminal. Custom themes can intentionally override Background.
222 t.Background = nil
223 return t
224}
225
226func withTerminalProfile(base, detected Theme) Theme {
227 base.Terminal = detected.Terminal
228 return base
229}
230
231func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
232 fromRGB, _ := rgbForTerminalColor(from)
233 toRGB, _ := rgbForTerminalColor(to)
234 percent = clampPercent(percent)
235 return ColorRGB(
236 blendChannel(fromRGB[0], toRGB[0], percent),
237 blendChannel(fromRGB[1], toRGB[1], percent),
238 blendChannel(fromRGB[2], toRGB[2], percent),
239 )
240}
241
242const maxThemeFileSize = 1 << 20
243
244// ThemeSource is an immutable, fully validated custom theme revision. Runtime
245// profile changes resolve this value without touching the filesystem again.
246type ThemeSource struct {
247 Name string
248 Path string
249 Digest [sha256.Size]byte
250 File ThemeFile
251}
252
253// ThemeResolution is the pure result of resolving a preference against one
254// terminal snapshot and, for custom preferences, one accepted source file.
255type ThemeResolution struct {
256 Theme Theme
257 Name string
258}
259
260// ThemePreference separates the persisted setting from the process-only
261// ZUT_THEME override. Only dark/light force a running session.
262type ThemePreference struct {
263 Persisted string
264 Effective string
265 Forced bool
266}
267
268func ResolveThemePreference(persisted, env string) ThemePreference {
269 preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
270 switch strings.ToLower(strings.TrimSpace(env)) {
271 case "dark", "light":
272 preference.Effective = strings.ToLower(strings.TrimSpace(env))
273 preference.Forced = true
274 case "", "auto":
275 // Persisted setting remains effective.
276 }
277 if preference.Effective == "" {
278 preference.Effective = "auto"
279 }
280 return preference
281}
282
283// LoadThemeSource resolves and validates a custom source. Built-in selections
284// have no source and return nil, nil. The file limit prevents a polling reload
285// from allocating an unbounded partial write.
286func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
287 path, err := resolveThemePath(zutHome, preference)
288 if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
289 return nil, err
290 }
291 info, err := os.Stat(path)
292 if err != nil {
293 return nil, fmt.Errorf("theme %s: %w", path, err)
294 }
295 if info.Size() > maxThemeFileSize {
296 return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
297 }
298 b, err := os.ReadFile(path)
299 if err != nil {
300 return nil, fmt.Errorf("read theme %s: %w", path, err)
301 }
302 if len(b) > maxThemeFileSize {
303 return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
304 }
305 var file ThemeFile
306 if err := json.Unmarshal(b, &file); err != nil {
307 return nil, fmt.Errorf("parse theme %s: %w", path, err)
308 }
309 if file.Name == "" {
310 file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
311 }
312 return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
313}
314
315// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
316// so omitted roles continue to follow the controlling terminal.
317func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
318 preference = strings.ToLower(strings.TrimSpace(preference))
319 switch preference {
320 case "", "auto", "default", "system":
321 return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
322 case "dark":
323 return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
324 case "light":
325 return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
326 }
327 if source == nil {
328 return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
329 }
330 base := TerminalTheme(profile)
331 base = applyThemeOverrides(base, source.File.Overrides)
332 base = applyThemeOverrides(base, source.File.Colors.Base)
333 if profileIsLight(profile) {
334 if source.File.Colors.HasLight {
335 base = applyThemeOverrides(base, source.File.Colors.Light)
336 } else if source.File.Colors.HasDark {
337 base = applyThemeOverrides(base, source.File.Colors.Dark)
338 }
339 } else if source.File.Colors.HasDark {
340 base = applyThemeOverrides(base, source.File.Colors.Dark)
341 } else if source.File.Colors.HasLight {
342 base = applyThemeOverrides(base, source.File.Colors.Light)
343 }
344 return ThemeResolution{Theme: base, Name: source.Name}
345}
346
347func profileIsLight(profile TerminalProfile) bool {
348 if profile.SchemeKnown {
349 return profile.Light
350 }
351 if profile.HasBackground {
352 r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
353 return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
354 }
355 return false
356}
357
358// DetectThemeWithCustom performs the initial bounded profile query then uses
359// the same loader and pure resolver as runtime selection.
360func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
361 detected := DetectThemeFromBackground(timeout)
362 return LoadThemeFromHome(zutHome, preferred, detected)
363}
364
365// LoadThemeFromHome is retained as the startup convenience wrapper. New
366// runtime code should keep the ThemeSource and call ResolveTheme directly.
367func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
368 profile := detected.Terminal
369 source, err := LoadThemeSource(zutHome, preferred)
370 if err != nil {
371 return TerminalTheme(profile), "", err
372 }
373 returned := ResolveTheme(preferred, source, profile)
374 return returned.Theme, returned.Name, nil
375}
376
377// AvailableThemes returns built-in and user-installed themes suitable
378// for a settings picker. Invalid JSON files are skipped.
379func AvailableThemes(zutHome string) []ThemeOption {
380 out := []ThemeOption{
381 {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true},
382 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
383 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
384 }
385 seen := map[string]bool{"auto": true, "dark": true, "light": true}
386 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
387 sort.Strings(paths)
388 for _, path := range paths {
389 b, err := os.ReadFile(path)
390 if err != nil {
391 continue
392 }
393 var tf ThemeFile
394 if err := json.Unmarshal(b, &tf); err != nil {
395 continue
396 }
397 value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
398 if value == "" || seen[value] {
399 continue
400 }
401 desc := tf.Description
402 if desc == "" {
403 desc = path
404 }
405 out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
406 seen[value] = true
407 }
408 return out
409}
410
411// ThemeOptionFromFile parses one theme JSON file for picker display.
412// value is what will be stored in config; pass an absolute path for
413// extension-owned themes so they can be loaded without copying into
414// $ZUT_HOME/themes.
415func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
416 b, err := os.ReadFile(path)
417 if err != nil {
418 return ThemeOption{}, false
419 }
420 var tf ThemeFile
421 if err := json.Unmarshal(b, &tf); err != nil {
422 return ThemeOption{}, false
423 }
424 if value == "" {
425 value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
426 }
427 label := value
428 if tf.Name != "" {
429 label = tf.Name
430 }
431 desc := tf.Description
432 if source != "" {
433 if desc != "" {
434 desc = "from " + source + " — " + desc
435 } else {
436 desc = "from " + source
437 }
438 }
439 if desc == "" {
440 desc = path
441 }
442 return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
443}
444
445func ThemeExists(zutHome, preferred string) bool {
446 path, err := resolveThemePath(zutHome, preferred)
447 return err == nil && path != ""
448}
449
450func resolveThemePath(zutHome, preferred string) (string, error) {
451 preferred = strings.TrimSpace(preferred)
452 switch strings.ToLower(preferred) {
453 case "", "auto", "default", "system":
454 return "", nil
455 case "dark":
456 return "builtin:dark", nil
457 case "light":
458 return "builtin:light", nil
459 }
460 if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
461 candidates := []string{preferred}
462 if filepath.Ext(preferred) == "" {
463 candidates = append(candidates,
464 filepath.Join(zutHome, "themes", preferred+".json"),
465 )
466 } else if !filepath.IsAbs(preferred) {
467 candidates = append(candidates,
468 filepath.Join(zutHome, "themes", preferred),
469 )
470 }
471 for _, c := range candidates {
472 if _, err := os.Stat(c); err == nil {
473 return c, nil
474 }
475 }
476 return "", fmt.Errorf("theme %q not found", preferred)
477 }
478
479 return "", nil
480}
481
482func themeFilesIn(dir string) ([]string, error) {
483 entries, err := os.ReadDir(dir)
484 if errors.Is(err, os.ErrNotExist) {
485 return nil, nil
486 }
487 if err != nil {
488 return nil, err
489 }
490 var out []string
491 for _, e := range entries {
492 if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
493 continue
494 }
495 out = append(out, filepath.Join(dir, e.Name()))
496 }
497 return out, nil
498}
499
500func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
501 if o.FG != nil {
502 th.FG = o.FG.TerminalColor
503 }
504 if o.Muted != nil {
505 th.Muted = o.Muted.TerminalColor
506 }
507 if o.Accent != nil {
508 th.Accent = o.Accent.TerminalColor
509 }
510 if o.Background != nil {
511 bg := o.Background.TerminalColor
512 th.Background = &bg
513 }
514 if o.User != nil {
515 th.User = o.User.TerminalColor
516 }
517 if o.UserBubbleBG != nil {
518 th.UserBubbleBG = o.UserBubbleBG.TerminalColor
519 }
520 if o.UserBubbleFG != nil {
521 th.UserBubbleFG = o.UserBubbleFG.TerminalColor
522 }
523 if o.Assistant != nil {
524 th.Assistant = o.Assistant.TerminalColor
525 }
526 if o.Tool != nil {
527 th.Tool = o.Tool.TerminalColor
528 }
529 if o.ToolOut != nil {
530 th.ToolOut = o.ToolOut.TerminalColor
531 }
532 if o.Error != nil {
533 th.Error = o.Error.TerminalColor
534 }
535 if o.Warning != nil {
536 th.Warning = o.Warning.TerminalColor
537 }
538 if o.Spinner != nil {
539 th.Spinner = o.Spinner.TerminalColor
540 }
541 if o.ThinkingMax != nil {
542 th.ThinkingMax = o.ThinkingMax.TerminalColor
543 } else if o.ThinkingMaxCamel != nil {
544 th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
545 }
546 if o.SelectionBG != nil {
547 th.SelectionBG = o.SelectionBG.TerminalColor
548 }
549 if o.SelectionFG != nil {
550 th.SelectionFG = o.SelectionFG.TerminalColor
551 }
552 if len(o.SpinnerFrames) > 0 {
553 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
554 }
555 if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
556 th.SpinnerIntervalMS = *o.SpinnerIntervalMS
557 }
558 if o.SyntaxBaseStyle != nil {
559 th.SyntaxBaseStyle = *o.SyntaxBaseStyle
560 }
561 th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
562 return th
563}
564
565func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
566 if o.Keyword != nil {
567 s.Keyword = *o.Keyword
568 }
569 if o.KeywordConstant != nil {
570 s.KeywordConstant = *o.KeywordConstant
571 }
572 if o.KeywordDeclaration != nil {
573 s.KeywordDeclaration = *o.KeywordDeclaration
574 }
575 if o.KeywordNamespace != nil {
576 s.KeywordNamespace = *o.KeywordNamespace
577 }
578 if o.KeywordReserved != nil {
579 s.KeywordReserved = *o.KeywordReserved
580 }
581 if o.KeywordType != nil {
582 s.KeywordType = *o.KeywordType
583 }
584 if o.NameBuiltin != nil {
585 s.NameBuiltin = *o.NameBuiltin
586 }
587 if o.NameFunction != nil {
588 s.NameFunction = *o.NameFunction
589 }
590 if o.NameClass != nil {
591 s.NameClass = *o.NameClass
592 }
593 if o.NameDecorator != nil {
594 s.NameDecorator = *o.NameDecorator
595 }
596 if o.LiteralString != nil {
597 s.LiteralString = *o.LiteralString
598 }
599 if o.LiteralStringEscape != nil {
600 s.LiteralStringEscape = *o.LiteralStringEscape
601 }
602 if o.LiteralNumber != nil {
603 s.LiteralNumber = *o.LiteralNumber
604 }
605 if o.Comment != nil {
606 s.Comment = *o.Comment
607 }
608 if o.CommentPreproc != nil {
609 s.CommentPreproc = *o.CommentPreproc
610 }
611 if o.Operator != nil {
612 s.Operator = *o.Operator
613 }
614 if o.Punctuation != nil {
615 s.Punctuation = *o.Punctuation
616 }
617 if o.Text != nil {
618 s.Text = *o.Text
619 }
620 return s
621}
622
623func validateExplicitTerminalColor(color TerminalColor) error {
624 switch color.Mode {
625 case terminalColor256:
626 if color.Index < 0 || color.Index > 255 {
627 return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
628 }
629 case terminalColorANSI:
630 if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
631 return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
632 }
633 case terminalColorRGB:
634 if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
635 return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
636 }
637 default:
638 return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
639 }
640 return nil
641}
642
643func validateThemeFile(file ThemeFile) error {
644 for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
645 if err := validateThemeOverrides(overrides); err != nil {
646 return err
647 }
648 }
649 return nil
650}
651
652func validateThemeOverrides(o ThemeOverrides) error {
653 for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} {
654 if color != nil {
655 if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
656 return err
657 }
658 }
659 }
660 if len(o.SpinnerFrames) > 64 {
661 return errors.New("spinner_frames has more than 64 entries")
662 }
663 for _, frame := range o.SpinnerFrames {
664 if frame == "" || len([]rune(frame)) > 16 {
665 return fmt.Errorf("invalid spinner frame %q", frame)
666 }
667 }
668 if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
669 return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
670 }
671 if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
672 if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
673 return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
674 }
675 }
676 for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} {
677 if entry == nil {
678 continue
679 }
680 if _, err := chroma.ParseStyleEntry(*entry); err != nil {
681 return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
682 }
683 }
684 return nil
685}
686
687func parseHexColor(s string) (TerminalColor, bool) {
688 s = strings.TrimPrefix(strings.TrimSpace(s), "#")
689 if len(s) != 6 {
690 return TerminalColor{}, false
691 }
692 var rgb [3]int
693 for i := 0; i < 3; i++ {
694 v, ok := parseHexByte(s[i*2 : i*2+2])
695 if !ok {
696 return TerminalColor{}, false
697 }
698 rgb[i] = v
699 }
700 return ColorRGB(rgb[0], rgb[1], rgb[2]), true
701}
702
703func parseHexByte(s string) (int, bool) {
704 var n int
705 for _, r := range s {
706 n *= 16
707 switch {
708 case r >= '0' && r <= '9':
709 n += int(r - '0')
710 case r >= 'a' && r <= 'f':
711 n += int(r-'a') + 10
712 case r >= 'A' && r <= 'F':
713 n += int(r-'A') + 10
714 default:
715 return 0, false
716 }
717 }
718 return n, true
719}