theme.go

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