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