@@ -0,0 +1,380 @@
9+ "github.com/MakeNowJust/heredoc"
10+ "github.com/charmbracelet/lipgloss/v2"
11+ "github.com/charmbracelet/x/exp/slice"
12+ "github.com/lucasb-eyer/go-colorful"
13+ "github.com/rivo/uniseg"
16+// letterform represents a letterform. It can be stretched horizontally by
17+// a given amount via the boolean argument.
18+type letterform func(bool) string
22+// Opts are the options for rendering the Crush title art.
24+ FieldColor color.Color // diagonal lines
25+ TitleColorA color.Color // left gradient ramp point
26+ TitleColorB color.Color // right gradient ramp point
27+ CharmColor color.Color // Charm™ text color
28+ VersionColor color.Color // Version text color
31+// Render renders the Crush title art. Set the argument to true to render the
32+// narrow version, intended for use in a sidebar.
34+// The compact argument determins whether it renders compact for the sidebar
35+// or wider for the main pane.
36+func Render(version string, compact bool, o Opts) string {
37+ const charm = "Charm™"
39+ fg := func(c color.Color, s string) string {
40+ return lipgloss.NewStyle().Foreground(c).Render(s)
44+ crush := renderWord(1, !compact, letterC, letterR, letterU, LetterS, letterH)
45+ crushWidth := lipgloss.Width(crush)
46+ b := new(strings.Builder)
47+ for r := range strings.SplitSeq(crush, "\n") {
48+ fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
52+ // Charm and version.
53+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
54+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
56+ // Join the meta row and big Crush title.
57+ crush = strings.TrimSpace(metaRow + "\n" + crush)
61+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
62+ return strings.Join([]string{field, field, crush, field}, "\n")
65+ fieldHeight := lipgloss.Height(crush)
69+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
70+ leftField := new(strings.Builder)
71+ for range fieldHeight {
72+ fmt.Fprintln(leftField, leftFieldRow)
76+ const rightWidth = 15
78+ rightField := new(strings.Builder)
79+ for i := range fieldHeight {
82+ width = rightWidth - (i - stepDownAt)
84+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
87+ // Return the wide version.
89+ return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
92+// renderWord renders letterforms to fork a word.
93+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
98+ renderedLetterforms := make([]string, len(letterforms))
100+ // pick one letter randomly to stretch
102+ if stretchRandomLetter {
103+ stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
106+ for i, letter := range letterforms {
107+ renderedLetterforms[i] = letter(i == stretchIndex)
111+ // Add spaces between the letters and render.
112+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
114+ return strings.TrimSpace(
115+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
119+// letterC renders the letter C in a stylized way. It takes an integer that
120+// determines how many cells to stretch the letter. If the stretch is less than
121+// 1, it defaults to no stretching.
122+func letterC(stretch bool) string {
123+ // Here's what we're making:
129+ left := heredoc.Doc(`
133+ right := heredoc.Doc(`
138+ return joinLetterform(
140+ stretchLetterformPart(right, letterformProps{
149+// letterH renders the letter H in a stylized way. It takes an integer that
150+// determines how many cells to stretch the letter. If the stretch is less than
151+// 1, it defaults to no stretching.
152+func letterH(stretch bool) string {
153+ // Here's what we're making:
159+ side := heredoc.Doc(`
163+ middle := heredoc.Doc(`
167+ return joinLetterform(
169+ stretchLetterformPart(middle, letterformProps{
179+// letterR renders the letter R in a stylized way. It takes an integer that
180+// determines how many cells to stretch the letter. If the stretch is less than
181+// 1, it defaults to no stretching.
182+func letterR(stretch bool) string {
183+ // Here's what we're making:
189+ left := heredoc.Doc(`
194+ center := heredoc.Doc(`
198+ right := heredoc.Doc(`
203+ return joinLetterform(
205+ stretchLetterformPart(center, letterformProps{
215+// LetterS renders the letter S in a stylized way. It takes an integer that
216+// determines how many cells to stretch the letter. If the stretch is less than
217+// 1, it defaults to no stretching.
218+func LetterS(stretch bool) string {
219+ // Here's what we're making:
225+ left := heredoc.Doc(`
230+ center := heredoc.Doc(`
235+ right := heredoc.Doc(`
239+ return joinLetterform(
241+ stretchLetterformPart(center, letterformProps{
251+// letterU renders the letter U in a stylized way. It takes an integer that
252+// determines how many cells to stretch the letter. If the stretch is less than
253+// 1, it defaults to no stretching.
254+func letterU(stretch bool) string {
255+ // Here's what we're making:
261+ side := heredoc.Doc(`
265+ middle := heredoc.Doc(`
270+ return joinLetterform(
272+ stretchLetterformPart(middle, letterformProps{
282+func joinLetterform(letters ...string) string {
283+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
286+// letterformProps defines letterform stretching properties.
288+type letterformProps struct {
295+// stretchLetterformPart is a helper function for letter stretching. If randomize
296+// is false the minimum number will be used.
297+func stretchLetterformPart(s string, p letterformProps) string {
298+ if p.maxStretch < p.minStretch {
299+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
303+ n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
305+ parts := make([]string, n)
306+ for i := range parts {
309+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
312+// applyForegroundGrad renders a given string with a horizontal gradient
314+func applyForegroundGrad(input string, color1, color2 color.Color) string {
319+ var o strings.Builder
320+ if len(input) == 1 {
321+ return lipgloss.NewStyle().Foreground(color1).Render(input)
324+ var clusters []string
325+ gr := uniseg.NewGraphemes(input)
327+ clusters = append(clusters, string(gr.Runes()))
330+ ramp := blendColors(len(clusters), color1, color2)
331+ for i, c := range ramp {
332+ fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
338+// blendColors returns a slice of colors blended between the given keys.
339+// Blending is done in Hcl to stay in gamut.
340+func blendColors(size int, stops ...color.Color) []color.Color {
345+ stopsPrime := make([]colorful.Color, len(stops))
346+ for i, k := range stops {
347+ stopsPrime[i], _ = colorful.MakeColor(k)
350+ numSegments := len(stopsPrime) - 1
351+ blended := make([]color.Color, 0, size)
353+ // Calculate how many colors each segment should have.
354+ segmentSizes := make([]int, numSegments)
355+ baseSize := size / numSegments
356+ remainder := size % numSegments
358+ // Distribute the remainder across segments.
359+ for i := range numSegments {
360+ segmentSizes[i] = baseSize
366+ // Generate colors for each segment.
367+ for i := range numSegments {
369+ c2 := stopsPrime[i+1]
370+ segmentSize := segmentSizes[i]
372+ for j := range segmentSize {
373+ t := float64(j) / float64(segmentSize)
374+ c := c1.BlendHcl(c2, t)
375+ blended = append(blended, c)