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