1package styles
2
3import (
4 "fmt"
5 "image/color"
6 "strings"
7 "sync"
8
9 "charm.land/bubbles/v2/filepicker"
10 "charm.land/bubbles/v2/help"
11 "charm.land/bubbles/v2/textarea"
12 "charm.land/bubbles/v2/textinput"
13 tea "charm.land/bubbletea/v2"
14 "charm.land/glamour/v2/ansi"
15 "charm.land/lipgloss/v2"
16 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
17 "github.com/charmbracelet/x/exp/charmtone"
18 "github.com/lucasb-eyer/go-colorful"
19 "github.com/rivo/uniseg"
20)
21
22const (
23 defaultListIndent = 2
24 defaultListLevelIndent = 4
25 defaultMargin = 2
26)
27
28type Theme struct {
29 Name string
30 IsDark bool
31
32 Primary color.Color
33 Secondary color.Color
34 Tertiary color.Color
35 Accent color.Color
36
37 BgBase color.Color
38 BgBaseLighter color.Color
39 BgSubtle color.Color
40 BgOverlay color.Color
41
42 FgBase color.Color
43 FgMuted color.Color
44 FgHalfMuted color.Color
45 FgSubtle color.Color
46 FgSelected color.Color
47
48 Border color.Color
49 BorderFocus color.Color
50
51 Success color.Color
52 Error color.Color
53 Warning color.Color
54 Info color.Color
55
56 // Colors
57 // White
58 White color.Color
59
60 // Blues
61 BlueLight color.Color
62 BlueDark color.Color
63 Blue color.Color
64
65 // Yellows
66 Yellow color.Color
67 Citron color.Color
68
69 // Greens
70 Green color.Color
71 GreenDark color.Color
72 GreenLight color.Color
73
74 // Reds
75 Red color.Color
76 RedDark color.Color
77 RedLight color.Color
78 Cherry color.Color
79
80 // Text selection.
81 TextSelection lipgloss.Style
82
83 // LSP and MCP status indicators.
84 ItemOfflineIcon lipgloss.Style
85 ItemBusyIcon lipgloss.Style
86 ItemErrorIcon lipgloss.Style
87 ItemOnlineIcon lipgloss.Style
88
89 // Editor: Yolo Mode.
90 YoloIconFocused lipgloss.Style
91 YoloIconBlurred lipgloss.Style
92 YoloDotsFocused lipgloss.Style
93 YoloDotsBlurred lipgloss.Style
94
95 // oAuth Chooser.
96 AuthBorderSelected lipgloss.Style
97 AuthTextSelected lipgloss.Style
98 AuthBorderUnselected lipgloss.Style
99 AuthTextUnselected lipgloss.Style
100
101 styles *Styles
102 stylesOnce sync.Once
103}
104
105type Styles struct {
106 Base lipgloss.Style
107 SelectedBase lipgloss.Style
108
109 Title lipgloss.Style
110 Subtitle lipgloss.Style
111 Text lipgloss.Style
112 TextSelected lipgloss.Style
113 Muted lipgloss.Style
114 Subtle lipgloss.Style
115
116 Success lipgloss.Style
117 Error lipgloss.Style
118 Warning lipgloss.Style
119 Info lipgloss.Style
120
121 // Markdown & Chroma
122 Markdown ansi.StyleConfig
123
124 // Inputs
125 TextInput textinput.Styles
126 TextArea textarea.Styles
127
128 // Help
129 Help help.Styles
130
131 // Diff
132 Diff diffview.Style
133
134 // FilePicker
135 FilePicker filepicker.Styles
136}
137
138func (t *Theme) S() *Styles {
139 t.stylesOnce.Do(func() {
140 t.styles = t.buildStyles()
141 })
142 return t.styles
143}
144
145func (t *Theme) buildStyles() *Styles {
146 base := lipgloss.NewStyle().
147 Foreground(t.FgBase)
148 return &Styles{
149 Base: base,
150
151 SelectedBase: base.Background(t.Primary),
152
153 Title: base.
154 Foreground(t.Accent).
155 Bold(true),
156
157 Subtitle: base.
158 Foreground(t.Secondary).
159 Bold(true),
160
161 Text: base,
162 TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
163
164 Muted: base.Foreground(t.FgMuted),
165
166 Subtle: base.Foreground(t.FgSubtle),
167
168 Success: base.Foreground(t.Success),
169
170 Error: base.Foreground(t.Error),
171
172 Warning: base.Foreground(t.Warning),
173
174 Info: base.Foreground(t.Info),
175
176 TextInput: textinput.Styles{
177 Focused: textinput.StyleState{
178 Text: base,
179 Placeholder: base.Foreground(t.FgSubtle),
180 Prompt: base.Foreground(t.Tertiary),
181 Suggestion: base.Foreground(t.FgSubtle),
182 },
183 Blurred: textinput.StyleState{
184 Text: base.Foreground(t.FgMuted),
185 Placeholder: base.Foreground(t.FgSubtle),
186 Prompt: base.Foreground(t.FgMuted),
187 Suggestion: base.Foreground(t.FgSubtle),
188 },
189 Cursor: textinput.CursorStyle{
190 Color: t.Secondary,
191 Shape: tea.CursorBlock,
192 Blink: true,
193 },
194 },
195 TextArea: textarea.Styles{
196 Focused: textarea.StyleState{
197 Base: base,
198 Text: base,
199 LineNumber: base.Foreground(t.FgSubtle),
200 CursorLine: base,
201 CursorLineNumber: base.Foreground(t.FgSubtle),
202 Placeholder: base.Foreground(t.FgSubtle),
203 Prompt: base.Foreground(t.Tertiary),
204 },
205 Blurred: textarea.StyleState{
206 Base: base,
207 Text: base.Foreground(t.FgMuted),
208 LineNumber: base.Foreground(t.FgMuted),
209 CursorLine: base,
210 CursorLineNumber: base.Foreground(t.FgMuted),
211 Placeholder: base.Foreground(t.FgSubtle),
212 Prompt: base.Foreground(t.FgMuted),
213 },
214 Cursor: textarea.CursorStyle{
215 Color: t.Secondary,
216 Shape: tea.CursorBlock,
217 Blink: true,
218 },
219 },
220
221 Markdown: ansi.StyleConfig{
222 Document: ansi.StyleBlock{
223 StylePrimitive: ansi.StylePrimitive{
224 // BlockPrefix: "\n",
225 // BlockSuffix: "\n",
226 Color: stringPtr(charmtone.Smoke.Hex()),
227 },
228 // Margin: uintPtr(defaultMargin),
229 },
230 BlockQuote: ansi.StyleBlock{
231 StylePrimitive: ansi.StylePrimitive{},
232 Indent: uintPtr(1),
233 IndentToken: stringPtr("│ "),
234 },
235 List: ansi.StyleList{
236 LevelIndent: defaultListIndent,
237 },
238 Heading: ansi.StyleBlock{
239 StylePrimitive: ansi.StylePrimitive{
240 BlockSuffix: "\n",
241 Color: stringPtr(charmtone.Malibu.Hex()),
242 Bold: boolPtr(true),
243 },
244 },
245 H1: ansi.StyleBlock{
246 StylePrimitive: ansi.StylePrimitive{
247 Prefix: " ",
248 Suffix: " ",
249 Color: stringPtr(charmtone.Zest.Hex()),
250 BackgroundColor: stringPtr(charmtone.Charple.Hex()),
251 Bold: boolPtr(true),
252 },
253 },
254 H2: ansi.StyleBlock{
255 StylePrimitive: ansi.StylePrimitive{
256 Prefix: "## ",
257 },
258 },
259 H3: ansi.StyleBlock{
260 StylePrimitive: ansi.StylePrimitive{
261 Prefix: "### ",
262 },
263 },
264 H4: ansi.StyleBlock{
265 StylePrimitive: ansi.StylePrimitive{
266 Prefix: "#### ",
267 },
268 },
269 H5: ansi.StyleBlock{
270 StylePrimitive: ansi.StylePrimitive{
271 Prefix: "##### ",
272 },
273 },
274 H6: ansi.StyleBlock{
275 StylePrimitive: ansi.StylePrimitive{
276 Prefix: "###### ",
277 Color: stringPtr(charmtone.Guac.Hex()),
278 Bold: boolPtr(false),
279 },
280 },
281 Strikethrough: ansi.StylePrimitive{
282 CrossedOut: boolPtr(true),
283 },
284 Emph: ansi.StylePrimitive{
285 Italic: boolPtr(true),
286 },
287 Strong: ansi.StylePrimitive{
288 Bold: boolPtr(true),
289 },
290 HorizontalRule: ansi.StylePrimitive{
291 Color: stringPtr(charmtone.Charcoal.Hex()),
292 Format: "\n--------\n",
293 },
294 Item: ansi.StylePrimitive{
295 BlockPrefix: "• ",
296 },
297 Enumeration: ansi.StylePrimitive{
298 BlockPrefix: ". ",
299 },
300 Task: ansi.StyleTask{
301 StylePrimitive: ansi.StylePrimitive{},
302 Ticked: "[✓] ",
303 Unticked: "[ ] ",
304 },
305 Link: ansi.StylePrimitive{
306 Color: stringPtr(charmtone.Zinc.Hex()),
307 Underline: boolPtr(true),
308 },
309 LinkText: ansi.StylePrimitive{
310 Color: stringPtr(charmtone.Guac.Hex()),
311 Bold: boolPtr(true),
312 },
313 Image: ansi.StylePrimitive{
314 Color: stringPtr(charmtone.Cheeky.Hex()),
315 Underline: boolPtr(true),
316 },
317 ImageText: ansi.StylePrimitive{
318 Color: stringPtr(charmtone.Squid.Hex()),
319 Format: "Image: {{.text}} →",
320 },
321 Code: ansi.StyleBlock{
322 StylePrimitive: ansi.StylePrimitive{
323 Prefix: " ",
324 Suffix: " ",
325 Color: stringPtr(charmtone.Coral.Hex()),
326 BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
327 },
328 },
329 CodeBlock: ansi.StyleCodeBlock{
330 StyleBlock: ansi.StyleBlock{
331 StylePrimitive: ansi.StylePrimitive{
332 Color: stringPtr(charmtone.Charcoal.Hex()),
333 },
334 Margin: uintPtr(defaultMargin),
335 },
336 Chroma: &ansi.Chroma{
337 Text: ansi.StylePrimitive{
338 Color: stringPtr(charmtone.Smoke.Hex()),
339 },
340 Error: ansi.StylePrimitive{
341 Color: stringPtr(charmtone.Butter.Hex()),
342 BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
343 },
344 Comment: ansi.StylePrimitive{
345 Color: stringPtr(charmtone.Oyster.Hex()),
346 },
347 CommentPreproc: ansi.StylePrimitive{
348 Color: stringPtr(charmtone.Bengal.Hex()),
349 },
350 Keyword: ansi.StylePrimitive{
351 Color: stringPtr(charmtone.Malibu.Hex()),
352 },
353 KeywordReserved: ansi.StylePrimitive{
354 Color: stringPtr(charmtone.Pony.Hex()),
355 },
356 KeywordNamespace: ansi.StylePrimitive{
357 Color: stringPtr(charmtone.Pony.Hex()),
358 },
359 KeywordType: ansi.StylePrimitive{
360 Color: stringPtr(charmtone.Guppy.Hex()),
361 },
362 Operator: ansi.StylePrimitive{
363 Color: stringPtr(charmtone.Salmon.Hex()),
364 },
365 Punctuation: ansi.StylePrimitive{
366 Color: stringPtr(charmtone.Zest.Hex()),
367 },
368 Name: ansi.StylePrimitive{
369 Color: stringPtr(charmtone.Smoke.Hex()),
370 },
371 NameBuiltin: ansi.StylePrimitive{
372 Color: stringPtr(charmtone.Cheeky.Hex()),
373 },
374 NameTag: ansi.StylePrimitive{
375 Color: stringPtr(charmtone.Mauve.Hex()),
376 },
377 NameAttribute: ansi.StylePrimitive{
378 Color: stringPtr(charmtone.Hazy.Hex()),
379 },
380 NameClass: ansi.StylePrimitive{
381 Color: stringPtr(charmtone.Salt.Hex()),
382 Underline: boolPtr(true),
383 Bold: boolPtr(true),
384 },
385 NameDecorator: ansi.StylePrimitive{
386 Color: stringPtr(charmtone.Citron.Hex()),
387 },
388 NameFunction: ansi.StylePrimitive{
389 Color: stringPtr(charmtone.Guac.Hex()),
390 },
391 LiteralNumber: ansi.StylePrimitive{
392 Color: stringPtr(charmtone.Julep.Hex()),
393 },
394 LiteralString: ansi.StylePrimitive{
395 Color: stringPtr(charmtone.Cumin.Hex()),
396 },
397 LiteralStringEscape: ansi.StylePrimitive{
398 Color: stringPtr(charmtone.Bok.Hex()),
399 },
400 GenericDeleted: ansi.StylePrimitive{
401 Color: stringPtr(charmtone.Coral.Hex()),
402 },
403 GenericEmph: ansi.StylePrimitive{
404 Italic: boolPtr(true),
405 },
406 GenericInserted: ansi.StylePrimitive{
407 Color: stringPtr(charmtone.Guac.Hex()),
408 },
409 GenericStrong: ansi.StylePrimitive{
410 Bold: boolPtr(true),
411 },
412 GenericSubheading: ansi.StylePrimitive{
413 Color: stringPtr(charmtone.Squid.Hex()),
414 },
415 Background: ansi.StylePrimitive{
416 BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
417 },
418 },
419 },
420 Table: ansi.StyleTable{
421 StyleBlock: ansi.StyleBlock{
422 StylePrimitive: ansi.StylePrimitive{},
423 },
424 },
425 DefinitionDescription: ansi.StylePrimitive{
426 BlockPrefix: "\n ",
427 },
428 },
429
430 Help: help.Styles{
431 ShortKey: base.Foreground(t.FgMuted),
432 ShortDesc: base.Foreground(t.FgSubtle),
433 ShortSeparator: base.Foreground(t.Border),
434 Ellipsis: base.Foreground(t.Border),
435 FullKey: base.Foreground(t.FgMuted),
436 FullDesc: base.Foreground(t.FgSubtle),
437 FullSeparator: base.Foreground(t.Border),
438 },
439
440 Diff: diffview.Style{
441 DividerLine: diffview.LineStyle{
442 LineNumber: lipgloss.NewStyle().
443 Foreground(t.FgHalfMuted).
444 Background(t.BgBaseLighter),
445 Code: lipgloss.NewStyle().
446 Foreground(t.FgHalfMuted).
447 Background(t.BgBaseLighter),
448 },
449 MissingLine: diffview.LineStyle{
450 LineNumber: lipgloss.NewStyle().
451 Background(t.BgBaseLighter),
452 Code: lipgloss.NewStyle().
453 Background(t.BgBaseLighter),
454 },
455 EqualLine: diffview.LineStyle{
456 LineNumber: lipgloss.NewStyle().
457 Foreground(t.FgMuted).
458 Background(t.BgBase),
459 Code: lipgloss.NewStyle().
460 Foreground(t.FgMuted).
461 Background(t.BgBase),
462 },
463 InsertLine: diffview.LineStyle{
464 LineNumber: lipgloss.NewStyle().
465 Foreground(lipgloss.Color("#629657")).
466 Background(lipgloss.Color("#2b322a")),
467 Symbol: lipgloss.NewStyle().
468 Foreground(lipgloss.Color("#629657")).
469 Background(lipgloss.Color("#323931")),
470 Code: lipgloss.NewStyle().
471 Background(lipgloss.Color("#323931")),
472 },
473 DeleteLine: diffview.LineStyle{
474 LineNumber: lipgloss.NewStyle().
475 Foreground(lipgloss.Color("#a45c59")).
476 Background(lipgloss.Color("#312929")),
477 Symbol: lipgloss.NewStyle().
478 Foreground(lipgloss.Color("#a45c59")).
479 Background(lipgloss.Color("#383030")),
480 Code: lipgloss.NewStyle().
481 Background(lipgloss.Color("#383030")),
482 },
483 },
484 FilePicker: filepicker.Styles{
485 DisabledCursor: base.Foreground(t.FgMuted),
486 Cursor: base.Foreground(t.FgBase),
487 Symlink: base.Foreground(t.FgSubtle),
488 Directory: base.Foreground(t.Primary),
489 File: base.Foreground(t.FgBase),
490 DisabledFile: base.Foreground(t.FgMuted),
491 DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
492 Permission: base.Foreground(t.FgMuted),
493 Selected: base.Background(t.Primary).Foreground(t.FgBase),
494 FileSize: base.Foreground(t.FgMuted),
495 EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
496 },
497 }
498}
499
500type Manager struct {
501 themes map[string]*Theme
502 current *Theme
503}
504
505var (
506 defaultManager *Manager
507 defaultManagerOnce sync.Once
508)
509
510func initDefaultManager() *Manager {
511 defaultManagerOnce.Do(func() {
512 defaultManager = newManager()
513 })
514 return defaultManager
515}
516
517func SetDefaultManager(m *Manager) {
518 defaultManager = m
519}
520
521func DefaultManager() *Manager {
522 return initDefaultManager()
523}
524
525func CurrentTheme() *Theme {
526 return initDefaultManager().Current()
527}
528
529func newManager() *Manager {
530 m := &Manager{
531 themes: make(map[string]*Theme),
532 }
533
534 t := NewCharmtoneTheme() // default theme
535 m.Register(t)
536 m.current = m.themes[t.Name]
537
538 return m
539}
540
541func (m *Manager) Register(theme *Theme) {
542 m.themes[theme.Name] = theme
543}
544
545func (m *Manager) Current() *Theme {
546 return m.current
547}
548
549func (m *Manager) SetTheme(name string) error {
550 if theme, ok := m.themes[name]; ok {
551 m.current = theme
552 return nil
553 }
554 return fmt.Errorf("theme %s not found", name)
555}
556
557func (m *Manager) List() []string {
558 names := make([]string, 0, len(m.themes))
559 for name := range m.themes {
560 names = append(names, name)
561 }
562 return names
563}
564
565// ParseHex converts hex string to color
566func ParseHex(hex string) color.Color {
567 var r, g, b uint8
568 fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
569 return color.RGBA{R: r, G: g, B: b, A: 255}
570}
571
572// Alpha returns a color with transparency
573func Alpha(c color.Color, alpha uint8) color.Color {
574 r, g, b, _ := c.RGBA()
575 return color.RGBA{
576 R: uint8(r >> 8),
577 G: uint8(g >> 8),
578 B: uint8(b >> 8),
579 A: alpha,
580 }
581}
582
583// Darken makes a color darker by percentage (0-100)
584func Darken(c color.Color, percent float64) color.Color {
585 r, g, b, a := c.RGBA()
586 factor := 1.0 - percent/100.0
587 return color.RGBA{
588 R: uint8(float64(r>>8) * factor),
589 G: uint8(float64(g>>8) * factor),
590 B: uint8(float64(b>>8) * factor),
591 A: uint8(a >> 8),
592 }
593}
594
595// Lighten makes a color lighter by percentage (0-100)
596func Lighten(c color.Color, percent float64) color.Color {
597 r, g, b, a := c.RGBA()
598 factor := percent / 100.0
599 return color.RGBA{
600 R: uint8(min(255, float64(r>>8)+255*factor)),
601 G: uint8(min(255, float64(g>>8)+255*factor)),
602 B: uint8(min(255, float64(b>>8)+255*factor)),
603 A: uint8(a >> 8),
604 }
605}
606
607func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string {
608 if input == "" {
609 return []string{""}
610 }
611 t := CurrentTheme()
612 if len(input) == 1 {
613 style := t.S().Base.Foreground(color1)
614 if bold {
615 style.Bold(true)
616 }
617 return []string{style.Render(input)}
618 }
619 var clusters []string
620 gr := uniseg.NewGraphemes(input)
621 for gr.Next() {
622 clusters = append(clusters, string(gr.Runes()))
623 }
624
625 ramp := blendColors(len(clusters), color1, color2)
626 for i, c := range ramp {
627 style := t.S().Base.Foreground(c)
628 if bold {
629 style.Bold(true)
630 }
631 clusters[i] = style.Render(clusters[i])
632 }
633 return clusters
634}
635
636// ApplyForegroundGrad renders a given string with a horizontal gradient
637// foreground.
638func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
639 if input == "" {
640 return ""
641 }
642 var o strings.Builder
643 clusters := ForegroundGrad(input, false, color1, color2)
644 for _, c := range clusters {
645 fmt.Fprint(&o, c)
646 }
647 return o.String()
648}
649
650// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
651// foreground.
652func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
653 if input == "" {
654 return ""
655 }
656 var o strings.Builder
657 clusters := ForegroundGrad(input, true, color1, color2)
658 for _, c := range clusters {
659 fmt.Fprint(&o, c)
660 }
661 return o.String()
662}
663
664// blendColors returns a slice of colors blended between the given keys.
665// Blending is done in Hcl to stay in gamut.
666func blendColors(size int, stops ...color.Color) []color.Color {
667 if len(stops) < 2 {
668 return nil
669 }
670
671 stopsPrime := make([]colorful.Color, len(stops))
672 for i, k := range stops {
673 stopsPrime[i], _ = colorful.MakeColor(k)
674 }
675
676 numSegments := len(stopsPrime) - 1
677 blended := make([]color.Color, 0, size)
678
679 // Calculate how many colors each segment should have.
680 segmentSizes := make([]int, numSegments)
681 baseSize := size / numSegments
682 remainder := size % numSegments
683
684 // Distribute the remainder across segments.
685 for i := range numSegments {
686 segmentSizes[i] = baseSize
687 if i < remainder {
688 segmentSizes[i]++
689 }
690 }
691
692 // Generate colors for each segment.
693 for i := range numSegments {
694 c1 := stopsPrime[i]
695 c2 := stopsPrime[i+1]
696 segmentSize := segmentSizes[i]
697
698 for j := range segmentSize {
699 var t float64
700 if segmentSize > 1 {
701 t = float64(j) / float64(segmentSize-1)
702 }
703 c := c1.BlendHcl(c2, t)
704 blended = append(blended, c)
705 }
706 }
707
708 return blended
709}