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 // Blues
54 Blue color.Color
55
56 // Greens
57 Green color.Color
58 GreenDark color.Color
59 GreenLight color.Color
60
61 // Reds
62 Red color.Color
63 RedDark color.Color
64 RedLight color.Color
65
66 // TODO: add any others needed
67
68 styles *Styles
69}
70
71type Diff struct {
72 Added color.Color
73 Removed color.Color
74 Context color.Color
75 HunkHeader color.Color
76 HighlightAdded color.Color
77 HighlightRemoved color.Color
78 AddedBg color.Color
79 RemovedBg color.Color
80 ContextBg color.Color
81 LineNumber color.Color
82 AddedLineNumberBg color.Color
83 RemovedLineNumberBg color.Color
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 Diff
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.FgMuted),
161 Prompt: base.Foreground(t.Tertiary),
162 Suggestion: base.Foreground(t.FgMuted),
163 },
164 Blurred: textinput.StyleState{
165 Text: base.Foreground(t.FgMuted),
166 Placeholder: base.Foreground(t.FgMuted),
167 Prompt: base.Foreground(t.FgMuted),
168 Suggestion: base.Foreground(t.FgMuted),
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.FgMuted),
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.FgMuted),
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 // TODO: update using the colors and add colors if missing
203 Markdown: ansi.StyleConfig{
204 Document: ansi.StyleBlock{
205 StylePrimitive: ansi.StylePrimitive{
206 // BlockPrefix: "\n",
207 // BlockSuffix: "\n",
208 Color: stringPtr("252"),
209 },
210 // Margin: uintPtr(defaultMargin),
211 },
212 BlockQuote: ansi.StyleBlock{
213 StylePrimitive: ansi.StylePrimitive{},
214 Indent: uintPtr(1),
215 IndentToken: stringPtr("│ "),
216 },
217 List: ansi.StyleList{
218 LevelIndent: defaultListIndent,
219 },
220 Heading: ansi.StyleBlock{
221 StylePrimitive: ansi.StylePrimitive{
222 BlockSuffix: "\n",
223 Color: stringPtr("39"),
224 Bold: boolPtr(true),
225 },
226 },
227 H1: ansi.StyleBlock{
228 StylePrimitive: ansi.StylePrimitive{
229 Prefix: " ",
230 Suffix: " ",
231 Color: stringPtr("228"),
232 BackgroundColor: stringPtr("63"),
233 Bold: boolPtr(true),
234 },
235 },
236 H2: ansi.StyleBlock{
237 StylePrimitive: ansi.StylePrimitive{
238 Prefix: "## ",
239 },
240 },
241 H3: ansi.StyleBlock{
242 StylePrimitive: ansi.StylePrimitive{
243 Prefix: "### ",
244 },
245 },
246 H4: ansi.StyleBlock{
247 StylePrimitive: ansi.StylePrimitive{
248 Prefix: "#### ",
249 },
250 },
251 H5: ansi.StyleBlock{
252 StylePrimitive: ansi.StylePrimitive{
253 Prefix: "##### ",
254 },
255 },
256 H6: ansi.StyleBlock{
257 StylePrimitive: ansi.StylePrimitive{
258 Prefix: "###### ",
259 Color: stringPtr("35"),
260 Bold: boolPtr(false),
261 },
262 },
263 Strikethrough: ansi.StylePrimitive{
264 CrossedOut: boolPtr(true),
265 },
266 Emph: ansi.StylePrimitive{
267 Italic: boolPtr(true),
268 },
269 Strong: ansi.StylePrimitive{
270 Bold: boolPtr(true),
271 },
272 HorizontalRule: ansi.StylePrimitive{
273 Color: stringPtr("240"),
274 Format: "\n--------\n",
275 },
276 Item: ansi.StylePrimitive{
277 BlockPrefix: "• ",
278 },
279 Enumeration: ansi.StylePrimitive{
280 BlockPrefix: ". ",
281 },
282 Task: ansi.StyleTask{
283 StylePrimitive: ansi.StylePrimitive{},
284 Ticked: "[✓] ",
285 Unticked: "[ ] ",
286 },
287 Link: ansi.StylePrimitive{
288 Color: stringPtr("30"),
289 Underline: boolPtr(true),
290 },
291 LinkText: ansi.StylePrimitive{
292 Color: stringPtr("35"),
293 Bold: boolPtr(true),
294 },
295 Image: ansi.StylePrimitive{
296 Color: stringPtr("212"),
297 Underline: boolPtr(true),
298 },
299 ImageText: ansi.StylePrimitive{
300 Color: stringPtr("243"),
301 Format: "Image: {{.text}} →",
302 },
303 Code: ansi.StyleBlock{
304 StylePrimitive: ansi.StylePrimitive{
305 Prefix: " ",
306 Suffix: " ",
307 Color: stringPtr("203"),
308 BackgroundColor: stringPtr("236"),
309 },
310 },
311 CodeBlock: ansi.StyleCodeBlock{
312 StyleBlock: ansi.StyleBlock{
313 StylePrimitive: ansi.StylePrimitive{
314 Color: stringPtr("244"),
315 },
316 Margin: uintPtr(defaultMargin),
317 },
318 Chroma: &ansi.Chroma{
319 Text: ansi.StylePrimitive{
320 Color: stringPtr("#C4C4C4"),
321 },
322 Error: ansi.StylePrimitive{
323 Color: stringPtr("#F1F1F1"),
324 BackgroundColor: stringPtr("#F05B5B"),
325 },
326 Comment: ansi.StylePrimitive{
327 Color: stringPtr("#676767"),
328 },
329 CommentPreproc: ansi.StylePrimitive{
330 Color: stringPtr("#FF875F"),
331 },
332 Keyword: ansi.StylePrimitive{
333 Color: stringPtr("#00AAFF"),
334 },
335 KeywordReserved: ansi.StylePrimitive{
336 Color: stringPtr("#FF5FD2"),
337 },
338 KeywordNamespace: ansi.StylePrimitive{
339 Color: stringPtr("#FF5F87"),
340 },
341 KeywordType: ansi.StylePrimitive{
342 Color: stringPtr("#6E6ED8"),
343 },
344 Operator: ansi.StylePrimitive{
345 Color: stringPtr("#EF8080"),
346 },
347 Punctuation: ansi.StylePrimitive{
348 Color: stringPtr("#E8E8A8"),
349 },
350 Name: ansi.StylePrimitive{
351 Color: stringPtr("#C4C4C4"),
352 },
353 NameBuiltin: ansi.StylePrimitive{
354 Color: stringPtr("#FF8EC7"),
355 },
356 NameTag: ansi.StylePrimitive{
357 Color: stringPtr("#B083EA"),
358 },
359 NameAttribute: ansi.StylePrimitive{
360 Color: stringPtr("#7A7AE6"),
361 },
362 NameClass: ansi.StylePrimitive{
363 Color: stringPtr("#F1F1F1"),
364 Underline: boolPtr(true),
365 Bold: boolPtr(true),
366 },
367 NameDecorator: ansi.StylePrimitive{
368 Color: stringPtr("#FFFF87"),
369 },
370 NameFunction: ansi.StylePrimitive{
371 Color: stringPtr("#00D787"),
372 },
373 LiteralNumber: ansi.StylePrimitive{
374 Color: stringPtr("#6EEFC0"),
375 },
376 LiteralString: ansi.StylePrimitive{
377 Color: stringPtr("#C69669"),
378 },
379 LiteralStringEscape: ansi.StylePrimitive{
380 Color: stringPtr("#AFFFD7"),
381 },
382 GenericDeleted: ansi.StylePrimitive{
383 Color: stringPtr("#FD5B5B"),
384 },
385 GenericEmph: ansi.StylePrimitive{
386 Italic: boolPtr(true),
387 },
388 GenericInserted: ansi.StylePrimitive{
389 Color: stringPtr("#00D787"),
390 },
391 GenericStrong: ansi.StylePrimitive{
392 Bold: boolPtr(true),
393 },
394 GenericSubheading: ansi.StylePrimitive{
395 Color: stringPtr("#777777"),
396 },
397 Background: ansi.StylePrimitive{
398 BackgroundColor: stringPtr("#373737"),
399 },
400 },
401 },
402 Table: ansi.StyleTable{
403 StyleBlock: ansi.StyleBlock{
404 StylePrimitive: ansi.StylePrimitive{},
405 },
406 },
407 DefinitionDescription: ansi.StylePrimitive{
408 BlockPrefix: "\n ",
409 },
410 },
411
412 Help: help.Styles{
413 ShortKey: base.Foreground(t.FgMuted),
414 ShortDesc: base.Foreground(t.FgSubtle),
415 ShortSeparator: base.Foreground(t.Border),
416 Ellipsis: base.Foreground(t.Border),
417 FullKey: base.Foreground(t.FgMuted),
418 FullDesc: base.Foreground(t.FgSubtle),
419 FullSeparator: base.Foreground(t.Border),
420 },
421
422 // TODO: Fix this this is bad
423 Diff: Diff{
424 Added: t.Green,
425 Removed: t.Red,
426 Context: t.FgSubtle,
427 HunkHeader: t.FgSubtle,
428 HighlightAdded: t.GreenLight,
429 HighlightRemoved: t.RedLight,
430 AddedBg: t.GreenDark,
431 RemovedBg: t.RedDark,
432 ContextBg: t.BgSubtle,
433 LineNumber: t.FgMuted,
434 AddedLineNumberBg: t.GreenDark,
435 RemovedLineNumberBg: t.RedDark,
436 },
437
438 FilePicker: filepicker.Styles{
439 DisabledCursor: base.Foreground(t.FgMuted),
440 Cursor: base.Foreground(t.FgBase),
441 Symlink: base.Foreground(t.FgSubtle),
442 Directory: base.Foreground(t.Primary),
443 File: base.Foreground(t.FgBase),
444 DisabledFile: base.Foreground(t.FgMuted),
445 DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
446 Permission: base.Foreground(t.FgMuted),
447 Selected: base.Background(t.Primary).Foreground(t.FgBase),
448 FileSize: base.Foreground(t.FgMuted),
449 EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
450 },
451 }
452}
453
454type Manager struct {
455 themes map[string]*Theme
456 current *Theme
457}
458
459var defaultManager *Manager
460
461func SetDefaultManager(m *Manager) {
462 defaultManager = m
463}
464
465func DefaultManager() *Manager {
466 if defaultManager == nil {
467 defaultManager = NewManager("crush")
468 }
469 return defaultManager
470}
471
472func CurrentTheme() *Theme {
473 if defaultManager == nil {
474 defaultManager = NewManager("crush")
475 }
476 return defaultManager.Current()
477}
478
479func NewManager(defaultTheme string) *Manager {
480 m := &Manager{
481 themes: make(map[string]*Theme),
482 }
483
484 m.Register(NewCrushTheme())
485
486 m.current = m.themes[defaultTheme]
487
488 return m
489}
490
491func (m *Manager) Register(theme *Theme) {
492 m.themes[theme.Name] = theme
493}
494
495func (m *Manager) Current() *Theme {
496 return m.current
497}
498
499func (m *Manager) SetTheme(name string) error {
500 if theme, ok := m.themes[name]; ok {
501 m.current = theme
502 return nil
503 }
504 return fmt.Errorf("theme %s not found", name)
505}
506
507func (m *Manager) List() []string {
508 names := make([]string, 0, len(m.themes))
509 for name := range m.themes {
510 names = append(names, name)
511 }
512 return names
513}
514
515// ParseHex converts hex string to color
516func ParseHex(hex string) color.Color {
517 var r, g, b uint8
518 fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
519 return color.RGBA{R: r, G: g, B: b, A: 255}
520}
521
522// Alpha returns a color with transparency
523func Alpha(c color.Color, alpha uint8) color.Color {
524 r, g, b, _ := c.RGBA()
525 return color.RGBA{
526 R: uint8(r >> 8),
527 G: uint8(g >> 8),
528 B: uint8(b >> 8),
529 A: alpha,
530 }
531}
532
533// Darken makes a color darker by percentage (0-100)
534func Darken(c color.Color, percent float64) color.Color {
535 r, g, b, a := c.RGBA()
536 factor := 1.0 - percent/100.0
537 return color.RGBA{
538 R: uint8(float64(r>>8) * factor),
539 G: uint8(float64(g>>8) * factor),
540 B: uint8(float64(b>>8) * factor),
541 A: uint8(a >> 8),
542 }
543}
544
545// Lighten makes a color lighter by percentage (0-100)
546func Lighten(c color.Color, percent float64) color.Color {
547 r, g, b, a := c.RGBA()
548 factor := percent / 100.0
549 return color.RGBA{
550 R: uint8(min(255, float64(r>>8)+255*factor)),
551 G: uint8(min(255, float64(g>>8)+255*factor)),
552 B: uint8(min(255, float64(b>>8)+255*factor)),
553 A: uint8(a >> 8),
554 }
555}
556
557// ApplyForegroundGrad renders a given string with a horizontal gradient
558// foreground.
559func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
560 if input == "" {
561 return ""
562 }
563
564 var o strings.Builder
565 if len(input) == 1 {
566 return lipgloss.NewStyle().Foreground(color1).Render(input)
567 }
568
569 var clusters []string
570 gr := uniseg.NewGraphemes(input)
571 for gr.Next() {
572 clusters = append(clusters, string(gr.Runes()))
573 }
574
575 ramp := blendColors(len(clusters), color1, color2)
576 for i, c := range ramp {
577 fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
578 }
579
580 return o.String()
581}
582
583// blendColors returns a slice of colors blended between the given keys.
584// Blending is done in Hcl to stay in gamut.
585func blendColors(size int, stops ...color.Color) []color.Color {
586 if len(stops) < 2 {
587 return nil
588 }
589
590 stopsPrime := make([]colorful.Color, len(stops))
591 for i, k := range stops {
592 stopsPrime[i], _ = colorful.MakeColor(k)
593 }
594
595 numSegments := len(stopsPrime) - 1
596 blended := make([]color.Color, 0, size)
597
598 // Calculate how many colors each segment should have.
599 segmentSizes := make([]int, numSegments)
600 baseSize := size / numSegments
601 remainder := size % numSegments
602
603 // Distribute the remainder across segments.
604 for i := range numSegments {
605 segmentSizes[i] = baseSize
606 if i < remainder {
607 segmentSizes[i]++
608 }
609 }
610
611 // Generate colors for each segment.
612 for i := range numSegments {
613 c1 := stopsPrime[i]
614 c2 := stopsPrime[i+1]
615 segmentSize := segmentSizes[i]
616
617 for j := range segmentSize {
618 var t float64
619 if segmentSize > 1 {
620 t = float64(j) / float64(segmentSize-1)
621 }
622 c := c1.BlendHcl(c2, t)
623 blended = append(blended, c)
624 }
625 }
626
627 return blended
628}