logo.go

  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}