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/ansi"
12 "github.com/charmbracelet/x/exp/slice"
13 "github.com/lucasb-eyer/go-colorful"
14 "github.com/rivo/uniseg"
15)
16
17// letterform represents a letterform. It can be stretched horizontally by
18// a given amount via the boolean argument.
19type letterform func(bool) string
20
21const diag = `β±`
22
23// Opts are the options for rendering the Crush title art.
24type Opts struct {
25 FieldColor color.Color // diagonal lines
26 TitleColorA color.Color // left gradient ramp point
27 TitleColorB color.Color // right gradient ramp point
28 CharmColor color.Color // Charmβ’ text color
29 VersionColor color.Color // Version text color
30}
31
32// Render renders the Crush title art. Set the argument to true to render the
33// narrow version, intended for use in a sidebar.
34//
35// The compact argument determins whether it renders compact for the sidebar
36// or wider for the main pane.
37func Render(version string, compact bool, o Opts) string {
38 const charm = "Charmβ’"
39
40 fg := func(c color.Color, s string) string {
41 return lipgloss.NewStyle().Foreground(c).Render(s)
42 }
43
44 // Title.
45 crush := renderWord(1, !compact, letterC, letterR, letterU, LetterS, letterH)
46 crushWidth := lipgloss.Width(crush)
47 b := new(strings.Builder)
48 for r := range strings.SplitSeq(crush, "\n") {
49 fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
50 }
51 crush = b.String()
52
53 // Charm and version.
54 metaRowGap := 1
55 maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
56 version = ansi.Truncate(version, maxVersionWidth, "β¦") // truncate version if too long.
57 gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
58 metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
59
60 // Join the meta row and big Crush title.
61 crush = strings.TrimSpace(metaRow + "\n" + crush)
62
63 // Narrow version.
64 if compact {
65 field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
66 return strings.Join([]string{field, field, crush, field}, "\n")
67 }
68
69 fieldHeight := lipgloss.Height(crush)
70
71 // Left field.
72 const leftWidth = 6
73 leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
74 leftField := new(strings.Builder)
75 for range fieldHeight {
76 fmt.Fprintln(leftField, leftFieldRow)
77 }
78
79 // Right field.
80 const rightWidth = 15
81 const stepDownAt = 0
82 rightField := new(strings.Builder)
83 for i := range fieldHeight {
84 width := rightWidth
85 if i >= stepDownAt {
86 width = rightWidth - (i - stepDownAt)
87 }
88 fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
89 }
90
91 // Return the wide version.
92 const hGap = " "
93 return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
94}
95
96// renderWord renders letterforms to fork a word.
97func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
98 if spacing < 0 {
99 spacing = 0
100 }
101
102 renderedLetterforms := make([]string, len(letterforms))
103
104 // pick one letter randomly to stretch
105 stretchIndex := -1
106 if stretchRandomLetter {
107 stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
108 }
109
110 for i, letter := range letterforms {
111 renderedLetterforms[i] = letter(i == stretchIndex)
112 }
113
114 if spacing > 0 {
115 // Add spaces between the letters and render.
116 renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
117 }
118 return strings.TrimSpace(
119 lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
120 )
121}
122
123// letterC renders the letter C in a stylized way. It takes an integer that
124// determines how many cells to stretch the letter. If the stretch is less than
125// 1, it defaults to no stretching.
126func letterC(stretch bool) string {
127 // Here's what we're making:
128 //
129 // βββββ
130 // β
131 // ββββ
132
133 left := heredoc.Doc(`
134 β
135 β
136 `)
137 right := heredoc.Doc(`
138 β
139
140 β
141 `)
142 return joinLetterform(
143 left,
144 stretchLetterformPart(right, letterformProps{
145 stretch: stretch,
146 width: 4,
147 minStretch: 7,
148 maxStretch: 12,
149 }),
150 )
151}
152
153// letterH renders the letter H in a stylized way. It takes an integer that
154// determines how many cells to stretch the letter. If the stretch is less than
155// 1, it defaults to no stretching.
156func letterH(stretch bool) string {
157 // Here's what we're making:
158 //
159 // β β
160 // βββββ
161 // β β
162
163 side := heredoc.Doc(`
164 β
165 β
166 β`)
167 middle := heredoc.Doc(`
168
169 β
170 `)
171 return joinLetterform(
172 side,
173 stretchLetterformPart(middle, letterformProps{
174 stretch: stretch,
175 width: 3,
176 minStretch: 8,
177 maxStretch: 12,
178 }),
179 side,
180 )
181}
182
183// letterR renders the letter R in a stylized way. It takes an integer that
184// determines how many cells to stretch the letter. If the stretch is less than
185// 1, it defaults to no stretching.
186func letterR(stretch bool) string {
187 // Here's what we're making:
188 //
189 // βββββ
190 // βββββ
191 // β β
192
193 left := heredoc.Doc(`
194 β
195 β
196 β
197 `)
198 center := heredoc.Doc(`
199 β
200 β
201 `)
202 right := heredoc.Doc(`
203 β
204 β
205 β
206 `)
207 return joinLetterform(
208 left,
209 stretchLetterformPart(center, letterformProps{
210 stretch: stretch,
211 width: 3,
212 minStretch: 7,
213 maxStretch: 12,
214 }),
215 right,
216 )
217}
218
219// LetterS renders the letter S in a stylized way. It takes an integer that
220// determines how many cells to stretch the letter. If the stretch is less than
221// 1, it defaults to no stretching.
222func LetterS(stretch bool) string {
223 // Here's what we're making:
224 //
225 // βββββ
226 // ββββ
227 // ββββ
228
229 left := heredoc.Doc(`
230 β
231
232 β
233 `)
234 center := heredoc.Doc(`
235 β
236 β
237 β
238 `)
239 right := heredoc.Doc(`
240 β
241 β
242 `)
243 return joinLetterform(
244 left,
245 stretchLetterformPart(center, letterformProps{
246 stretch: stretch,
247 width: 3,
248 minStretch: 7,
249 maxStretch: 12,
250 }),
251 right,
252 )
253}
254
255// letterU renders the letter U in a stylized way. It takes an integer that
256// determines how many cells to stretch the letter. If the stretch is less than
257// 1, it defaults to no stretching.
258func letterU(stretch bool) string {
259 // Here's what we're making:
260 //
261 // β β
262 // β β
263 // βββ
264
265 side := heredoc.Doc(`
266 β
267 β
268 `)
269 middle := heredoc.Doc(`
270
271
272 β
273 `)
274 return joinLetterform(
275 side,
276 stretchLetterformPart(middle, letterformProps{
277 stretch: stretch,
278 width: 3,
279 minStretch: 7,
280 maxStretch: 12,
281 }),
282 side,
283 )
284}
285
286func joinLetterform(letters ...string) string {
287 return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
288}
289
290// letterformProps defines letterform stretching properties.
291// for readability.
292type letterformProps struct {
293 width int
294 minStretch int
295 maxStretch int
296 stretch bool
297}
298
299// stretchLetterformPart is a helper function for letter stretching. If randomize
300// is false the minimum number will be used.
301func stretchLetterformPart(s string, p letterformProps) string {
302 if p.maxStretch < p.minStretch {
303 p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
304 }
305 n := p.width
306 if p.stretch {
307 n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
308 }
309 parts := make([]string, n)
310 for i := range parts {
311 parts[i] = s
312 }
313 return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
314}
315
316// applyForegroundGrad renders a given string with a horizontal gradient
317// foreground.
318func applyForegroundGrad(input string, color1, color2 color.Color) string {
319 if input == "" {
320 return ""
321 }
322
323 var o strings.Builder
324 if len(input) == 1 {
325 return lipgloss.NewStyle().Foreground(color1).Render(input)
326 }
327
328 var clusters []string
329 gr := uniseg.NewGraphemes(input)
330 for gr.Next() {
331 clusters = append(clusters, string(gr.Runes()))
332 }
333
334 ramp := blendColors(len(clusters), color1, color2)
335 for i, c := range ramp {
336 fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
337 }
338
339 return o.String()
340}
341
342// blendColors returns a slice of colors blended between the given keys.
343// Blending is done in Hcl to stay in gamut.
344func blendColors(size int, stops ...color.Color) []color.Color {
345 if len(stops) < 2 {
346 return nil
347 }
348
349 stopsPrime := make([]colorful.Color, len(stops))
350 for i, k := range stops {
351 stopsPrime[i], _ = colorful.MakeColor(k)
352 }
353
354 numSegments := len(stopsPrime) - 1
355 blended := make([]color.Color, 0, size)
356
357 // Calculate how many colors each segment should have.
358 segmentSizes := make([]int, numSegments)
359 baseSize := size / numSegments
360 remainder := size % numSegments
361
362 // Distribute the remainder across segments.
363 for i := range numSegments {
364 segmentSizes[i] = baseSize
365 if i < remainder {
366 segmentSizes[i]++
367 }
368 }
369
370 // Generate colors for each segment.
371 for i := range numSegments {
372 c1 := stopsPrime[i]
373 c2 := stopsPrime[i+1]
374 segmentSize := segmentSizes[i]
375
376 for j := range segmentSize {
377 t := float64(j) / float64(segmentSize)
378 c := c1.BlendHcl(c2, t)
379 blended = append(blended, c)
380 }
381 }
382
383 return blended
384}