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