1package title
2
3import (
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.
18type letterform func(bool) string
19
20const diag = `β±`
21
22// Opts are the options for rendering the Crush title art.
23type 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.
36func 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.
93func 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.
122func 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.
152func 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.
182func 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.
218func 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.
254func 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
282func joinLetterform(letters ...string) string {
283 return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
284}
285
286// letterformProps defines letterform stretching properties.
287// for readability.
288type 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.
297func 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.
314func 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.
340func 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}