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