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