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