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/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}