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