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