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