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