diff --git a/go.mod b/go.mod index 52ab603e5f4a0158e0ac2dec3ddfc1cf5f8214ca..87928e392256bead51a38f3773fc6728cb2717b3 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index eb7738075c88558f623578bd0bcfae89480bb1e8..7acf6b4bada37cbe5776a3c84ee3ff0ddc7e1f3a 100644 --- a/go.sum +++ b/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= diff --git a/internal/tui/components/title/title.go b/internal/tui/components/title/title.go new file mode 100644 index 0000000000000000000000000000000000000000..3c97ebab31b74d5a8b291eae9acb5d6eeca5315b --- /dev/null +++ b/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 +}