feat(ui): add initial sidebar component with logo

Ayman Bagabas created

Change summary

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(-)

Detailed changes

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,
 	}
 }
 

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...)
+}

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

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),
+	})
+}

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.

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
 }