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