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) // 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	logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
102	if o.Width > 0 {
103		// Truncate the logo to the specified width.
104		lines := strings.Split(logo, "\n")
105		for i, line := range lines {
106			lines[i] = ansi.Truncate(line, o.Width, "")
107		}
108		logo = strings.Join(lines, "\n")
109	}
110	return logo
111}
112
113// SmallRender renders a smaller version of the Crush logo, suitable for
114// smaller windows or sidebar usage.
115func SmallRender(width int) string {
116	t := styles.CurrentTheme()
117	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
118	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
119	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
120	if remainingWidth > 0 {
121		lines := strings.Repeat("β±", remainingWidth)
122		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
123	}
124	return title
125}
126
127// renderWord renders letterforms to fork a word.
128func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
129	if spacing < 0 {
130		spacing = 0
131	}
132
133	renderedLetterforms := make([]string, len(letterforms))
134
135	// pick one letter randomly to stretch
136	stretchIndex := -1
137	if stretchRandomLetter {
138		stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
139	}
140
141	for i, letter := range letterforms {
142		renderedLetterforms[i] = letter(i == stretchIndex)
143	}
144
145	if spacing > 0 {
146		// Add spaces between the letters and render.
147		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
148	}
149	return strings.TrimSpace(
150		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
151	)
152}
153
154// letterC renders the letter C in a stylized way. It takes an integer that
155// determines how many cells to stretch the letter. If the stretch is less than
156// 1, it defaults to no stretching.
157func letterC(stretch bool) string {
158	// Here's what we're making:
159	//
160	// βββββ
161	// β
162	//	ββββ
163
164	left := heredoc.Doc(`
165		β
166		β
167	`)
168	right := heredoc.Doc(`
169		β
170
171		β
172	`)
173	return joinLetterform(
174		left,
175		stretchLetterformPart(right, letterformProps{
176			stretch:    stretch,
177			width:      4,
178			minStretch: 7,
179			maxStretch: 12,
180		}),
181	)
182}
183
184// letterH renders the letter H in a stylized way. It takes an integer that
185// determines how many cells to stretch the letter. If the stretch is less than
186// 1, it defaults to no stretching.
187func letterH(stretch bool) string {
188	// Here's what we're making:
189	//
190	// β   β
191	// βββββ
192	// β   β
193
194	side := heredoc.Doc(`
195		β
196		β
197		β`)
198	middle := heredoc.Doc(`
199
200		β
201	`)
202	return joinLetterform(
203		side,
204		stretchLetterformPart(middle, letterformProps{
205			stretch:    stretch,
206			width:      3,
207			minStretch: 8,
208			maxStretch: 12,
209		}),
210		side,
211	)
212}
213
214// letterR renders the letter R in a stylized way. It takes an integer that
215// determines how many cells to stretch the letter. If the stretch is less than
216// 1, it defaults to no stretching.
217func letterR(stretch bool) string {
218	// Here's what we're making:
219	//
220	// βββββ
221	// βββββ
222	// β   β
223
224	left := heredoc.Doc(`
225		β
226		β
227		β
228	`)
229	center := heredoc.Doc(`
230		β
231		β
232	`)
233	right := heredoc.Doc(`
234		β
235		β
236		β
237	`)
238	return joinLetterform(
239		left,
240		stretchLetterformPart(center, letterformProps{
241			stretch:    stretch,
242			width:      3,
243			minStretch: 7,
244			maxStretch: 12,
245		}),
246		right,
247	)
248}
249
250// letterSStylized renders the letter S in a stylized way, more so than
251// [letterS]. It takes an integer that determines how many cells to stretch the
252// letter. If the stretch is less than 1, it defaults to no stretching.
253func letterSStylized(stretch bool) string {
254	// Here's what we're making:
255	//
256	// ββββββ
257	// ββββββ
258	// βββββ
259
260	left := heredoc.Doc(`
261		β
262		β
263		β
264	`)
265	center := heredoc.Doc(`
266		β
267		β
268		β
269	`)
270	right := heredoc.Doc(`
271		β
272		β
273	`)
274	return joinLetterform(
275		left,
276		stretchLetterformPart(center, letterformProps{
277			stretch:    stretch,
278			width:      3,
279			minStretch: 7,
280			maxStretch: 12,
281		}),
282		right,
283	)
284}
285
286// letterU renders the letter U in a stylized way. It takes an integer that
287// determines how many cells to stretch the letter. If the stretch is less than
288// 1, it defaults to no stretching.
289func letterU(stretch bool) string {
290	// Here's what we're making:
291	//
292	// β   β
293	// β   β
294	//	βββ
295
296	side := heredoc.Doc(`
297		β
298		β
299	`)
300	middle := heredoc.Doc(`
301
302
303		β
304	`)
305	return joinLetterform(
306		side,
307		stretchLetterformPart(middle, letterformProps{
308			stretch:    stretch,
309			width:      3,
310			minStretch: 7,
311			maxStretch: 12,
312		}),
313		side,
314	)
315}
316
317func joinLetterform(letters ...string) string {
318	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
319}
320
321// letterformProps defines letterform stretching properties.
322// for readability.
323type letterformProps struct {
324	width      int
325	minStretch int
326	maxStretch int
327	stretch    bool
328}
329
330// stretchLetterformPart is a helper function for letter stretching. If randomize
331// is false the minimum number will be used.
332func stretchLetterformPart(s string, p letterformProps) string {
333	if p.maxStretch < p.minStretch {
334		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
335	}
336	n := p.width
337	if p.stretch {
338		n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
339	}
340	parts := make([]string, n)
341	for i := range parts {
342		parts[i] = s
343	}
344	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
345}