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