1// Package logo renders a Crush wordmark in a stylized way.
2package logo
3
4import (
5 "fmt"
6 "image/color"
7 "strings"
8
9 "charm.land/lipgloss/v2"
10 "github.com/MakeNowJust/heredoc"
11 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/exp/slice"
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 Width int // width of the rendered logo, used for truncation
30}
31
32// Render renders the Crush logo. Set the argument to true to render the narrow
33// version, intended for use in a sidebar.
34//
35// The compact argument determines whether it renders compact for the sidebar
36// or wider for the main pane.
37func Render(s *styles.Styles, 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 const spacing = 1
46 letterforms := []letterform{
47 letterC,
48 letterR,
49 letterU,
50 letterSStylized,
51 letterH,
52 }
53 stretchIndex := -1 // -1 means no stretching.
54 if !compact {
55 stretchIndex = cachedRandN(len(letterforms))
56 }
57
58 crush := renderWord(spacing, stretchIndex, letterforms...)
59 crushWidth := lipgloss.Width(crush)
60 b := new(strings.Builder)
61 for r := range strings.SplitSeq(crush, "\n") {
62 fmt.Fprintln(b, styles.ApplyForegroundGrad(s, r, o.TitleColorA, o.TitleColorB))
63 }
64 crush = b.String()
65
66 // Charm and version.
67 metaRowGap := 1
68 maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
69 version = ansi.Truncate(version, maxVersionWidth, "β¦") // truncate version if too long.
70 gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
71 metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
72
73 // Join the meta row and big Crush title.
74 crush = strings.TrimSpace(metaRow + "\n" + crush)
75
76 // Narrow version.
77 if compact {
78 field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
79 return strings.Join([]string{field, field, crush, field, ""}, "\n")
80 }
81
82 fieldHeight := lipgloss.Height(crush)
83
84 // Left field.
85 const leftWidth = 6
86 leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
87 leftField := new(strings.Builder)
88 for range fieldHeight {
89 fmt.Fprintln(leftField, leftFieldRow)
90 }
91
92 // Right field.
93 rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
94 const stepDownAt = 0
95 rightField := new(strings.Builder)
96 for i := range fieldHeight {
97 width := rightWidth
98 if i >= stepDownAt {
99 width = rightWidth - (i - stepDownAt)
100 }
101 fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
102 }
103
104 // Return the wide version.
105 const hGap = " "
106 logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
107 if o.Width > 0 {
108 // Truncate the logo to the specified width.
109 lines := strings.Split(logo, "\n")
110 for i, line := range lines {
111 lines[i] = ansi.Truncate(line, o.Width, "")
112 }
113 logo = strings.Join(lines, "\n")
114 }
115 return logo
116}
117
118// SmallRender renders a smaller version of the Crush logo, suitable for
119// smaller windows or sidebar usage.
120func SmallRender(t *styles.Styles, width int) string {
121 title := t.Base.Foreground(t.Secondary).Render("Charmβ’")
122 title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t, "Crush", t.Secondary, t.Primary))
123 remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
124 if remainingWidth > 0 {
125 lines := strings.Repeat("β±", remainingWidth)
126 title = fmt.Sprintf("%s %s", title, t.Base.Foreground(t.Primary).Render(lines))
127 }
128 return title
129}
130
131// renderWord renders letterforms to fork a word. stretchIndex is the index of
132// the letter to stretch, or -1 if no letter should be stretched.
133func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
134 if spacing < 0 {
135 spacing = 0
136 }
137
138 renderedLetterforms := make([]string, len(letterforms))
139
140 // pick one letter randomly to stretch
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 = cachedRandN(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}