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/glamour/v2/ansi"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/lucasb-eyer/go-colorful"
16 "github.com/rivo/uniseg"
17)
18
19const (
20 defaultListIndent = 2
21 defaultListLevelIndent = 4
22 defaultMargin = 2
23)
24
25type Theme struct {
26 Name string
27 IsDark bool
28
29 Primary color.Color
30 Secondary color.Color
31 Tertiary color.Color
32 Accent color.Color
33
34 BgBase color.Color
35 BgSubtle color.Color
36 BgOverlay color.Color
37
38 FgBase color.Color
39 FgMuted color.Color
40 FgHalfMuted color.Color
41 FgSubtle color.Color
42 FgSelected color.Color
43
44 Border color.Color
45 BorderFocus color.Color
46
47 Success color.Color
48 Error color.Color
49 Warning color.Color
50 Info color.Color
51
52 // Colors
53 // White
54 White color.Color
55 // Blues
56 Blue color.Color
57
58 // Greens
59 Green color.Color
60 GreenDark color.Color
61 GreenLight color.Color
62
63 // Reds
64 Red color.Color
65 RedDark color.Color
66 RedLight color.Color
67
68 // TODO: add any others needed
69
70 styles *Styles
71}
72
73type Diff struct {
74 Added color.Color
75 Removed color.Color
76 Context color.Color
77 HunkHeader color.Color
78 HighlightAdded color.Color
79 HighlightRemoved color.Color
80 AddedBg color.Color
81 RemovedBg color.Color
82 ContextBg color.Color
83 LineNumber color.Color
84 AddedLineNumberBg color.Color
85 RemovedLineNumberBg color.Color
86}
87
88type Styles struct {
89 Base lipgloss.Style
90 SelectedBase lipgloss.Style
91
92 Title lipgloss.Style
93 Subtitle lipgloss.Style
94 Text lipgloss.Style
95 TextSelected lipgloss.Style
96 Muted lipgloss.Style
97 Subtle lipgloss.Style
98
99 Success lipgloss.Style
100 Error lipgloss.Style
101 Warning lipgloss.Style
102 Info lipgloss.Style
103
104 // Markdown & Chroma
105 Markdown ansi.StyleConfig
106
107 // Inputs
108 TextInput textinput.Styles
109 TextArea textarea.Styles
110
111 // Help
112 Help help.Styles
113
114 // Diff
115 Diff Diff
116
117 // FilePicker
118 FilePicker filepicker.Styles
119}
120
121func (t *Theme) S() *Styles {
122 if t.styles == nil {
123 t.styles = t.buildStyles()
124 }
125 return t.styles
126}
127
128func (t *Theme) buildStyles() *Styles {
129 base := lipgloss.NewStyle().
130 Foreground(t.FgBase)
131 return &Styles{
132 Base: base,
133
134 SelectedBase: base.Background(t.Primary),
135
136 Title: base.
137 Foreground(t.Accent).
138 Bold(true),
139
140 Subtitle: base.
141 Foreground(t.Secondary).
142 Bold(true),
143
144 Text: base,
145 TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
146
147 Muted: base.Foreground(t.FgMuted),
148
149 Subtle: base.Foreground(t.FgSubtle),
150
151 Success: base.Foreground(t.Success),
152
153 Error: base.Foreground(t.Error),
154
155 Warning: base.Foreground(t.Warning),
156
157 Info: base.Foreground(t.Info),
158
159 TextInput: textinput.Styles{
160 Focused: textinput.StyleState{
161 Text: base,
162 Placeholder: base.Foreground(t.FgMuted),
163 Prompt: base.Foreground(t.Tertiary),
164 Suggestion: base.Foreground(t.FgMuted),
165 },
166 Blurred: textinput.StyleState{
167 Text: base.Foreground(t.FgMuted),
168 Placeholder: base.Foreground(t.FgMuted),
169 Prompt: base.Foreground(t.FgMuted),
170 Suggestion: base.Foreground(t.FgMuted),
171 },
172 Cursor: textinput.CursorStyle{
173 Color: t.Secondary,
174 Shape: tea.CursorBar,
175 Blink: true,
176 },
177 },
178 TextArea: textarea.Styles{
179 Focused: textarea.StyleState{
180 Base: base,
181 Text: base,
182 LineNumber: base.Foreground(t.FgSubtle),
183 CursorLine: base,
184 CursorLineNumber: base.Foreground(t.FgSubtle),
185 Placeholder: base.Foreground(t.FgMuted),
186 Prompt: base.Foreground(t.Tertiary),
187 },
188 Blurred: textarea.StyleState{
189 Base: base,
190 Text: base.Foreground(t.FgMuted),
191 LineNumber: base.Foreground(t.FgMuted),
192 CursorLine: base,
193 CursorLineNumber: base.Foreground(t.FgMuted),
194 Placeholder: base.Foreground(t.FgMuted),
195 Prompt: base.Foreground(t.FgMuted),
196 },
197 Cursor: textarea.CursorStyle{
198 Color: t.Secondary,
199 Shape: tea.CursorBar,
200 Blink: true,
201 },
202 },
203
204 // TODO: update using the colors and add colors if missing
205 Markdown: ansi.StyleConfig{
206 Document: ansi.StyleBlock{
207 StylePrimitive: ansi.StylePrimitive{
208 // BlockPrefix: "\n",
209 // BlockSuffix: "\n",
210 Color: stringPtr("252"),
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("39"),
226 Bold: boolPtr(true),
227 },
228 },
229 H1: ansi.StyleBlock{
230 StylePrimitive: ansi.StylePrimitive{
231 Prefix: " ",
232 Suffix: " ",
233 Color: stringPtr("228"),
234 BackgroundColor: stringPtr("63"),
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("35"),
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("240"),
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("30"),
291 Underline: boolPtr(true),
292 },
293 LinkText: ansi.StylePrimitive{
294 Color: stringPtr("35"),
295 Bold: boolPtr(true),
296 },
297 Image: ansi.StylePrimitive{
298 Color: stringPtr("212"),
299 Underline: boolPtr(true),
300 },
301 ImageText: ansi.StylePrimitive{
302 Color: stringPtr("243"),
303 Format: "Image: {{.text}} →",
304 },
305 Code: ansi.StyleBlock{
306 StylePrimitive: ansi.StylePrimitive{
307 Prefix: " ",
308 Suffix: " ",
309 Color: stringPtr("203"),
310 BackgroundColor: stringPtr("236"),
311 },
312 },
313 CodeBlock: ansi.StyleCodeBlock{
314 StyleBlock: ansi.StyleBlock{
315 StylePrimitive: ansi.StylePrimitive{
316 Color: stringPtr("244"),
317 },
318 Margin: uintPtr(defaultMargin),
319 },
320 Chroma: &ansi.Chroma{
321 Text: ansi.StylePrimitive{
322 Color: stringPtr("#C4C4C4"),
323 },
324 Error: ansi.StylePrimitive{
325 Color: stringPtr("#F1F1F1"),
326 BackgroundColor: stringPtr("#F05B5B"),
327 },
328 Comment: ansi.StylePrimitive{
329 Color: stringPtr("#676767"),
330 },
331 CommentPreproc: ansi.StylePrimitive{
332 Color: stringPtr("#FF875F"),
333 },
334 Keyword: ansi.StylePrimitive{
335 Color: stringPtr("#00AAFF"),
336 },
337 KeywordReserved: ansi.StylePrimitive{
338 Color: stringPtr("#FF5FD2"),
339 },
340 KeywordNamespace: ansi.StylePrimitive{
341 Color: stringPtr("#FF5F87"),
342 },
343 KeywordType: ansi.StylePrimitive{
344 Color: stringPtr("#6E6ED8"),
345 },
346 Operator: ansi.StylePrimitive{
347 Color: stringPtr("#EF8080"),
348 },
349 Punctuation: ansi.StylePrimitive{
350 Color: stringPtr("#E8E8A8"),
351 },
352 Name: ansi.StylePrimitive{
353 Color: stringPtr("#C4C4C4"),
354 },
355 NameBuiltin: ansi.StylePrimitive{
356 Color: stringPtr("#FF8EC7"),
357 },
358 NameTag: ansi.StylePrimitive{
359 Color: stringPtr("#B083EA"),
360 },
361 NameAttribute: ansi.StylePrimitive{
362 Color: stringPtr("#7A7AE6"),
363 },
364 NameClass: ansi.StylePrimitive{
365 Color: stringPtr("#F1F1F1"),
366 Underline: boolPtr(true),
367 Bold: boolPtr(true),
368 },
369 NameDecorator: ansi.StylePrimitive{
370 Color: stringPtr("#FFFF87"),
371 },
372 NameFunction: ansi.StylePrimitive{
373 Color: stringPtr("#00D787"),
374 },
375 LiteralNumber: ansi.StylePrimitive{
376 Color: stringPtr("#6EEFC0"),
377 },
378 LiteralString: ansi.StylePrimitive{
379 Color: stringPtr("#C69669"),
380 },
381 LiteralStringEscape: ansi.StylePrimitive{
382 Color: stringPtr("#AFFFD7"),
383 },
384 GenericDeleted: ansi.StylePrimitive{
385 Color: stringPtr("#FD5B5B"),
386 },
387 GenericEmph: ansi.StylePrimitive{
388 Italic: boolPtr(true),
389 },
390 GenericInserted: ansi.StylePrimitive{
391 Color: stringPtr("#00D787"),
392 },
393 GenericStrong: ansi.StylePrimitive{
394 Bold: boolPtr(true),
395 },
396 GenericSubheading: ansi.StylePrimitive{
397 Color: stringPtr("#777777"),
398 },
399 Background: ansi.StylePrimitive{
400 BackgroundColor: stringPtr("#373737"),
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 // TODO: Fix this this is bad
425 Diff: Diff{
426 Added: t.Green,
427 Removed: t.Red,
428 Context: t.FgSubtle,
429 HunkHeader: t.FgSubtle,
430 HighlightAdded: t.GreenLight,
431 HighlightRemoved: t.RedLight,
432 AddedBg: t.GreenDark,
433 RemovedBg: t.RedDark,
434 ContextBg: t.BgSubtle,
435 LineNumber: t.FgMuted,
436 AddedLineNumberBg: t.GreenDark,
437 RemovedLineNumberBg: t.RedDark,
438 },
439
440 FilePicker: filepicker.Styles{
441 DisabledCursor: base.Foreground(t.FgMuted),
442 Cursor: base.Foreground(t.FgBase),
443 Symlink: base.Foreground(t.FgSubtle),
444 Directory: base.Foreground(t.Primary),
445 File: base.Foreground(t.FgBase),
446 DisabledFile: base.Foreground(t.FgMuted),
447 DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
448 Permission: base.Foreground(t.FgMuted),
449 Selected: base.Background(t.Primary).Foreground(t.FgBase),
450 FileSize: base.Foreground(t.FgMuted),
451 EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
452 },
453 }
454}
455
456type Manager struct {
457 themes map[string]*Theme
458 current *Theme
459}
460
461var defaultManager *Manager
462
463func SetDefaultManager(m *Manager) {
464 defaultManager = m
465}
466
467func DefaultManager() *Manager {
468 if defaultManager == nil {
469 defaultManager = NewManager("crush")
470 }
471 return defaultManager
472}
473
474func CurrentTheme() *Theme {
475 if defaultManager == nil {
476 defaultManager = NewManager("crush")
477 }
478 return defaultManager.Current()
479}
480
481func NewManager(defaultTheme string) *Manager {
482 m := &Manager{
483 themes: make(map[string]*Theme),
484 }
485
486 m.Register(NewCrushTheme())
487
488 m.current = m.themes[defaultTheme]
489
490 return m
491}
492
493func (m *Manager) Register(theme *Theme) {
494 m.themes[theme.Name] = theme
495}
496
497func (m *Manager) Current() *Theme {
498 return m.current
499}
500
501func (m *Manager) SetTheme(name string) error {
502 if theme, ok := m.themes[name]; ok {
503 m.current = theme
504 return nil
505 }
506 return fmt.Errorf("theme %s not found", name)
507}
508
509func (m *Manager) List() []string {
510 names := make([]string, 0, len(m.themes))
511 for name := range m.themes {
512 names = append(names, name)
513 }
514 return names
515}
516
517// ParseHex converts hex string to color
518func ParseHex(hex string) color.Color {
519 var r, g, b uint8
520 fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
521 return color.RGBA{R: r, G: g, B: b, A: 255}
522}
523
524// Alpha returns a color with transparency
525func Alpha(c color.Color, alpha uint8) color.Color {
526 r, g, b, _ := c.RGBA()
527 return color.RGBA{
528 R: uint8(r >> 8),
529 G: uint8(g >> 8),
530 B: uint8(b >> 8),
531 A: alpha,
532 }
533}
534
535// Darken makes a color darker by percentage (0-100)
536func Darken(c color.Color, percent float64) color.Color {
537 r, g, b, a := c.RGBA()
538 factor := 1.0 - percent/100.0
539 return color.RGBA{
540 R: uint8(float64(r>>8) * factor),
541 G: uint8(float64(g>>8) * factor),
542 B: uint8(float64(b>>8) * factor),
543 A: uint8(a >> 8),
544 }
545}
546
547// Lighten makes a color lighter by percentage (0-100)
548func Lighten(c color.Color, percent float64) color.Color {
549 r, g, b, a := c.RGBA()
550 factor := percent / 100.0
551 return color.RGBA{
552 R: uint8(min(255, float64(r>>8)+255*factor)),
553 G: uint8(min(255, float64(g>>8)+255*factor)),
554 B: uint8(min(255, float64(b>>8)+255*factor)),
555 A: uint8(a >> 8),
556 }
557}
558
559// ApplyForegroundGrad renders a given string with a horizontal gradient
560// foreground.
561func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
562 if input == "" {
563 return ""
564 }
565
566 var o strings.Builder
567 if len(input) == 1 {
568 return lipgloss.NewStyle().Foreground(color1).Render(input)
569 }
570
571 var clusters []string
572 gr := uniseg.NewGraphemes(input)
573 for gr.Next() {
574 clusters = append(clusters, string(gr.Runes()))
575 }
576
577 ramp := blendColors(len(clusters), color1, color2)
578 for i, c := range ramp {
579 fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
580 }
581
582 return o.String()
583}
584
585// blendColors returns a slice of colors blended between the given keys.
586// Blending is done in Hcl to stay in gamut.
587func blendColors(size int, stops ...color.Color) []color.Color {
588 if len(stops) < 2 {
589 return nil
590 }
591
592 stopsPrime := make([]colorful.Color, len(stops))
593 for i, k := range stops {
594 stopsPrime[i], _ = colorful.MakeColor(k)
595 }
596
597 numSegments := len(stopsPrime) - 1
598 blended := make([]color.Color, 0, size)
599
600 // Calculate how many colors each segment should have.
601 segmentSizes := make([]int, numSegments)
602 baseSize := size / numSegments
603 remainder := size % numSegments
604
605 // Distribute the remainder across segments.
606 for i := range numSegments {
607 segmentSizes[i] = baseSize
608 if i < remainder {
609 segmentSizes[i]++
610 }
611 }
612
613 // Generate colors for each segment.
614 for i := range numSegments {
615 c1 := stopsPrime[i]
616 c2 := stopsPrime[i+1]
617 segmentSize := segmentSizes[i]
618
619 for j := range segmentSize {
620 var t float64
621 if segmentSize > 1 {
622 t = float64(j) / float64(segmentSize-1)
623 }
624 c := c1.BlendHcl(c2, t)
625 blended = append(blended, c)
626 }
627 }
628
629 return blended
630}