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