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