From 470e6f6de7b40957e080a17f25b879c4296eb329 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 3 Nov 2025 09:44:01 -0500 Subject: [PATCH] feat(ui): add initial sidebar component with logo --- internal/ui/common/common.go | 5 +- internal/ui/logo/logo.go | 346 +++++++++++++++++++++++++++++++++++ internal/ui/logo/rand.go | 24 +++ internal/ui/model/sidebar.go | 81 ++++++++ internal/ui/model/ui.go | 27 +-- internal/ui/styles/styles.go | 24 +++ 6 files changed, 492 insertions(+), 15 deletions(-) create mode 100644 internal/ui/logo/logo.go create mode 100644 internal/ui/logo/rand.go create mode 100644 internal/ui/model/sidebar.go diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 88ca494ad5ac885806fe0d8959ae8b5e4b5592f9..3583160faa40a2a2f507f4d56376fc619cb45d09 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -11,14 +11,15 @@ import ( // Common defines common UI options and configurations. type Common struct { Config *config.Config - Styles styles.Styles + Styles *styles.Styles } // DefaultCommon returns the default common UI configurations. func DefaultCommon(cfg *config.Config) *Common { + s := styles.DefaultStyles() return &Common{ Config: cfg, - Styles: styles.DefaultStyles(), + Styles: &s, } } diff --git a/internal/ui/logo/logo.go b/internal/ui/logo/logo.go new file mode 100644 index 0000000000000000000000000000000000000000..6d1fbe5c69b908c27f3819011a02054d9b940452 --- /dev/null +++ b/internal/ui/logo/logo.go @@ -0,0 +1,346 @@ +// Package logo renders a Crush wordmark in a stylized way. +package logo + +import ( + "fmt" + "image/color" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/slice" +) + +// 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 + Width int // width of the rendered logo, used for truncation +} + +// Render renders the Crush logo. Set the argument to true to render the narrow +// version, intended for use in a sidebar. +// +// The compact argument determines 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. + const spacing = 1 + letterforms := []letterform{ + letterC, + letterR, + letterU, + letterSStylized, + letterH, + } + stretchIndex := -1 // -1 means no stretching. + if !compact { + stretchIndex = cachedRandN(len(letterforms)) + } + + crush := renderWord(spacing, stretchIndex, letterforms...) + crushWidth := lipgloss.Width(crush) + b := new(strings.Builder) + for r := range strings.SplitSeq(crush, "\n") { + fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) + } + crush = b.String() + + // Charm and version. + metaRowGap := 1 + maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap + version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long. + 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. + rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap. + 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 = " " + logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + if o.Width > 0 { + // Truncate the logo to the specified width. + lines := strings.Split(logo, "\n") + for i, line := range lines { + lines[i] = ansi.Truncate(line, o.Width, "") + } + logo = strings.Join(lines, "\n") + } + return logo +} + +// SmallRender renders a smaller version of the Crush logo, suitable for +// smaller windows or sidebar usage. +func SmallRender(width int) string { + t := styles.CurrentTheme() + title := t.S().Base.Foreground(t.Secondary).Render("Charm™") + title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary)) + remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush" + if remainingWidth > 0 { + lines := strings.Repeat("╱", remainingWidth) + title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines)) + } + return title +} + +// renderWord renders letterforms to fork a word. stretchIndex is the index of +// the letter to stretch, or -1 if no letter should be stretched. +func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string { + if spacing < 0 { + spacing = 0 + } + + renderedLetterforms := make([]string, len(letterforms)) + + // pick one letter randomly to stretch + 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, + ) +} + +// letterSStylized renders the letter S in a stylized way, more so than +// [letterS]. 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 letterSStylized(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 = cachedRandN(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...) +} diff --git a/internal/ui/logo/rand.go b/internal/ui/logo/rand.go new file mode 100644 index 0000000000000000000000000000000000000000..cf79487e23825b468c98a0f27bbc8dbfbb1a7081 --- /dev/null +++ b/internal/ui/logo/rand.go @@ -0,0 +1,24 @@ +package logo + +import ( + "math/rand/v2" + "sync" +) + +var ( + randCaches = make(map[int]int) + randCachesMu sync.Mutex +) + +func cachedRandN(n int) int { + randCachesMu.Lock() + defer randCachesMu.Unlock() + + if n, ok := randCaches[n]; ok { + return n + } + + r := rand.IntN(n) + randCaches[n] = r + return r +} diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go new file mode 100644 index 0000000000000000000000000000000000000000..e0f3210dbeff7efbcbe82b2a1a478713d1fe8402 --- /dev/null +++ b/internal/ui/model/sidebar.go @@ -0,0 +1,81 @@ +package model + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/version" + "github.com/charmbracelet/lipgloss/v2" +) + +// SidebarModel is the model for the sidebar UI component. +type SidebarModel struct { + com *common.Common + + // width of the sidebar. + width int + + // Cached rendered logo string. + logo string + // Cached cwd string. + cwd string + + // TODO: lsp, files, session + + // Whether to render the sidebar in compact mode. + compact bool +} + +// NewSidebarModel creates a new SidebarModel instance. +func NewSidebarModel(com *common.Common) *SidebarModel { + return &SidebarModel{ + com: com, + compact: true, + cwd: com.Config.WorkingDir(), + } +} + +// Init initializes the sidebar model. +func (m *SidebarModel) Init() tea.Cmd { + return nil +} + +// Update updates the sidebar model based on incoming messages. +func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) { + return m, nil +} + +// View renders the sidebar model as a string. +func (m *SidebarModel) View() string { + s := m.com.Styles.SidebarFull + if m.compact { + s = m.com.Styles.SidebarCompact + } + + blocks := []string{ + m.logo, + } + + return s.Render(lipgloss.JoinVertical( + lipgloss.Top, + blocks..., + )) +} + +// SetWidth sets the width of the sidebar and updates the logo accordingly. +func (m *SidebarModel) SetWidth(width int) { + m.logo = logoBlock(m.com.Styles, width) + m.width = width +} + +func logoBlock(t *styles.Styles, width int) string { + return logo.Render(version.Version, true, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: max(0, width-2), + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cb1a96b5557e59de8b6099e803ba1f806550b769..addcd68f67f6451eb4f2a59ae30d164ade8613e5 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -36,6 +36,7 @@ type UI struct { chat *ChatModel editor *EditorModel + side *SidebarModel dialog *dialog.Overlay help help.Model @@ -58,6 +59,7 @@ func New(com *common.Common, app *app.App) *UI { dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), editor: NewEditorModel(com, app), + side: NewSidebarModel(com), help: help.New(), } } @@ -88,9 +90,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.WindowSizeMsg: - m.updateLayout(msg.Width, msg.Height) - m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) - m.help.Width = m.layout.help.Dx() + m.updateLayoutAndSize(msg.Width, msg.Height) case tea.KeyPressMsg: if m.dialog.HasDialogs() { m.updateDialogs(msg, &cmds) @@ -106,7 +106,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll - m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy()) + m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy()) case key.Matches(msg, m.keyMap.Quit): if !m.dialog.ContainsDialog(dialog.QuitDialogID) { m.dialog.AddDialog(dialog.NewQuit(m.com)) @@ -172,12 +172,8 @@ func (m *UI) View() tea.View { Background(lipgloss.ANSIColor(rand.Intn(256))). Render(" Main View "), ).X(chatRect.Min.X).Y(chatRect.Min.Y), - lipgloss.NewLayer( - lipgloss.NewStyle().Width(sideRect.Dx()). - Height(sideRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Side View "), - ).X(sideRect.Min.X).Y(sideRect.Min.Y), + lipgloss.NewLayer(m.side.View()). + X(sideRect.Min.X).Y(sideRect.Min.Y), lipgloss.NewLayer(m.editor.View()). X(editRect.Min.X).Y(editRect.Min.Y), lipgloss.NewLayer(m.help.View(helpKeyMap)). @@ -244,9 +240,9 @@ func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { } } -// updateLayout updates the layout based on the given terminal width and -// height given in cells. -func (m *UI) updateLayout(w, h int) { +// updateLayoutAndSize updates the layout and sub-models sizes based on the +// given terminal width and height given in cells. +func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) helpKeyMap := m.focusedKeyMap() @@ -284,6 +280,11 @@ func (m *UI) updateLayout(w, h int) { sidebar: sideRect, help: helpRect, } + + // Update sub-model sizes + m.side.SetWidth(m.layout.sidebar.Dx()) + m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) + m.help.Width = m.layout.help.Dx() } // layout defines the positioning of UI elements. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 297cadfe721432a40055b31c2817d7ecbe3237b8..37ae34916bdcf14717a9a4d1395dfa5e754649b9 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -1,6 +1,8 @@ package styles import ( + "image/color" + "github.com/charmbracelet/bubbles/v2/filepicker" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/textarea" @@ -110,6 +112,17 @@ type Styles struct { EditorPromptYoloIconBlurred lipgloss.Style EditorPromptYoloDotsFocused lipgloss.Style EditorPromptYoloDotsBlurred lipgloss.Style + + // Logo + LogoFieldColor color.Color + LogoTitleColorA color.Color + LogoTitleColorB color.Color + LogoCharmColor color.Color + LogoVersionColor color.Color + + // Sidebar + SidebarFull lipgloss.Style + SidebarCompact lipgloss.Style } func DefaultStyles() Styles { @@ -538,6 +551,17 @@ func DefaultStyles() Styles { s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + // Logo colors + s.LogoFieldColor = primary + s.LogoTitleColorA = secondary + s.LogoTitleColorB = primary + s.LogoCharmColor = secondary + s.LogoVersionColor = primary + + // Sidebar + s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) + s.SidebarCompact = s.SidebarFull.PaddingTop(0) + return s }