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