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/lucasb-eyer/go-colorful"
17 "github.com/rivo/uniseg"
18)
19
20const (
21 defaultListIndent = 2
22 defaultListLevelIndent = 4
23 defaultMargin = 2
24)
25
26type Theme struct {
27 Name string
28 IsDark bool
29
30 Primary color.Color
31 Secondary color.Color
32 Tertiary color.Color
33 Accent color.Color
34
35 BgBase color.Color
36 BgBaseLighter color.Color
37 BgSubtle color.Color
38 BgOverlay color.Color
39
40 FgBase color.Color
41 FgMuted color.Color
42 FgHalfMuted color.Color
43 FgSubtle color.Color
44 FgSelected color.Color
45
46 Border color.Color
47 BorderFocus color.Color
48
49 Success color.Color
50 Error color.Color
51 Warning color.Color
52 Info color.Color
53
54 // Colors
55 // White
56 White color.Color
57
58 // Blues
59 BlueLight color.Color
60 Blue color.Color
61
62 // Yellows
63 Yellow color.Color
64
65 // Greens
66 Green color.Color
67 GreenDark color.Color
68 GreenLight color.Color
69
70 // Reds
71 Red color.Color
72 RedDark color.Color
73 RedLight color.Color
74
75 styles *Styles
76}
77
78type Styles struct {
79 Base lipgloss.Style
80 SelectedBase lipgloss.Style
81
82 Title lipgloss.Style
83 Subtitle lipgloss.Style
84 Text lipgloss.Style
85 TextSelected lipgloss.Style
86 Muted lipgloss.Style
87 Subtle lipgloss.Style
88
89 Success lipgloss.Style
90 Error lipgloss.Style
91 Warning lipgloss.Style
92 Info lipgloss.Style
93
94 // Markdown & Chroma
95 Markdown ansi.StyleConfig
96
97 // Inputs
98 TextInput textinput.Styles
99 TextArea textarea.Styles
100
101 // Help
102 Help help.Styles
103
104 // Diff
105 Diff diffview.Style
106
107 // FilePicker
108 FilePicker filepicker.Styles
109}
110
111func (t *Theme) S() *Styles {
112 if t.styles == nil {
113 t.styles = t.buildStyles()
114 }
115 return t.styles
116}
117
118func (t *Theme) buildStyles() *Styles {
119 base := lipgloss.NewStyle().
120 Foreground(t.FgBase)
121 return &Styles{
122 Base: base,
123
124 SelectedBase: base.Background(t.Primary),
125
126 Title: base.
127 Foreground(t.Accent).
128 Bold(true),
129
130 Subtitle: base.
131 Foreground(t.Secondary).
132 Bold(true),
133
134 Text: base,
135 TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
136
137 Muted: base.Foreground(t.FgMuted),
138
139 Subtle: base.Foreground(t.FgSubtle),
140
141 Success: base.Foreground(t.Success),
142
143 Error: base.Foreground(t.Error),
144
145 Warning: base.Foreground(t.Warning),
146
147 Info: base.Foreground(t.Info),
148
149 TextInput: textinput.Styles{
150 Focused: textinput.StyleState{
151 Text: base,
152 Placeholder: base.Foreground(t.FgMuted),
153 Prompt: base.Foreground(t.Tertiary),
154 Suggestion: base.Foreground(t.FgMuted),
155 },
156 Blurred: textinput.StyleState{
157 Text: base.Foreground(t.FgMuted),
158 Placeholder: base.Foreground(t.FgMuted),
159 Prompt: base.Foreground(t.FgMuted),
160 Suggestion: base.Foreground(t.FgMuted),
161 },
162 Cursor: textinput.CursorStyle{
163 Color: t.Secondary,
164 Shape: tea.CursorBar,
165 Blink: true,
166 },
167 },
168 TextArea: textarea.Styles{
169 Focused: textarea.StyleState{
170 Base: base,
171 Text: base,
172 LineNumber: base.Foreground(t.FgSubtle),
173 CursorLine: base,
174 CursorLineNumber: base.Foreground(t.FgSubtle),
175 Placeholder: base.Foreground(t.FgMuted),
176 Prompt: base.Foreground(t.Tertiary),
177 },
178 Blurred: textarea.StyleState{
179 Base: base,
180 Text: base.Foreground(t.FgMuted),
181 LineNumber: base.Foreground(t.FgMuted),
182 CursorLine: base,
183 CursorLineNumber: base.Foreground(t.FgMuted),
184 Placeholder: base.Foreground(t.FgMuted),
185 Prompt: base.Foreground(t.FgMuted),
186 },
187 Cursor: textarea.CursorStyle{
188 Color: t.Secondary,
189 Shape: tea.CursorBar,
190 Blink: true,
191 },
192 },
193
194 // TODO: update using the colors and add colors if missing
195 Markdown: ansi.StyleConfig{
196 Document: ansi.StyleBlock{
197 StylePrimitive: ansi.StylePrimitive{
198 // BlockPrefix: "\n",
199 // BlockSuffix: "\n",
200 Color: stringPtr("252"),
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("39"),
216 Bold: boolPtr(true),
217 },
218 },
219 H1: ansi.StyleBlock{
220 StylePrimitive: ansi.StylePrimitive{
221 Prefix: " ",
222 Suffix: " ",
223 Color: stringPtr("228"),
224 BackgroundColor: stringPtr("63"),
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("35"),
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("240"),
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("30"),
281 Underline: boolPtr(true),
282 },
283 LinkText: ansi.StylePrimitive{
284 Color: stringPtr("35"),
285 Bold: boolPtr(true),
286 },
287 Image: ansi.StylePrimitive{
288 Color: stringPtr("212"),
289 Underline: boolPtr(true),
290 },
291 ImageText: ansi.StylePrimitive{
292 Color: stringPtr("243"),
293 Format: "Image: {{.text}} →",
294 },
295 Code: ansi.StyleBlock{
296 StylePrimitive: ansi.StylePrimitive{
297 Prefix: " ",
298 Suffix: " ",
299 Color: stringPtr("203"),
300 BackgroundColor: stringPtr("236"),
301 },
302 },
303 CodeBlock: ansi.StyleCodeBlock{
304 StyleBlock: ansi.StyleBlock{
305 StylePrimitive: ansi.StylePrimitive{
306 Color: stringPtr("244"),
307 },
308 Margin: uintPtr(defaultMargin),
309 },
310 Chroma: &ansi.Chroma{
311 Text: ansi.StylePrimitive{
312 Color: stringPtr("#C4C4C4"),
313 },
314 Error: ansi.StylePrimitive{
315 Color: stringPtr("#F1F1F1"),
316 BackgroundColor: stringPtr("#F05B5B"),
317 },
318 Comment: ansi.StylePrimitive{
319 Color: stringPtr("#676767"),
320 },
321 CommentPreproc: ansi.StylePrimitive{
322 Color: stringPtr("#FF875F"),
323 },
324 Keyword: ansi.StylePrimitive{
325 Color: stringPtr("#00AAFF"),
326 },
327 KeywordReserved: ansi.StylePrimitive{
328 Color: stringPtr("#FF5FD2"),
329 },
330 KeywordNamespace: ansi.StylePrimitive{
331 Color: stringPtr("#FF5F87"),
332 },
333 KeywordType: ansi.StylePrimitive{
334 Color: stringPtr("#6E6ED8"),
335 },
336 Operator: ansi.StylePrimitive{
337 Color: stringPtr("#EF8080"),
338 },
339 Punctuation: ansi.StylePrimitive{
340 Color: stringPtr("#E8E8A8"),
341 },
342 Name: ansi.StylePrimitive{
343 Color: stringPtr("#C4C4C4"),
344 },
345 NameBuiltin: ansi.StylePrimitive{
346 Color: stringPtr("#FF8EC7"),
347 },
348 NameTag: ansi.StylePrimitive{
349 Color: stringPtr("#B083EA"),
350 },
351 NameAttribute: ansi.StylePrimitive{
352 Color: stringPtr("#7A7AE6"),
353 },
354 NameClass: ansi.StylePrimitive{
355 Color: stringPtr("#F1F1F1"),
356 Underline: boolPtr(true),
357 Bold: boolPtr(true),
358 },
359 NameDecorator: ansi.StylePrimitive{
360 Color: stringPtr("#FFFF87"),
361 },
362 NameFunction: ansi.StylePrimitive{
363 Color: stringPtr("#00D787"),
364 },
365 LiteralNumber: ansi.StylePrimitive{
366 Color: stringPtr("#6EEFC0"),
367 },
368 LiteralString: ansi.StylePrimitive{
369 Color: stringPtr("#C69669"),
370 },
371 LiteralStringEscape: ansi.StylePrimitive{
372 Color: stringPtr("#AFFFD7"),
373 },
374 GenericDeleted: ansi.StylePrimitive{
375 Color: stringPtr("#FD5B5B"),
376 },
377 GenericEmph: ansi.StylePrimitive{
378 Italic: boolPtr(true),
379 },
380 GenericInserted: ansi.StylePrimitive{
381 Color: stringPtr("#00D787"),
382 },
383 GenericStrong: ansi.StylePrimitive{
384 Bold: boolPtr(true),
385 },
386 GenericSubheading: ansi.StylePrimitive{
387 Color: stringPtr("#777777"),
388 },
389 Background: ansi.StylePrimitive{
390 BackgroundColor: stringPtr("#373737"),
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}