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