theme.go

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