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