on main help sign in
feat: title block package
93e39fcChristian Rocha
go.mod+4−4
@@ -5,6 +5,7 @@ go 1.24.0
5require (
5require (
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
7 github.com/JohannesKaufmann/html-to-markdown v1.6.0
7 github.com/JohannesKaufmann/html-to-markdown v1.6.0
8+ github.com/MakeNowJust/heredoc v1.0.0
8 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/anthropics/anthropic-sdk-go v1.4.0
11 github.com/anthropics/anthropic-sdk-go v1.4.0
@@ -26,14 +27,13 @@ require (
26 github.com/ncruces/go-sqlite3 v0.25.0
27 github.com/ncruces/go-sqlite3 v0.25.0
27 github.com/openai/openai-go v0.1.0-beta.2
28 github.com/openai/openai-go v0.1.0-beta.2
28 github.com/pressly/goose/v3 v3.24.2
29 github.com/pressly/goose/v3 v3.24.2
30+ github.com/sahilm/fuzzy v0.1.1
29 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
31 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
30 github.com/spf13/cobra v1.9.1
32 github.com/spf13/cobra v1.9.1
31 github.com/spf13/viper v1.20.0
33 github.com/spf13/viper v1.20.0
32 github.com/stretchr/testify v1.10.0
34 github.com/stretchr/testify v1.10.0
33)
35)
34
36
35−require github.com/sahilm/fuzzy v0.1.1
36−
37require (
37require (
38 cloud.google.com/go v0.116.0 // indirect
38 cloud.google.com/go v0.116.0 // indirect
39 cloud.google.com/go/auth v0.13.0 // indirect
39 cloud.google.com/go/auth v0.13.0 // indirect
@@ -61,7 +61,7 @@ require (
61 github.com/aymerick/douceur v0.2.0 // indirect
61 github.com/aymerick/douceur v0.2.0 // indirect
62 github.com/charmbracelet/colorprofile v0.3.1 // indirect
62 github.com/charmbracelet/colorprofile v0.3.1 // indirect
63 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
63 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
64− github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
64+ github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620
65 github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
65 github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
66 github.com/charmbracelet/x/term v0.2.1 // indirect
66 github.com/charmbracelet/x/term v0.2.1 // indirect
67 github.com/charmbracelet/x/windows v0.2.1 // indirect
67 github.com/charmbracelet/x/windows v0.2.1 // indirect
@@ -92,7 +92,7 @@ require (
92 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
92 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
93 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
93 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
94 github.com/pmezard/go-difflib v1.0.0 // indirect
94 github.com/pmezard/go-difflib v1.0.0 // indirect
95− github.com/rivo/uniseg v0.4.7 // indirect
95+ github.com/rivo/uniseg v0.4.7
96 github.com/rogpeppe/go-internal v1.14.1 // indirect
96 github.com/rogpeppe/go-internal v1.14.1 // indirect
97 github.com/sagikazarmark/locafero v0.7.0 // indirect
97 github.com/sagikazarmark/locafero v0.7.0 // indirect
98 github.com/sethvargo/go-retry v0.3.0 // indirect
98 github.com/sethvargo/go-retry v0.3.0 // indirect
go.sum+2−2
@@ -84,8 +84,8 @@ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz
84github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
84github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
85github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
85github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
86github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
86github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
87−github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
87+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
88−github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
88+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
89github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
89github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
90github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
90github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
91github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
91github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
internal/tui/components/title/title.gonew file+380
@@ -0,0 +1,380 @@
1+package title
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "math/rand/v2"
7+ "strings"
8+
9+ "github.com/MakeNowJust/heredoc"
10+ "github.com/charmbracelet/lipgloss/v2"
11+ "github.com/charmbracelet/x/exp/slice"
12+ "github.com/lucasb-eyer/go-colorful"
13+ "github.com/rivo/uniseg"
14+)
15+
16+// letterform represents a letterform. It can be stretched horizontally by
17+// a given amount via the boolean argument.
18+type letterform func(bool) string
19+
20+const diag = `╱`
21+
22+// Opts are the options for rendering the Crush title art.
23+type Opts struct {
24+ FieldColor color.Color // diagonal lines
25+ TitleColorA color.Color // left gradient ramp point
26+ TitleColorB color.Color // right gradient ramp point
27+ CharmColor color.Color // Charm™ text color
28+ VersionColor color.Color // Version text color
29+}
30+
31+// Render renders the Crush title art. Set the argument to true to render the
32+// narrow version, intended for use in a sidebar.
33+//
34+// The compact argument determins whether it renders compact for the sidebar
35+// or wider for the main pane.
36+func Render(version string, compact bool, o Opts) string {
37+ const charm = "Charm™"
38+
39+ fg := func(c color.Color, s string) string {
40+ return lipgloss.NewStyle().Foreground(c).Render(s)
41+ }
42+
43+ // Title.
44+ crush := renderWord(1, !compact, letterC, letterR, letterU, LetterS, letterH)
45+ crushWidth := lipgloss.Width(crush)
46+ b := new(strings.Builder)
47+ for r := range strings.SplitSeq(crush, "\n") {
48+ fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
49+ }
50+ crush = b.String()
51+
52+ // Charm and version.
53+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
54+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
55+
56+ // Join the meta row and big Crush title.
57+ crush = strings.TrimSpace(metaRow + "\n" + crush)
58+
59+ // Narrow version.
60+ if compact {
61+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
62+ return strings.Join([]string{field, field, crush, field}, "\n")
63+ }
64+
65+ fieldHeight := lipgloss.Height(crush)
66+
67+ // Left field.
68+ const leftWidth = 6
69+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
70+ leftField := new(strings.Builder)
71+ for range fieldHeight {
72+ fmt.Fprintln(leftField, leftFieldRow)
73+ }
74+
75+ // Right field.
76+ const rightWidth = 15
77+ const stepDownAt = 0
78+ rightField := new(strings.Builder)
79+ for i := range fieldHeight {
80+ width := rightWidth
81+ if i >= stepDownAt {
82+ width = rightWidth - (i - stepDownAt)
83+ }
84+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
85+ }
86+
87+ // Return the wide version.
88+ const hGap = " "
89+ return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
90+}
91+
92+// renderWord renders letterforms to fork a word.
93+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
94+ if spacing < 0 {
95+ spacing = 0
96+ }
97+
98+ renderedLetterforms := make([]string, len(letterforms))
99+
100+ // pick one letter randomly to stretch
101+ stretchIndex := -1
102+ if stretchRandomLetter {
103+ stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
104+ }
105+
106+ for i, letter := range letterforms {
107+ renderedLetterforms[i] = letter(i == stretchIndex)
108+ }
109+
110+ if spacing > 0 {
111+ // Add spaces between the letters and render.
112+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
113+ }
114+ return strings.TrimSpace(
115+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
116+ )
117+}
118+
119+// letterC renders the letter C in a stylized way. It takes an integer that
120+// determines how many cells to stretch the letter. If the stretch is less than
121+// 1, it defaults to no stretching.
122+func letterC(stretch bool) string {
123+ // Here's what we're making:
124+ //
125+ // ▄▀▀▀▀
126+ // █
127+ // ▀▀▀▀
128+
129+ left := heredoc.Doc(`
130+ ▄
131+ █
132+ `)
133+ right := heredoc.Doc(`
134+ ▀
135+
136+ ▀
137+ `)
138+ return joinLetterform(
139+ left,
140+ stretchLetterformPart(right, letterformProps{
141+ stretch: stretch,
142+ width: 4,
143+ minStretch: 7,
144+ maxStretch: 12,
145+ }),
146+ )
147+}
148+
149+// letterH renders the letter H in a stylized way. It takes an integer that
150+// determines how many cells to stretch the letter. If the stretch is less than
151+// 1, it defaults to no stretching.
152+func letterH(stretch bool) string {
153+ // Here's what we're making:
154+ //
155+ // █ █
156+ // █▀▀▀█
157+ // ▀ ▀
158+
159+ side := heredoc.Doc(`
160+ █
161+ █
162+ ▀`)
163+ middle := heredoc.Doc(`
164+
165+ ▀
166+ `)
167+ return joinLetterform(
168+ side,
169+ stretchLetterformPart(middle, letterformProps{
170+ stretch: stretch,
171+ width: 3,
172+ minStretch: 8,
173+ maxStretch: 12,
174+ }),
175+ side,
176+ )
177+}
178+
179+// letterR renders the letter R in a stylized way. It takes an integer that
180+// determines how many cells to stretch the letter. If the stretch is less than
181+// 1, it defaults to no stretching.
182+func letterR(stretch bool) string {
183+ // Here's what we're making:
184+ //
185+ // █▀▀▀▄
186+ // █▀▀▀▄
187+ // ▀ ▀
188+
189+ left := heredoc.Doc(`
190+ █
191+ █
192+ ▀
193+ `)
194+ center := heredoc.Doc(`
195+ ▀
196+ ▀
197+ `)
198+ right := heredoc.Doc(`
199+ ▄
200+ ▄
201+ ▀
202+ `)
203+ return joinLetterform(
204+ left,
205+ stretchLetterformPart(center, letterformProps{
206+ stretch: stretch,
207+ width: 3,
208+ minStretch: 7,
209+ maxStretch: 12,
210+ }),
211+ right,
212+ )
213+}
214+
215+// LetterS renders the letter S in a stylized way. It takes an integer that
216+// determines how many cells to stretch the letter. If the stretch is less than
217+// 1, it defaults to no stretching.
218+func LetterS(stretch bool) string {
219+ // Here's what we're making:
220+ //
221+ // ▄▀▀▀▀
222+ // ▀▀▀▄
223+ // ▀▀▀▀
224+
225+ left := heredoc.Doc(`
226+ ▄
227+
228+ ▀
229+ `)
230+ center := heredoc.Doc(`
231+ ▀
232+ ▀
233+ ▀
234+ `)
235+ right := heredoc.Doc(`
236+ ▀
237+ ▄
238+ `)
239+ return joinLetterform(
240+ left,
241+ stretchLetterformPart(center, letterformProps{
242+ stretch: stretch,
243+ width: 3,
244+ minStretch: 7,
245+ maxStretch: 12,
246+ }),
247+ right,
248+ )
249+}
250+
251+// letterU renders the letter U in a stylized way. It takes an integer that
252+// determines how many cells to stretch the letter. If the stretch is less than
253+// 1, it defaults to no stretching.
254+func letterU(stretch bool) string {
255+ // Here's what we're making:
256+ //
257+ // █ █
258+ // █ █
259+ // ▀▀▀
260+
261+ side := heredoc.Doc(`
262+ █
263+ █
264+ `)
265+ middle := heredoc.Doc(`
266+
267+
268+ ▀
269+ `)
270+ return joinLetterform(
271+ side,
272+ stretchLetterformPart(middle, letterformProps{
273+ stretch: stretch,
274+ width: 3,
275+ minStretch: 7,
276+ maxStretch: 12,
277+ }),
278+ side,
279+ )
280+}
281+
282+func joinLetterform(letters ...string) string {
283+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
284+}
285+
286+// letterformProps defines letterform stretching properties.
287+// for readability.
288+type letterformProps struct {
289+ width int
290+ minStretch int
291+ maxStretch int
292+ stretch bool
293+}
294+
295+// stretchLetterformPart is a helper function for letter stretching. If randomize
296+// is false the minimum number will be used.
297+func stretchLetterformPart(s string, p letterformProps) string {
298+ if p.maxStretch < p.minStretch {
299+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
300+ }
301+ n := p.width
302+ if p.stretch {
303+ n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
304+ }
305+ parts := make([]string, n)
306+ for i := range parts {
307+ parts[i] = s
308+ }
309+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
310+}
311+
312+// applyForegroundGrad renders a given string with a horizontal gradient
313+// foreground.
314+func applyForegroundGrad(input string, color1, color2 color.Color) string {
315+ if input == "" {
316+ return ""
317+ }
318+
319+ var o strings.Builder
320+ if len(input) == 1 {
321+ return lipgloss.NewStyle().Foreground(color1).Render(input)
322+ }
323+
324+ var clusters []string
325+ gr := uniseg.NewGraphemes(input)
326+ for gr.Next() {
327+ clusters = append(clusters, string(gr.Runes()))
328+ }
329+
330+ ramp := blendColors(len(clusters), color1, color2)
331+ for i, c := range ramp {
332+ fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
333+ }
334+
335+ return o.String()
336+}
337+
338+// blendColors returns a slice of colors blended between the given keys.
339+// Blending is done in Hcl to stay in gamut.
340+func blendColors(size int, stops ...color.Color) []color.Color {
341+ if len(stops) < 2 {
342+ return nil
343+ }
344+
345+ stopsPrime := make([]colorful.Color, len(stops))
346+ for i, k := range stops {
347+ stopsPrime[i], _ = colorful.MakeColor(k)
348+ }
349+
350+ numSegments := len(stopsPrime) - 1
351+ blended := make([]color.Color, 0, size)
352+
353+ // Calculate how many colors each segment should have.
354+ segmentSizes := make([]int, numSegments)
355+ baseSize := size / numSegments
356+ remainder := size % numSegments
357+
358+ // Distribute the remainder across segments.
359+ for i := range numSegments {
360+ segmentSizes[i] = baseSize
361+ if i < remainder {
362+ segmentSizes[i]++
363+ }
364+ }
365+
366+ // Generate colors for each segment.
367+ for i := range numSegments {
368+ c1 := stopsPrime[i]
369+ c2 := stopsPrime[i+1]
370+ segmentSize := segmentSizes[i]
371+
372+ for j := range segmentSize {
373+ t := float64(j) / float64(segmentSize)
374+ c := c1.BlendHcl(c2, t)
375+ blended = append(blended, c)
376+ }
377+ }
378+
379+ return blended
380+}