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