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