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