@@ -0,0 +1,380 @@
1+package title
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "math/rand/v2"
7+ "strings"
8+
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"
14+)
15+
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
19+
20+const diag = `╱`
21+
22+// Opts are the options for rendering the Crush title art.
23+type Opts struct {
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
29+}
30+
31+// Render renders the Crush title art. Set the argument to true to render the
32+// narrow version, intended for use in a sidebar.
33+//
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™"
38+
39+ fg := func(c color.Color, s string) string {
40+ return lipgloss.NewStyle().Foreground(c).Render(s)
41+ }
42+
43+ // Title.
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))
49+ }
50+ crush = b.String()
51+
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)
55+
56+ // Join the meta row and big Crush title.
57+ crush = strings.TrimSpace(metaRow + "\n" + crush)
58+
59+ // Narrow version.
60+ if compact {
61+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
62+ return strings.Join([]string{field, field, crush, field}, "\n")
63+ }
64+
65+ fieldHeight := lipgloss.Height(crush)
66+
67+ // Left field.
68+ const leftWidth = 6
69+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
70+ leftField := new(strings.Builder)
71+ for range fieldHeight {
72+ fmt.Fprintln(leftField, leftFieldRow)
73+ }
74+
75+ // Right field.
76+ const rightWidth = 15
77+ const stepDownAt = 0
78+ rightField := new(strings.Builder)
79+ for i := range fieldHeight {
80+ width := rightWidth
81+ if i >= stepDownAt {
82+ width = rightWidth - (i - stepDownAt)
83+ }
84+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
85+ }
86+
87+ // Return the wide version.
88+ const hGap = " "
89+ return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
90+}
91+
92+// renderWord renders letterforms to fork a word.
93+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
94+ if spacing < 0 {
95+ spacing = 0
96+ }
97+
98+ renderedLetterforms := make([]string, len(letterforms))
99+
100+ // pick one letter randomly to stretch
101+ stretchIndex := -1
102+ if stretchRandomLetter {
103+ stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
104+ }
105+
106+ for i, letter := range letterforms {
107+ renderedLetterforms[i] = letter(i == stretchIndex)
108+ }
109+
110+ if spacing > 0 {
111+ // Add spaces between the letters and render.
112+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
113+ }
114+ return strings.TrimSpace(
115+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
116+ )
117+}
118+
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:
124+ //
125+ // ▄▀▀▀▀
126+ // █
127+ // ▀▀▀▀
128+
129+ left := heredoc.Doc(`
130+ ▄
131+ █
132+ `)
133+ right := heredoc.Doc(`
134+ ▀
135+
136+ ▀
137+ `)
138+ return joinLetterform(
139+ left,
140+ stretchLetterformPart(right, letterformProps{
141+ stretch: stretch,
142+ width: 4,
143+ minStretch: 7,
144+ maxStretch: 12,
145+ }),
146+ )
147+}
148+
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:
154+ //
155+ // █ █
156+ // █▀▀▀█
157+ // ▀ ▀
158+
159+ side := heredoc.Doc(`
160+ █
161+ █
162+ ▀`)
163+ middle := heredoc.Doc(`
164+
165+ ▀
166+ `)
167+ return joinLetterform(
168+ side,
169+ stretchLetterformPart(middle, letterformProps{
170+ stretch: stretch,
171+ width: 3,
172+ minStretch: 8,
173+ maxStretch: 12,
174+ }),
175+ side,
176+ )
177+}
178+
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:
184+ //
185+ // █▀▀▀▄
186+ // █▀▀▀▄
187+ // ▀ ▀
188+
189+ left := heredoc.Doc(`
190+ █
191+ █
192+ ▀
193+ `)
194+ center := heredoc.Doc(`
195+ ▀
196+ ▀
197+ `)
198+ right := heredoc.Doc(`
199+ ▄
200+ ▄
201+ ▀
202+ `)
203+ return joinLetterform(
204+ left,
205+ stretchLetterformPart(center, letterformProps{
206+ stretch: stretch,
207+ width: 3,
208+ minStretch: 7,
209+ maxStretch: 12,
210+ }),
211+ right,
212+ )
213+}
214+
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:
220+ //
221+ // ▄▀▀▀▀
222+ // ▀▀▀▄
223+ // ▀▀▀▀
224+
225+ left := heredoc.Doc(`
226+ ▄
227+
228+ ▀
229+ `)
230+ center := heredoc.Doc(`
231+ ▀
232+ ▀
233+ ▀
234+ `)
235+ right := heredoc.Doc(`
236+ ▀
237+ ▄
238+ `)
239+ return joinLetterform(
240+ left,
241+ stretchLetterformPart(center, letterformProps{
242+ stretch: stretch,
243+ width: 3,
244+ minStretch: 7,
245+ maxStretch: 12,
246+ }),
247+ right,
248+ )
249+}
250+
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:
256+ //
257+ // █ █
258+ // █ █
259+ // ▀▀▀
260+
261+ side := heredoc.Doc(`
262+ █
263+ █
264+ `)
265+ middle := heredoc.Doc(`
266+
267+
268+ ▀
269+ `)
270+ return joinLetterform(
271+ side,
272+ stretchLetterformPart(middle, letterformProps{
273+ stretch: stretch,
274+ width: 3,
275+ minStretch: 7,
276+ maxStretch: 12,
277+ }),
278+ side,
279+ )
280+}
281+
282+func joinLetterform(letters ...string) string {
283+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
284+}
285+
286+// letterformProps defines letterform stretching properties.
287+// for readability.
288+type letterformProps struct {
289+ width int
290+ minStretch int
291+ maxStretch int
292+ stretch bool
293+}
294+
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
300+ }
301+ n := p.width
302+ if p.stretch {
303+ n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
304+ }
305+ parts := make([]string, n)
306+ for i := range parts {
307+ parts[i] = s
308+ }
309+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
310+}
311+
312+// applyForegroundGrad renders a given string with a horizontal gradient
313+// foreground.
314+func applyForegroundGrad(input string, color1, color2 color.Color) string {
315+ if input == "" {
316+ return ""
317+ }
318+
319+ var o strings.Builder
320+ if len(input) == 1 {
321+ return lipgloss.NewStyle().Foreground(color1).Render(input)
322+ }
323+
324+ var clusters []string
325+ gr := uniseg.NewGraphemes(input)
326+ for gr.Next() {
327+ clusters = append(clusters, string(gr.Runes()))
328+ }
329+
330+ ramp := blendColors(len(clusters), color1, color2)
331+ for i, c := range ramp {
332+ fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
333+ }
334+
335+ return o.String()
336+}
337+
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 {
341+ if len(stops) < 2 {
342+ return nil
343+ }
344+
345+ stopsPrime := make([]colorful.Color, len(stops))
346+ for i, k := range stops {
347+ stopsPrime[i], _ = colorful.MakeColor(k)
348+ }
349+
350+ numSegments := len(stopsPrime) - 1
351+ blended := make([]color.Color, 0, size)
352+
353+ // Calculate how many colors each segment should have.
354+ segmentSizes := make([]int, numSegments)
355+ baseSize := size / numSegments
356+ remainder := size % numSegments
357+
358+ // Distribute the remainder across segments.
359+ for i := range numSegments {
360+ segmentSizes[i] = baseSize
361+ if i < remainder {
362+ segmentSizes[i]++
363+ }
364+ }
365+
366+ // Generate colors for each segment.
367+ for i := range numSegments {
368+ c1 := stopsPrime[i]
369+ c2 := stopsPrime[i+1]
370+ segmentSize := segmentSizes[i]
371+
372+ for j := range segmentSize {
373+ t := float64(j) / float64(segmentSize)
374+ c := c1.BlendHcl(c2, t)
375+ blended = append(blended, c)
376+ }
377+ }
378+
379+ return blended
380+}