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