feat: title block package

Christian Rocha created

Change summary

go.mod                                 |   8 
go.sum                                 |   4 
internal/tui/components/title/title.go | 380 ++++++++++++++++++++++++++++
3 files changed, 386 insertions(+), 6 deletions(-)

Detailed changes

go.mod πŸ”—

@@ -5,6 +5,7 @@ go 1.24.0
 require (
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
+	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.9.2
 	github.com/alecthomas/chroma/v2 v2.15.0
 	github.com/anthropics/anthropic-sdk-go v1.4.0
@@ -26,14 +27,13 @@ require (
 	github.com/ncruces/go-sqlite3 v0.25.0
 	github.com/openai/openai-go v0.1.0-beta.2
 	github.com/pressly/goose/v3 v3.24.2
+	github.com/sahilm/fuzzy v0.1.1
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/viper v1.20.0
 	github.com/stretchr/testify v1.10.0
 )
 
-require github.com/sahilm/fuzzy v0.1.1
-
 require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect
@@ -61,7 +61,7 @@ require (
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/colorprofile v0.3.1 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
-	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
+	github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620
 	github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.1 // indirect
@@ -92,7 +92,7 @@ require (
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/rivo/uniseg v0.4.7
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect

go.sum πŸ”—

@@ -84,8 +84,8 @@ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
 github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
 github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=

internal/tui/components/title/title.go πŸ”—

@@ -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
+}