@@ -0,0 +1,380 @@
+package title
+
+import (
+ "fmt"
+ "image/color"
+ "math/rand/v2"
+ "strings"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/slice"
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/rivo/uniseg"
+)
+
+// letterform represents a letterform. It can be stretched horizontally by
+// a given amount via the boolean argument.
+type letterform func(bool) string
+
+const diag = `β±`
+
+// Opts are the options for rendering the Crush title art.
+type Opts struct {
+ FieldColor color.Color // diagonal lines
+ TitleColorA color.Color // left gradient ramp point
+ TitleColorB color.Color // right gradient ramp point
+ CharmColor color.Color // Charmβ’ text color
+ VersionColor color.Color // Version text color
+}
+
+// Render renders the Crush title art. Set the argument to true to render the
+// narrow version, intended for use in a sidebar.
+//
+// The compact argument determins whether it renders compact for the sidebar
+// or wider for the main pane.
+func Render(version string, compact bool, o Opts) string {
+ const charm = "Charmβ’"
+
+ fg := func(c color.Color, s string) string {
+ return lipgloss.NewStyle().Foreground(c).Render(s)
+ }
+
+ // Title.
+ crush := renderWord(1, !compact, letterC, letterR, letterU, LetterS, letterH)
+ crushWidth := lipgloss.Width(crush)
+ b := new(strings.Builder)
+ for r := range strings.SplitSeq(crush, "\n") {
+ fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+ }
+ crush = b.String()
+
+ // Charm and version.
+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
+
+ // Join the meta row and big Crush title.
+ crush = strings.TrimSpace(metaRow + "\n" + crush)
+
+ // Narrow version.
+ if compact {
+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
+ return strings.Join([]string{field, field, crush, field}, "\n")
+ }
+
+ fieldHeight := lipgloss.Height(crush)
+
+ // Left field.
+ const leftWidth = 6
+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
+ leftField := new(strings.Builder)
+ for range fieldHeight {
+ fmt.Fprintln(leftField, leftFieldRow)
+ }
+
+ // Right field.
+ const rightWidth = 15
+ const stepDownAt = 0
+ rightField := new(strings.Builder)
+ for i := range fieldHeight {
+ width := rightWidth
+ if i >= stepDownAt {
+ width = rightWidth - (i - stepDownAt)
+ }
+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
+ }
+
+ // Return the wide version.
+ const hGap = " "
+ return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
+}
+
+// renderWord renders letterforms to fork a word.
+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
+ if spacing < 0 {
+ spacing = 0
+ }
+
+ renderedLetterforms := make([]string, len(letterforms))
+
+ // pick one letter randomly to stretch
+ stretchIndex := -1
+ if stretchRandomLetter {
+ stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
+ }
+
+ for i, letter := range letterforms {
+ renderedLetterforms[i] = letter(i == stretchIndex)
+ }
+
+ if spacing > 0 {
+ // Add spaces between the letters and render.
+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
+ }
+ return strings.TrimSpace(
+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
+ )
+}
+
+// letterC renders the letter C in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterC(stretch bool) string {
+ // Here's what we're making:
+ //
+ // βββββ
+ // β
+ // ββββ
+
+ left := heredoc.Doc(`
+ β
+ β
+ `)
+ right := heredoc.Doc(`
+ β
+
+ β
+ `)
+ return joinLetterform(
+ left,
+ stretchLetterformPart(right, letterformProps{
+ stretch: stretch,
+ width: 4,
+ minStretch: 7,
+ maxStretch: 12,
+ }),
+ )
+}
+
+// letterH renders the letter H in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterH(stretch bool) string {
+ // Here's what we're making:
+ //
+ // β β
+ // βββββ
+ // β β
+
+ side := heredoc.Doc(`
+ β
+ β
+ β`)
+ middle := heredoc.Doc(`
+
+ β
+ `)
+ return joinLetterform(
+ side,
+ stretchLetterformPart(middle, letterformProps{
+ stretch: stretch,
+ width: 3,
+ minStretch: 8,
+ maxStretch: 12,
+ }),
+ side,
+ )
+}
+
+// letterR renders the letter R in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterR(stretch bool) string {
+ // Here's what we're making:
+ //
+ // βββββ
+ // βββββ
+ // β β
+
+ left := heredoc.Doc(`
+ β
+ β
+ β
+ `)
+ center := heredoc.Doc(`
+ β
+ β
+ `)
+ right := heredoc.Doc(`
+ β
+ β
+ β
+ `)
+ return joinLetterform(
+ left,
+ stretchLetterformPart(center, letterformProps{
+ stretch: stretch,
+ width: 3,
+ minStretch: 7,
+ maxStretch: 12,
+ }),
+ right,
+ )
+}
+
+// LetterS renders the letter S in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func LetterS(stretch bool) string {
+ // Here's what we're making:
+ //
+ // βββββ
+ // ββββ
+ // ββββ
+
+ left := heredoc.Doc(`
+ β
+
+ β
+ `)
+ center := heredoc.Doc(`
+ β
+ β
+ β
+ `)
+ right := heredoc.Doc(`
+ β
+ β
+ `)
+ return joinLetterform(
+ left,
+ stretchLetterformPart(center, letterformProps{
+ stretch: stretch,
+ width: 3,
+ minStretch: 7,
+ maxStretch: 12,
+ }),
+ right,
+ )
+}
+
+// letterU renders the letter U in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterU(stretch bool) string {
+ // Here's what we're making:
+ //
+ // β β
+ // β β
+ // βββ
+
+ side := heredoc.Doc(`
+ β
+ β
+ `)
+ middle := heredoc.Doc(`
+
+
+ β
+ `)
+ return joinLetterform(
+ side,
+ stretchLetterformPart(middle, letterformProps{
+ stretch: stretch,
+ width: 3,
+ minStretch: 7,
+ maxStretch: 12,
+ }),
+ side,
+ )
+}
+
+func joinLetterform(letters ...string) string {
+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
+}
+
+// letterformProps defines letterform stretching properties.
+// for readability.
+type letterformProps struct {
+ width int
+ minStretch int
+ maxStretch int
+ stretch bool
+}
+
+// stretchLetterformPart is a helper function for letter stretching. If randomize
+// is false the minimum number will be used.
+func stretchLetterformPart(s string, p letterformProps) string {
+ if p.maxStretch < p.minStretch {
+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
+ }
+ n := p.width
+ if p.stretch {
+ n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+ }
+ parts := make([]string, n)
+ for i := range parts {
+ parts[i] = s
+ }
+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+}
+
+// applyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func applyForegroundGrad(input string, color1, color2 color.Color) string {
+ if input == "" {
+ return ""
+ }
+
+ var o strings.Builder
+ if len(input) == 1 {
+ return lipgloss.NewStyle().Foreground(color1).Render(input)
+ }
+
+ var clusters []string
+ gr := uniseg.NewGraphemes(input)
+ for gr.Next() {
+ clusters = append(clusters, string(gr.Runes()))
+ }
+
+ ramp := blendColors(len(clusters), color1, color2)
+ for i, c := range ramp {
+ fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
+ }
+
+ return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+ if len(stops) < 2 {
+ return nil
+ }
+
+ stopsPrime := make([]colorful.Color, len(stops))
+ for i, k := range stops {
+ stopsPrime[i], _ = colorful.MakeColor(k)
+ }
+
+ numSegments := len(stopsPrime) - 1
+ blended := make([]color.Color, 0, size)
+
+ // Calculate how many colors each segment should have.
+ segmentSizes := make([]int, numSegments)
+ baseSize := size / numSegments
+ remainder := size % numSegments
+
+ // Distribute the remainder across segments.
+ for i := range numSegments {
+ segmentSizes[i] = baseSize
+ if i < remainder {
+ segmentSizes[i]++
+ }
+ }
+
+ // Generate colors for each segment.
+ for i := range numSegments {
+ c1 := stopsPrime[i]
+ c2 := stopsPrime[i+1]
+ segmentSize := segmentSizes[i]
+
+ for j := range segmentSize {
+ t := float64(j) / float64(segmentSize)
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
+}