theme.go

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