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