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