title.go

  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}