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 "charm.land/lipgloss/v2"
11 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/x/ansi"
13)
14
15// letterform represents a letterform. It can be stretched horizontally by
16// a given amount via the boolean argument.
17type letterform func(bool) string
18
19const diag = `╱`
20
21// Opts are the options for rendering the Crush title art.
22type Opts struct {
23 FieldColor color.Color // diagonal lines
24 TitleColorA color.Color // left gradient ramp point
25 TitleColorB color.Color // right gradient ramp point
26 CharmColor color.Color // Charm™ text color
27 VersionColor color.Color // version text color
28 Width int // width of the rendered logo, used for truncation
29 Hyper bool // whether it is Crush or Hypercrush
30
31 // When true, stretch a random letterform on each render. Has no effect in
32 // compact mode. Mainly for testing. In production you will want to cache
33 // the stretched letterform to keep the logo from jittering on resize.
34 Unstable bool
35}
36
37// Render renders the Crush logo. Set the argument to true to render the narrow
38// version, intended for use in a sidebar.
39//
40// The compact argument determines whether it renders compact for the sidebar
41// or wider for the main pane.
42func Render(base lipgloss.Style, version string, compact bool, o Opts) string {
43 charm := "Charm™"
44 if !o.Hyper {
45 charm = " " + charm
46 }
47
48 fg := func(c color.Color, s string) string {
49 return lipgloss.NewStyle().Foreground(c).Render(s)
50 }
51
52 // Title.
53 const spacing = 1
54 var hyperLetterforms []letterform
55 if o.Hyper {
56 hyperLetterforms = []letterform{
57 LetterH,
58 LetterYAlt,
59 LetterP,
60 LetterE,
61 LetterR,
62 }
63 }
64 crushLetterforms := []letterform{
65 LetterC,
66 LetterR,
67 LetterU,
68 LetterSAlt,
69 LetterH,
70 }
71 if o.Hyper && !compact {
72 crushLetterforms = append(hyperLetterforms, crushLetterforms...)
73 }
74
75 stretchIndex := -1 // -1 means no stretching.
76 if !compact && !o.Unstable {
77 // Always stretch the same letterform, which is picked once at random.
78 stretchIndex = cachedRandN(len(crushLetterforms))
79 } else if !compact && o.Unstable {
80 // Stretch a random letterform on every render.
81 stretchIndex = rand.IntN(len(crushLetterforms))
82 }
83 crush := renderWord(spacing, stretchIndex, crushLetterforms...)
84 if o.Hyper && compact {
85 crush = renderWord(spacing, stretchIndex, hyperLetterforms...) + "\n" + crush
86 }
87 crushWidth := lipgloss.Width(crush)
88 b := new(strings.Builder)
89 for r := range strings.SplitSeq(crush, "\n") {
90 fmt.Fprintln(b, styles.ApplyForegroundGrad(base, r, o.TitleColorA, o.TitleColorB))
91 }
92 crush = b.String()
93
94 // Charm and version.
95 metaRowGap := 1
96 maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
97 version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
98 if o.Hyper && compact {
99 version += " "
100 }
101 gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
102 metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
103
104 // Join the meta row and big Crush title.
105 crush = strings.TrimSpace(metaRow + "\n" + crush)
106
107 // Narrow version. If this is Hypercrush, this is also a stacked version.
108 if compact {
109 field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
110 return strings.Join([]string{field, field, crush, field, ""}, "\n")
111 }
112
113 fieldHeight := lipgloss.Height(crush)
114
115 // Left field.
116 const leftWidth = 6
117 leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
118 leftField := new(strings.Builder)
119 for range fieldHeight {
120 fmt.Fprintln(leftField, leftFieldRow)
121 }
122
123 // Right field.
124 rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
125 const stepDownAt = 0
126 rightField := new(strings.Builder)
127 for i := range fieldHeight {
128 width := rightWidth
129 if i >= stepDownAt {
130 width = rightWidth - (i - stepDownAt)
131 }
132 fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
133 }
134
135 // Return the wide version.
136 const hGap = " "
137 logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
138 if o.Width > 0 {
139 // Truncate the logo to the specified width.
140 lines := strings.Split(logo, "\n")
141 for i, line := range lines {
142 lines[i] = ansi.Truncate(line, o.Width, "")
143 }
144 logo = strings.Join(lines, "\n")
145 }
146 return logo
147}
148
149// SmallRender renders a smaller version of the Crush logo, suitable for
150// smaller windows or sidebar usage.
151func SmallRender(t *styles.Styles, width int) string {
152 title := t.Logo.SmallCharm.Render("Charm™")
153 title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t.Logo.GradCanvas, "Crush", t.Logo.SmallGradFromColor, t.Logo.SmallGradToColor))
154 remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
155 if remainingWidth > 0 {
156 lines := strings.Repeat("╱", remainingWidth)
157 title = fmt.Sprintf("%s %s", title, t.Logo.SmallDiagonals.Render(lines))
158 }
159 return title
160}