theme.go

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