on main help sign in
feat(ui): add initial sidebar component with logo
commit470e6f6de7b40957e080a17f25b879c4296eb329 authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 3 Nov 2025, 09:44 (1 year ago) parent538d203
6 files changed+492−15
internal/ui/common/common.go+3−2 internal/ui/logo/logo.go+346 internal/ui/logo/rand.go+24 internal/ui/model/sidebar.go+81 internal/ui/model/ui.go+14−13 internal/ui/styles/styles.go+24
internal/ui/common/common.go+3−2
@@ -11,14 +11,15 @@ import (
1111// Common defines common UI options and configurations.
1212type Common struct {
1313 Config *config.Config
14− Styles styles.Styles
14+ Styles *styles.Styles
1515}
1616
1717// DefaultCommon returns the default common UI configurations.
1818func DefaultCommon(cfg *config.Config) *Common {
19+ s := styles.DefaultStyles()
1920 return &Common{
2021 Config: cfg,
21− Styles: styles.DefaultStyles(),
22+ Styles: &s,
2223 }
2324}
2425
internal/ui/logo/logo.gonew file+346
@@ -0,0 +1,346 @@
1+// Package logo renders a Crush wordmark in a stylized way.
2+package logo
3+
4+import (
5+ "fmt"
6+ "image/color"
7+ "strings"
8+
9+ "github.com/MakeNowJust/heredoc"
10+ "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/charmbracelet/x/exp/slice"
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+ Width int // width of the rendered logo, used for truncation
30+}
31+
32+// Render renders the Crush logo. Set the argument to true to render the narrow
33+// version, intended for use in a sidebar.
34+//
35+// The compact argument determines whether it renders compact for the sidebar
36+// or wider for the main pane.
37+func Render(version string, compact bool, o Opts) string {
38+ const charm = " Charm™"
39+
40+ fg := func(c color.Color, s string) string {
41+ return lipgloss.NewStyle().Foreground(c).Render(s)
42+ }
43+
44+ // Title.
45+ const spacing = 1
46+ letterforms := []letterform{
47+ letterC,
48+ letterR,
49+ letterU,
50+ letterSStylized,
51+ letterH,
52+ }
53+ stretchIndex := -1 // -1 means no stretching.
54+ if !compact {
55+ stretchIndex = cachedRandN(len(letterforms))
56+ }
57+
58+ crush := renderWord(spacing, stretchIndex, letterforms...)
59+ crushWidth := lipgloss.Width(crush)
60+ b := new(strings.Builder)
61+ for r := range strings.SplitSeq(crush, "\n") {
62+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
63+ }
64+ crush = b.String()
65+
66+ // Charm and version.
67+ metaRowGap := 1
68+ maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
69+ version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
70+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
71+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
72+
73+ // Join the meta row and big Crush title.
74+ crush = strings.TrimSpace(metaRow + "\n" + crush)
75+
76+ // Narrow version.
77+ if compact {
78+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
79+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
80+ }
81+
82+ fieldHeight := lipgloss.Height(crush)
83+
84+ // Left field.
85+ const leftWidth = 6
86+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
87+ leftField := new(strings.Builder)
88+ for range fieldHeight {
89+ fmt.Fprintln(leftField, leftFieldRow)
90+ }
91+
92+ // Right field.
93+ rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
94+ const stepDownAt = 0
95+ rightField := new(strings.Builder)
96+ for i := range fieldHeight {
97+ width := rightWidth
98+ if i >= stepDownAt {
99+ width = rightWidth - (i - stepDownAt)
100+ }
101+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
102+ }
103+
104+ // Return the wide version.
105+ const hGap = " "
106+ logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
107+ if o.Width > 0 {
108+ // Truncate the logo to the specified width.
109+ lines := strings.Split(logo, "\n")
110+ for i, line := range lines {
111+ lines[i] = ansi.Truncate(line, o.Width, "")
112+ }
113+ logo = strings.Join(lines, "\n")
114+ }
115+ return logo
116+}
117+
118+// SmallRender renders a smaller version of the Crush logo, suitable for
119+// smaller windows or sidebar usage.
120+func SmallRender(width int) string {
121+ t := styles.CurrentTheme()
122+ title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
123+ title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
124+ remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
125+ if remainingWidth > 0 {
126+ lines := strings.Repeat("╱", remainingWidth)
127+ title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
128+ }
129+ return title
130+}
131+
132+// renderWord renders letterforms to fork a word. stretchIndex is the index of
133+// the letter to stretch, or -1 if no letter should be stretched.
134+func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
135+ if spacing < 0 {
136+ spacing = 0
137+ }
138+
139+ renderedLetterforms := make([]string, len(letterforms))
140+
141+ // pick one letter randomly to stretch
142+ for i, letter := range letterforms {
143+ renderedLetterforms[i] = letter(i == stretchIndex)
144+ }
145+
146+ if spacing > 0 {
147+ // Add spaces between the letters and render.
148+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
149+ }
150+ return strings.TrimSpace(
151+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
152+ )
153+}
154+
155+// letterC renders the letter C in a stylized way. It takes an integer that
156+// determines how many cells to stretch the letter. If the stretch is less than
157+// 1, it defaults to no stretching.
158+func letterC(stretch bool) string {
159+ // Here's what we're making:
160+ //
161+ // ▄▀▀▀▀
162+ // █
163+ // ▀▀▀▀
164+
165+ left := heredoc.Doc(`
166+ ▄
167+ █
168+ `)
169+ right := heredoc.Doc(`
170+ ▀
171+
172+ ▀
173+ `)
174+ return joinLetterform(
175+ left,
176+ stretchLetterformPart(right, letterformProps{
177+ stretch: stretch,
178+ width: 4,
179+ minStretch: 7,
180+ maxStretch: 12,
181+ }),
182+ )
183+}
184+
185+// letterH renders the letter H in a stylized way. It takes an integer that
186+// determines how many cells to stretch the letter. If the stretch is less than
187+// 1, it defaults to no stretching.
188+func letterH(stretch bool) string {
189+ // Here's what we're making:
190+ //
191+ // █ █
192+ // █▀▀▀█
193+ // ▀ ▀
194+
195+ side := heredoc.Doc(`
196+ █
197+ █
198+ ▀`)
199+ middle := heredoc.Doc(`
200+
201+ ▀
202+ `)
203+ return joinLetterform(
204+ side,
205+ stretchLetterformPart(middle, letterformProps{
206+ stretch: stretch,
207+ width: 3,
208+ minStretch: 8,
209+ maxStretch: 12,
210+ }),
211+ side,
212+ )
213+}
214+
215+// letterR renders the letter R 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 letterR(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+ right := heredoc.Doc(`
235+ ▄
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+// letterSStylized renders the letter S in a stylized way, more so than
252+// [letterS]. It takes an integer that determines how many cells to stretch the
253+// letter. If the stretch is less than 1, it defaults to no stretching.
254+func letterSStylized(stretch bool) string {
255+ // Here's what we're making:
256+ //
257+ // ▄▀▀▀▀▀
258+ // ▀▀▀▀▀█
259+ // ▀▀▀▀▀
260+
261+ left := heredoc.Doc(`
262+ ▄
263+ ▀
264+ ▀
265+ `)
266+ center := heredoc.Doc(`
267+ ▀
268+ ▀
269+ ▀
270+ `)
271+ right := heredoc.Doc(`
272+ ▀
273+ █
274+ `)
275+ return joinLetterform(
276+ left,
277+ stretchLetterformPart(center, letterformProps{
278+ stretch: stretch,
279+ width: 3,
280+ minStretch: 7,
281+ maxStretch: 12,
282+ }),
283+ right,
284+ )
285+}
286+
287+// letterU renders the letter U in a stylized way. It takes an integer that
288+// determines how many cells to stretch the letter. If the stretch is less than
289+// 1, it defaults to no stretching.
290+func letterU(stretch bool) string {
291+ // Here's what we're making:
292+ //
293+ // █ █
294+ // █ █
295+ // ▀▀▀
296+
297+ side := heredoc.Doc(`
298+ █
299+ █
300+ `)
301+ middle := heredoc.Doc(`
302+
303+
304+ ▀
305+ `)
306+ return joinLetterform(
307+ side,
308+ stretchLetterformPart(middle, letterformProps{
309+ stretch: stretch,
310+ width: 3,
311+ minStretch: 7,
312+ maxStretch: 12,
313+ }),
314+ side,
315+ )
316+}
317+
318+func joinLetterform(letters ...string) string {
319+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
320+}
321+
322+// letterformProps defines letterform stretching properties.
323+// for readability.
324+type letterformProps struct {
325+ width int
326+ minStretch int
327+ maxStretch int
328+ stretch bool
329+}
330+
331+// stretchLetterformPart is a helper function for letter stretching. If randomize
332+// is false the minimum number will be used.
333+func stretchLetterformPart(s string, p letterformProps) string {
334+ if p.maxStretch < p.minStretch {
335+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
336+ }
337+ n := p.width
338+ if p.stretch {
339+ n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
340+ }
341+ parts := make([]string, n)
342+ for i := range parts {
343+ parts[i] = s
344+ }
345+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
346+}
internal/ui/logo/rand.gonew file+24
@@ -0,0 +1,24 @@
1+package logo
2+
3+import (
4+ "math/rand/v2"
5+ "sync"
6+)
7+
8+var (
9+ randCaches = make(map[int]int)
10+ randCachesMu sync.Mutex
11+)
12+
13+func cachedRandN(n int) int {
14+ randCachesMu.Lock()
15+ defer randCachesMu.Unlock()
16+
17+ if n, ok := randCaches[n]; ok {
18+ return n
19+ }
20+
21+ r := rand.IntN(n)
22+ randCaches[n] = r
23+ return r
24+}
internal/ui/model/sidebar.gonew file+81
@@ -0,0 +1,81 @@
1+package model
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/ui/common"
6+ "github.com/charmbracelet/crush/internal/ui/logo"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+ "github.com/charmbracelet/crush/internal/version"
9+ "github.com/charmbracelet/lipgloss/v2"
10+)
11+
12+// SidebarModel is the model for the sidebar UI component.
13+type SidebarModel struct {
14+ com *common.Common
15+
16+ // width of the sidebar.
17+ width int
18+
19+ // Cached rendered logo string.
20+ logo string
21+ // Cached cwd string.
22+ cwd string
23+
24+ // TODO: lsp, files, session
25+
26+ // Whether to render the sidebar in compact mode.
27+ compact bool
28+}
29+
30+// NewSidebarModel creates a new SidebarModel instance.
31+func NewSidebarModel(com *common.Common) *SidebarModel {
32+ return &SidebarModel{
33+ com: com,
34+ compact: true,
35+ cwd: com.Config.WorkingDir(),
36+ }
37+}
38+
39+// Init initializes the sidebar model.
40+func (m *SidebarModel) Init() tea.Cmd {
41+ return nil
42+}
43+
44+// Update updates the sidebar model based on incoming messages.
45+func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) {
46+ return m, nil
47+}
48+
49+// View renders the sidebar model as a string.
50+func (m *SidebarModel) View() string {
51+ s := m.com.Styles.SidebarFull
52+ if m.compact {
53+ s = m.com.Styles.SidebarCompact
54+ }
55+
56+ blocks := []string{
57+ m.logo,
58+ }
59+
60+ return s.Render(lipgloss.JoinVertical(
61+ lipgloss.Top,
62+ blocks...,
63+ ))
64+}
65+
66+// SetWidth sets the width of the sidebar and updates the logo accordingly.
67+func (m *SidebarModel) SetWidth(width int) {
68+ m.logo = logoBlock(m.com.Styles, width)
69+ m.width = width
70+}
71+
72+func logoBlock(t *styles.Styles, width int) string {
73+ return logo.Render(version.Version, true, logo.Opts{
74+ FieldColor: t.LogoFieldColor,
75+ TitleColorA: t.LogoTitleColorA,
76+ TitleColorB: t.LogoTitleColorB,
77+ CharmColor: t.LogoCharmColor,
78+ VersionColor: t.LogoVersionColor,
79+ Width: max(0, width-2),
80+ })
81+}
internal/ui/model/ui.go+14−13
@@ -36,6 +36,7 @@ type UI struct {
3636
3737 chat *ChatModel
3838 editor *EditorModel
39+ side *SidebarModel
3940 dialog *dialog.Overlay
4041 help help.Model
4142
@@ -58,6 +59,7 @@ func New(com *common.Common, app *app.App) *UI {
5859 dialog: dialog.NewOverlay(),
5960 keyMap: DefaultKeyMap(),
6061 editor: NewEditorModel(com, app),
62+ side: NewSidebarModel(com),
6163 help: help.New(),
6264 }
6365}
@@ -88,9 +90,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8890 }
8991 return m, nil
9092 case tea.WindowSizeMsg:
91− m.updateLayout(msg.Width, msg.Height)
92− m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
93− m.help.Width = m.layout.help.Dx()
93+ m.updateLayoutAndSize(msg.Width, msg.Height)
9494 case tea.KeyPressMsg:
9595 if m.dialog.HasDialogs() {
9696 m.updateDialogs(msg, &cmds)
@@ -106,7 +106,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106106 }
107107 case key.Matches(msg, m.keyMap.Help):
108108 m.help.ShowAll = !m.help.ShowAll
109− m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy())
109+ m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
110110 case key.Matches(msg, m.keyMap.Quit):
111111 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
112112 m.dialog.AddDialog(dialog.NewQuit(m.com))
@@ -172,12 +172,8 @@ func (m *UI) View() tea.View {
172172 Background(lipgloss.ANSIColor(rand.Intn(256))).
173173 Render(" Main View "),
174174 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
175− lipgloss.NewLayer(
176− lipgloss.NewStyle().Width(sideRect.Dx()).
177− Height(sideRect.Dy()).
178− Background(lipgloss.ANSIColor(rand.Intn(256))).
179− Render(" Side View "),
180− ).X(sideRect.Min.X).Y(sideRect.Min.Y),
175+ lipgloss.NewLayer(m.side.View()).
176+ X(sideRect.Min.X).Y(sideRect.Min.Y),
181177 lipgloss.NewLayer(m.editor.View()).
182178 X(editRect.Min.X).Y(editRect.Min.Y),
183179 lipgloss.NewLayer(m.help.View(helpKeyMap)).
@@ -244,9 +240,9 @@ func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
244240 }
245241}
246242
247−// updateLayout updates the layout based on the given terminal width and
248−// height given in cells.
249−func (m *UI) updateLayout(w, h int) {
243+// updateLayoutAndSize updates the layout and sub-models sizes based on the
244+// given terminal width and height given in cells.
245+func (m *UI) updateLayoutAndSize(w, h int) {
250246 // The screen area we're working with
251247 area := image.Rect(0, 0, w, h)
252248 helpKeyMap := m.focusedKeyMap()
@@ -284,6 +280,11 @@ func (m *UI) updateLayout(w, h int) {
284280 sidebar: sideRect,
285281 help: helpRect,
286282 }
283+
284+ // Update sub-model sizes
285+ m.side.SetWidth(m.layout.sidebar.Dx())
286+ m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
287+ m.help.Width = m.layout.help.Dx()
287288}
288289
289290// layout defines the positioning of UI elements.
internal/ui/styles/styles.go+24
@@ -1,6 +1,8 @@
11package styles
22
33import (
4+ "image/color"
5+
46 "github.com/charmbracelet/bubbles/v2/filepicker"
57 "github.com/charmbracelet/bubbles/v2/help"
68 "github.com/charmbracelet/bubbles/v2/textarea"
@@ -110,6 +112,17 @@ type Styles struct {
110112 EditorPromptYoloIconBlurred lipgloss.Style
111113 EditorPromptYoloDotsFocused lipgloss.Style
112114 EditorPromptYoloDotsBlurred lipgloss.Style
115+
116+ // Logo
117+ LogoFieldColor color.Color
118+ LogoTitleColorA color.Color
119+ LogoTitleColorB color.Color
120+ LogoCharmColor color.Color
121+ LogoVersionColor color.Color
122+
123+ // Sidebar
124+ SidebarFull lipgloss.Style
125+ SidebarCompact lipgloss.Style
113126}
114127
115128func DefaultStyles() Styles {
@@ -538,6 +551,17 @@ func DefaultStyles() Styles {
538551 s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
539552 s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
540553
554+ // Logo colors
555+ s.LogoFieldColor = primary
556+ s.LogoTitleColorA = secondary
557+ s.LogoTitleColorB = primary
558+ s.LogoCharmColor = secondary
559+ s.LogoVersionColor = primary
560+
561+ // Sidebar
562+ s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
563+ s.SidebarCompact = s.SidebarFull.PaddingTop(0)
564+
541565 return s
542566}
543567