logo.go

  1// Package logo renders a Crush wordmark in a stylized way.
  2package logo
  3
  4import (
  5	"fmt"
  6	"image/color"
  7	"math/rand/v2"
  8	"strings"
  9
 10	"github.com/MakeNowJust/heredoc"
 11	"github.com/charmbracelet/crush/internal/tui/styles"
 12	"github.com/charmbracelet/lipgloss/v2"
 13	"github.com/charmbracelet/x/ansi"
 14	"github.com/charmbracelet/x/exp/slice"
 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	Width        int         // width of the rendered logo, used for truncation
 31}
 32
 33// Render renders the Crush logo. Set the argument to true to render the narrow
 34// version, intended for use in a sidebar.
 35//
 36// The compact argument determines whether it renders compact for the sidebar
 37// or wider for the main pane.
 38func Render(version string, compact bool, o Opts) string {
 39	const charm = " Charmβ„’"
 40
 41	fg := func(c color.Color, s string) string {
 42		return lipgloss.NewStyle().Foreground(c).Render(s)
 43	}
 44
 45	// Title.
 46	const spacing = 1
 47	crush := renderWord(spacing, !compact,
 48		letterC,
 49		letterR,
 50		letterU,
 51		letterSStylized,
 52		letterH,
 53	)
 54	crushWidth := lipgloss.Width(crush)
 55	b := new(strings.Builder)
 56	for r := range strings.SplitSeq(crush, "\n") {
 57		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
 58	}
 59	crush = b.String()
 60
 61	// Charm and version.
 62	metaRowGap := 1
 63	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
 64	version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
 65	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
 66	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
 67
 68	// Join the meta row and big Crush title.
 69	crush = strings.TrimSpace(metaRow + "\n" + crush)
 70
 71	// Narrow version.
 72	if compact {
 73		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
 74		return strings.Join([]string{field, field, crush, field, ""}, "\n")
 75	}
 76
 77	fieldHeight := lipgloss.Height(crush)
 78
 79	// Left field.
 80	const leftWidth = 6
 81	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
 82	leftField := new(strings.Builder)
 83	for range fieldHeight {
 84		fmt.Fprintln(leftField, leftFieldRow)
 85	}
 86
 87	// Right field.
 88	rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
 89	const stepDownAt = 0
 90	rightField := new(strings.Builder)
 91	for i := range fieldHeight {
 92		width := rightWidth
 93		if i >= stepDownAt {
 94			width = rightWidth - (i - stepDownAt)
 95		}
 96		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
 97	}
 98
 99	// Return the wide version.
100	const hGap = " "
101	return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
102}
103
104// renderWord renders letterforms to fork a word.
105func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
106	if spacing < 0 {
107		spacing = 0
108	}
109
110	renderedLetterforms := make([]string, len(letterforms))
111
112	// pick one letter randomly to stretch
113	stretchIndex := -1
114	if stretchRandomLetter {
115		stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
116	}
117
118	for i, letter := range letterforms {
119		renderedLetterforms[i] = letter(i == stretchIndex)
120	}
121
122	if spacing > 0 {
123		// Add spaces between the letters and render.
124		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
125	}
126	return strings.TrimSpace(
127		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
128	)
129}
130
131// letterC renders the letter C in a stylized way. It takes an integer that
132// determines how many cells to stretch the letter. If the stretch is less than
133// 1, it defaults to no stretching.
134func letterC(stretch bool) string {
135	// Here's what we're making:
136	//
137	// β–„β–€β–€β–€β–€
138	// β–ˆ
139	//	β–€β–€β–€β–€
140
141	left := heredoc.Doc(`
142		β–„
143		β–ˆ
144	`)
145	right := heredoc.Doc(`
146		β–€
147
148		β–€
149	`)
150	return joinLetterform(
151		left,
152		stretchLetterformPart(right, letterformProps{
153			stretch:    stretch,
154			width:      4,
155			minStretch: 7,
156			maxStretch: 12,
157		}),
158	)
159}
160
161// letterH renders the letter H in a stylized way. It takes an integer that
162// determines how many cells to stretch the letter. If the stretch is less than
163// 1, it defaults to no stretching.
164func letterH(stretch bool) string {
165	// Here's what we're making:
166	//
167	// β–ˆ   β–ˆ
168	// β–ˆβ–€β–€β–€β–ˆ
169	// β–€   β–€
170
171	side := heredoc.Doc(`
172		β–ˆ
173		β–ˆ
174		β–€`)
175	middle := heredoc.Doc(`
176
177		β–€
178	`)
179	return joinLetterform(
180		side,
181		stretchLetterformPart(middle, letterformProps{
182			stretch:    stretch,
183			width:      3,
184			minStretch: 8,
185			maxStretch: 12,
186		}),
187		side,
188	)
189}
190
191// letterR renders the letter R in a stylized way. It takes an integer that
192// determines how many cells to stretch the letter. If the stretch is less than
193// 1, it defaults to no stretching.
194func letterR(stretch bool) string {
195	// Here's what we're making:
196	//
197	// β–ˆβ–€β–€β–€β–„
198	// β–ˆβ–€β–€β–€β–„
199	// β–€   β–€
200
201	left := heredoc.Doc(`
202		β–ˆ
203		β–ˆ
204		β–€
205	`)
206	center := heredoc.Doc(`
207		β–€
208		β–€
209	`)
210	right := heredoc.Doc(`
211		β–„
212		β–„
213		β–€
214	`)
215	return joinLetterform(
216		left,
217		stretchLetterformPart(center, letterformProps{
218			stretch:    stretch,
219			width:      3,
220			minStretch: 7,
221			maxStretch: 12,
222		}),
223		right,
224	)
225}
226
227// letterS renders the letter S in a stylized way. It takes an integer that
228// determines how many cells to stretch the letter. If the stretch is less than
229// 1, it defaults to no stretching.
230func letterS(stretch bool) string {
231	// Here's what we're making:
232	//
233	// β–„β–€β–€β–€β–€
234	//	β–€β–€β–€β–„
235	// β–€β–€β–€β–€
236
237	left := heredoc.Doc(`
238		β–„
239
240		β–€
241	`)
242	center := heredoc.Doc(`
243		β–€
244		β–€
245		β–€
246	`)
247	right := heredoc.Doc(`
248		β–€
249		β–„
250	`)
251	return joinLetterform(
252		left,
253		stretchLetterformPart(center, letterformProps{
254			stretch:    stretch,
255			width:      3,
256			minStretch: 7,
257			maxStretch: 12,
258		}),
259		right,
260	)
261}
262
263// letterSStylized renders the letter S in a stylized way, more so than
264// [letterS]. It takes an integer that determines how many cells to stretch the
265// letter. If the stretch is less than 1, it defaults to no stretching.
266func letterSStylized(stretch bool) string {
267	// Here's what we're making:
268	//
269	// β–„β–€β–€β–€β–€β–€
270	// β–€β–€β–€β–€β–€β–ˆ
271	// β–€β–€β–€β–€β–€
272
273	left := heredoc.Doc(`
274		β–„
275		β–€
276		β–€
277	`)
278	center := heredoc.Doc(`
279		β–€
280		β–€
281		β–€
282	`)
283	right := heredoc.Doc(`
284		β–€
285		β–ˆ
286	`)
287	return joinLetterform(
288		left,
289		stretchLetterformPart(center, letterformProps{
290			stretch:    stretch,
291			width:      3,
292			minStretch: 7,
293			maxStretch: 12,
294		}),
295		right,
296	)
297}
298
299// letterU renders the letter U in a stylized way. It takes an integer that
300// determines how many cells to stretch the letter. If the stretch is less than
301// 1, it defaults to no stretching.
302func letterU(stretch bool) string {
303	// Here's what we're making:
304	//
305	// β–ˆ   β–ˆ
306	// β–ˆ   β–ˆ
307	//	β–€β–€β–€
308
309	side := heredoc.Doc(`
310		β–ˆ
311		β–ˆ
312	`)
313	middle := heredoc.Doc(`
314
315
316		β–€
317	`)
318	return joinLetterform(
319		side,
320		stretchLetterformPart(middle, letterformProps{
321			stretch:    stretch,
322			width:      3,
323			minStretch: 7,
324			maxStretch: 12,
325		}),
326		side,
327	)
328}
329
330func joinLetterform(letters ...string) string {
331	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
332}
333
334// letterformProps defines letterform stretching properties.
335// for readability.
336type letterformProps struct {
337	width      int
338	minStretch int
339	maxStretch int
340	stretch    bool
341}
342
343// stretchLetterformPart is a helper function for letter stretching. If randomize
344// is false the minimum number will be used.
345func stretchLetterformPart(s string, p letterformProps) string {
346	if p.maxStretch < p.minStretch {
347		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
348	}
349	n := p.width
350	if p.stretch {
351		n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
352	}
353	parts := make([]string, n)
354	for i := range parts {
355		parts[i] = s
356	}
357	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
358}