theme.go

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