1package styles
2
3import (
4 "fmt"
5 "image/color"
6 "strings"
7
8 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/textarea"
10 "github.com/charmbracelet/bubbles/v2/textinput"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/glamour/v2/ansi"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/lucasb-eyer/go-colorful"
15 "github.com/rivo/uniseg"
16)
17
18const (
19 defaultListIndent = 2
20 defaultListLevelIndent = 4
21 defaultMargin = 2
22)
23
24type Theme struct {
25 Name string
26 IsDark bool
27
28 Primary color.Color
29 Secondary color.Color
30 Tertiary color.Color
31 Accent color.Color
32
33 // Colors
34 Blue color.Color
35 // TODO: add any others needed
36
37 BgBase 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 // TODO: add more syntax colors, maybe just use a chroma theme here.
56 SyntaxBg color.Color
57 SyntaxKeyword color.Color
58 SyntaxString color.Color
59 SyntaxComment color.Color
60
61 styles *Styles
62}
63
64type Styles struct {
65 Base lipgloss.Style
66 SelectedBase lipgloss.Style
67
68 Title lipgloss.Style
69 Subtitle lipgloss.Style
70 Text lipgloss.Style
71 TextSelected lipgloss.Style
72 Muted lipgloss.Style
73 Subtle lipgloss.Style
74
75 Success lipgloss.Style
76 Error lipgloss.Style
77 Warning lipgloss.Style
78 Info lipgloss.Style
79
80 // Markdown & Chroma
81 Markdown ansi.StyleConfig
82
83 // Inputs
84 TextInput textinput.Styles
85 TextArea textarea.Styles
86
87 // Help
88 Help help.Styles
89}
90
91func (t *Theme) S() *Styles {
92 if t.styles == nil {
93 t.styles = t.buildStyles()
94 }
95 return t.styles
96}
97
98func (t *Theme) buildStyles() *Styles {
99 base := lipgloss.NewStyle().
100 Foreground(t.FgBase)
101 return &Styles{
102 Base: base,
103
104 SelectedBase: base.Background(t.Primary),
105
106 Title: base.
107 Foreground(t.Accent).
108 Bold(true),
109
110 Subtitle: base.
111 Foreground(t.Secondary).
112 Bold(true),
113
114 Text: base,
115 TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
116
117 Muted: base.Foreground(t.FgMuted),
118
119 Subtle: base.Foreground(t.FgSubtle),
120
121 Success: base.Foreground(t.Success),
122
123 Error: base.Foreground(t.Error),
124
125 Warning: base.Foreground(t.Warning),
126
127 Info: base.Foreground(t.Info),
128
129 TextInput: textinput.Styles{
130 Focused: textinput.StyleState{
131 Text: base,
132 Placeholder: base.Foreground(t.FgMuted),
133 Prompt: base.Foreground(t.Tertiary),
134 Suggestion: base.Foreground(t.FgMuted),
135 },
136 Blurred: textinput.StyleState{
137 Text: base.Foreground(t.FgMuted),
138 Placeholder: base.Foreground(t.FgMuted),
139 Prompt: base.Foreground(t.FgMuted),
140 Suggestion: base.Foreground(t.FgMuted),
141 },
142 Cursor: textinput.CursorStyle{
143 Color: t.Secondary,
144 Shape: tea.CursorBar,
145 Blink: true,
146 },
147 },
148 TextArea: textarea.Styles{
149 Focused: textarea.StyleState{
150 Base: base,
151 Text: base,
152 LineNumber: base.Foreground(t.FgSubtle),
153 CursorLine: base,
154 CursorLineNumber: base.Foreground(t.FgSubtle),
155 Placeholder: base.Foreground(t.FgMuted),
156 Prompt: base.Foreground(t.Tertiary),
157 },
158 Blurred: textarea.StyleState{
159 Base: base,
160 Text: base.Foreground(t.FgMuted),
161 LineNumber: base.Foreground(t.FgMuted),
162 CursorLine: base,
163 CursorLineNumber: base.Foreground(t.FgMuted),
164 Placeholder: base.Foreground(t.FgMuted),
165 Prompt: base.Foreground(t.FgMuted),
166 },
167 Cursor: textarea.CursorStyle{
168 Color: t.Secondary,
169 Shape: tea.CursorBar,
170 Blink: true,
171 },
172 },
173
174 // TODO: update using the colors and add colors if missing
175 Markdown: ansi.StyleConfig{
176 Document: ansi.StyleBlock{
177 StylePrimitive: ansi.StylePrimitive{
178 BlockPrefix: "\n",
179 BlockSuffix: "\n",
180 Color: stringPtr("252"),
181 },
182 Margin: uintPtr(defaultMargin),
183 },
184 BlockQuote: ansi.StyleBlock{
185 StylePrimitive: ansi.StylePrimitive{},
186 Indent: uintPtr(1),
187 IndentToken: stringPtr("│ "),
188 },
189 List: ansi.StyleList{
190 LevelIndent: defaultListIndent,
191 },
192 Heading: ansi.StyleBlock{
193 StylePrimitive: ansi.StylePrimitive{
194 BlockSuffix: "\n",
195 Color: stringPtr("39"),
196 Bold: boolPtr(true),
197 },
198 },
199 H1: ansi.StyleBlock{
200 StylePrimitive: ansi.StylePrimitive{
201 Prefix: " ",
202 Suffix: " ",
203 Color: stringPtr("228"),
204 BackgroundColor: stringPtr("63"),
205 Bold: boolPtr(true),
206 },
207 },
208 H2: ansi.StyleBlock{
209 StylePrimitive: ansi.StylePrimitive{
210 Prefix: "## ",
211 },
212 },
213 H3: ansi.StyleBlock{
214 StylePrimitive: ansi.StylePrimitive{
215 Prefix: "### ",
216 },
217 },
218 H4: ansi.StyleBlock{
219 StylePrimitive: ansi.StylePrimitive{
220 Prefix: "#### ",
221 },
222 },
223 H5: ansi.StyleBlock{
224 StylePrimitive: ansi.StylePrimitive{
225 Prefix: "##### ",
226 },
227 },
228 H6: ansi.StyleBlock{
229 StylePrimitive: ansi.StylePrimitive{
230 Prefix: "###### ",
231 Color: stringPtr("35"),
232 Bold: boolPtr(false),
233 },
234 },
235 Strikethrough: ansi.StylePrimitive{
236 CrossedOut: boolPtr(true),
237 },
238 Emph: ansi.StylePrimitive{
239 Italic: boolPtr(true),
240 },
241 Strong: ansi.StylePrimitive{
242 Bold: boolPtr(true),
243 },
244 HorizontalRule: ansi.StylePrimitive{
245 Color: stringPtr("240"),
246 Format: "\n--------\n",
247 },
248 Item: ansi.StylePrimitive{
249 BlockPrefix: "• ",
250 },
251 Enumeration: ansi.StylePrimitive{
252 BlockPrefix: ". ",
253 },
254 Task: ansi.StyleTask{
255 StylePrimitive: ansi.StylePrimitive{},
256 Ticked: "[✓] ",
257 Unticked: "[ ] ",
258 },
259 Link: ansi.StylePrimitive{
260 Color: stringPtr("30"),
261 Underline: boolPtr(true),
262 },
263 LinkText: ansi.StylePrimitive{
264 Color: stringPtr("35"),
265 Bold: boolPtr(true),
266 },
267 Image: ansi.StylePrimitive{
268 Color: stringPtr("212"),
269 Underline: boolPtr(true),
270 },
271 ImageText: ansi.StylePrimitive{
272 Color: stringPtr("243"),
273 Format: "Image: {{.text}} →",
274 },
275 Code: ansi.StyleBlock{
276 StylePrimitive: ansi.StylePrimitive{
277 Prefix: " ",
278 Suffix: " ",
279 Color: stringPtr("203"),
280 BackgroundColor: stringPtr("236"),
281 },
282 },
283 CodeBlock: ansi.StyleCodeBlock{
284 StyleBlock: ansi.StyleBlock{
285 StylePrimitive: ansi.StylePrimitive{
286 Color: stringPtr("244"),
287 },
288 Margin: uintPtr(defaultMargin),
289 },
290 Chroma: &ansi.Chroma{
291 Text: ansi.StylePrimitive{
292 Color: stringPtr("#C4C4C4"),
293 },
294 Error: ansi.StylePrimitive{
295 Color: stringPtr("#F1F1F1"),
296 BackgroundColor: stringPtr("#F05B5B"),
297 },
298 Comment: ansi.StylePrimitive{
299 Color: stringPtr("#676767"),
300 },
301 CommentPreproc: ansi.StylePrimitive{
302 Color: stringPtr("#FF875F"),
303 },
304 Keyword: ansi.StylePrimitive{
305 Color: stringPtr("#00AAFF"),
306 },
307 KeywordReserved: ansi.StylePrimitive{
308 Color: stringPtr("#FF5FD2"),
309 },
310 KeywordNamespace: ansi.StylePrimitive{
311 Color: stringPtr("#FF5F87"),
312 },
313 KeywordType: ansi.StylePrimitive{
314 Color: stringPtr("#6E6ED8"),
315 },
316 Operator: ansi.StylePrimitive{
317 Color: stringPtr("#EF8080"),
318 },
319 Punctuation: ansi.StylePrimitive{
320 Color: stringPtr("#E8E8A8"),
321 },
322 Name: ansi.StylePrimitive{
323 Color: stringPtr("#C4C4C4"),
324 },
325 NameBuiltin: ansi.StylePrimitive{
326 Color: stringPtr("#FF8EC7"),
327 },
328 NameTag: ansi.StylePrimitive{
329 Color: stringPtr("#B083EA"),
330 },
331 NameAttribute: ansi.StylePrimitive{
332 Color: stringPtr("#7A7AE6"),
333 },
334 NameClass: ansi.StylePrimitive{
335 Color: stringPtr("#F1F1F1"),
336 Underline: boolPtr(true),
337 Bold: boolPtr(true),
338 },
339 NameDecorator: ansi.StylePrimitive{
340 Color: stringPtr("#FFFF87"),
341 },
342 NameFunction: ansi.StylePrimitive{
343 Color: stringPtr("#00D787"),
344 },
345 LiteralNumber: ansi.StylePrimitive{
346 Color: stringPtr("#6EEFC0"),
347 },
348 LiteralString: ansi.StylePrimitive{
349 Color: stringPtr("#C69669"),
350 },
351 LiteralStringEscape: ansi.StylePrimitive{
352 Color: stringPtr("#AFFFD7"),
353 },
354 GenericDeleted: ansi.StylePrimitive{
355 Color: stringPtr("#FD5B5B"),
356 },
357 GenericEmph: ansi.StylePrimitive{
358 Italic: boolPtr(true),
359 },
360 GenericInserted: ansi.StylePrimitive{
361 Color: stringPtr("#00D787"),
362 },
363 GenericStrong: ansi.StylePrimitive{
364 Bold: boolPtr(true),
365 },
366 GenericSubheading: ansi.StylePrimitive{
367 Color: stringPtr("#777777"),
368 },
369 Background: ansi.StylePrimitive{
370 BackgroundColor: stringPtr("#373737"),
371 },
372 },
373 },
374 Table: ansi.StyleTable{
375 StyleBlock: ansi.StyleBlock{
376 StylePrimitive: ansi.StylePrimitive{},
377 },
378 },
379 DefinitionDescription: ansi.StylePrimitive{
380 BlockPrefix: "\n ",
381 },
382 },
383
384 Help: help.Styles{
385 ShortKey: base.Foreground(t.FgMuted),
386 ShortDesc: base.Foreground(t.FgSubtle),
387 ShortSeparator: base.Foreground(t.Border),
388 Ellipsis: base.Foreground(t.Border),
389 FullKey: base.Foreground(t.FgMuted),
390 FullDesc: base.Foreground(t.FgSubtle),
391 FullSeparator: base.Foreground(t.Border),
392 },
393 }
394}
395
396type Manager struct {
397 themes map[string]*Theme
398 current *Theme
399}
400
401var defaultManager *Manager
402
403func SetDefaultManager(m *Manager) {
404 defaultManager = m
405}
406
407func DefaultManager() *Manager {
408 if defaultManager == nil {
409 defaultManager = NewManager("crush")
410 }
411 return defaultManager
412}
413
414func CurrentTheme() *Theme {
415 if defaultManager == nil {
416 defaultManager = NewManager("crush")
417 }
418 return defaultManager.Current()
419}
420
421func NewManager(defaultTheme string) *Manager {
422 m := &Manager{
423 themes: make(map[string]*Theme),
424 }
425
426 m.Register(NewCrushTheme())
427
428 m.current = m.themes[defaultTheme]
429
430 return m
431}
432
433func (m *Manager) Register(theme *Theme) {
434 m.themes[theme.Name] = theme
435}
436
437func (m *Manager) Current() *Theme {
438 return m.current
439}
440
441func (m *Manager) SetTheme(name string) error {
442 if theme, ok := m.themes[name]; ok {
443 m.current = theme
444 return nil
445 }
446 return fmt.Errorf("theme %s not found", name)
447}
448
449func (m *Manager) List() []string {
450 names := make([]string, 0, len(m.themes))
451 for name := range m.themes {
452 names = append(names, name)
453 }
454 return names
455}
456
457// ParseHex converts hex string to color
458func ParseHex(hex string) color.Color {
459 var r, g, b uint8
460 fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
461 return color.RGBA{R: r, G: g, B: b, A: 255}
462}
463
464// Alpha returns a color with transparency
465func Alpha(c color.Color, alpha uint8) color.Color {
466 r, g, b, _ := c.RGBA()
467 return color.RGBA{
468 R: uint8(r >> 8),
469 G: uint8(g >> 8),
470 B: uint8(b >> 8),
471 A: alpha,
472 }
473}
474
475// Darken makes a color darker by percentage (0-100)
476func Darken(c color.Color, percent float64) color.Color {
477 r, g, b, a := c.RGBA()
478 factor := 1.0 - percent/100.0
479 return color.RGBA{
480 R: uint8(float64(r>>8) * factor),
481 G: uint8(float64(g>>8) * factor),
482 B: uint8(float64(b>>8) * factor),
483 A: uint8(a >> 8),
484 }
485}
486
487// Lighten makes a color lighter by percentage (0-100)
488func Lighten(c color.Color, percent float64) color.Color {
489 r, g, b, a := c.RGBA()
490 factor := percent / 100.0
491 return color.RGBA{
492 R: uint8(min(255, float64(r>>8)+255*factor)),
493 G: uint8(min(255, float64(g>>8)+255*factor)),
494 B: uint8(min(255, float64(b>>8)+255*factor)),
495 A: uint8(a >> 8),
496 }
497}
498
499// ApplyForegroundGrad renders a given string with a horizontal gradient
500// foreground.
501func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
502 if input == "" {
503 return ""
504 }
505
506 var o strings.Builder
507 if len(input) == 1 {
508 return lipgloss.NewStyle().Foreground(color1).Render(input)
509 }
510
511 var clusters []string
512 gr := uniseg.NewGraphemes(input)
513 for gr.Next() {
514 clusters = append(clusters, string(gr.Runes()))
515 }
516
517 ramp := blendColors(len(clusters), color1, color2)
518 for i, c := range ramp {
519 fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
520 }
521
522 return o.String()
523}
524
525// blendColors returns a slice of colors blended between the given keys.
526// Blending is done in Hcl to stay in gamut.
527func blendColors(size int, stops ...color.Color) []color.Color {
528 if len(stops) < 2 {
529 return nil
530 }
531
532 stopsPrime := make([]colorful.Color, len(stops))
533 for i, k := range stops {
534 stopsPrime[i], _ = colorful.MakeColor(k)
535 }
536
537 numSegments := len(stopsPrime) - 1
538 blended := make([]color.Color, 0, size)
539
540 // Calculate how many colors each segment should have.
541 segmentSizes := make([]int, numSegments)
542 baseSize := size / numSegments
543 remainder := size % numSegments
544
545 // Distribute the remainder across segments.
546 for i := range numSegments {
547 segmentSizes[i] = baseSize
548 if i < remainder {
549 segmentSizes[i]++
550 }
551 }
552
553 // Generate colors for each segment.
554 for i := range numSegments {
555 c1 := stopsPrime[i]
556 c2 := stopsPrime[i+1]
557 segmentSize := segmentSizes[i]
558
559 for j := range segmentSize {
560 var t float64
561 if segmentSize > 1 {
562 t = float64(j) / float64(segmentSize-1)
563 }
564 c := c1.BlendHcl(c2, t)
565 blended = append(blended, c)
566 }
567 }
568
569 return blended
570}