refactor(ui): reorganize ui components into flat structure

Ayman Bagabas created

Change summary

internal/ui/common.go        |  17 +++
internal/ui/dialog.go        |  35 ++++--
internal/ui/dialog/keymap.go |  57 -----------
internal/ui/model.go         |   2 
internal/ui/quit_dialog.go   |  87 +++++++++++-----
internal/ui/styles.go        | 194 ++++++++++++++++++++++++++++++++++++++
internal/ui/ui.go            |  25 ++--
7 files changed, 310 insertions(+), 107 deletions(-)

Detailed changes

internal/ui/common.go 🔗

@@ -0,0 +1,17 @@
+package ui
+
+import "github.com/charmbracelet/crush/internal/config"
+
+// Common defines common UI options and configurations.
+type Common struct {
+	Config *config.Config
+	Styles Styles
+}
+
+// DefaultCommon returns the default common UI configurations.
+func DefaultCommon(cfg *config.Config) *Common {
+	return &Common{
+		Config: cfg,
+		Styles: DefaultStyles(),
+	}
+}

internal/ui/dialog/dialog.go → internal/ui/dialog.go 🔗

@@ -1,29 +1,42 @@
-package dialog
+package ui
 
 import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
-// Model is a component that can be displayed on top of the UI.
-type Model interface {
-	common.Model[Model]
+// DialogOverlayKeyMap defines key bindings for dialogs.
+type DialogOverlayKeyMap struct {
+	Close key.Binding
+}
+
+// DefaultDialogOverlayKeyMap returns the default key bindings for dialogs.
+func DefaultDialogOverlayKeyMap() DialogOverlayKeyMap {
+	return DialogOverlayKeyMap{
+		Close: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+		),
+	}
+}
+
+// Dialog is a component that can be displayed on top of the UI.
+type Dialog interface {
+	Model[Dialog]
 	ID() string
 }
 
 // Overlay manages multiple dialogs as an overlay.
 type Overlay struct {
-	dialogs []Model
-	keyMap  KeyMap
+	dialogs []Dialog
+	keyMap  DialogOverlayKeyMap
 }
 
-// NewOverlay creates a new [Overlay] instance.
-func NewOverlay(dialogs ...Model) *Overlay {
+// NewDialogOverlay creates a new [Overlay] instance.
+func NewDialogOverlay(dialogs ...Dialog) *Overlay {
 	return &Overlay{
 		dialogs: dialogs,
-		keyMap:  DefaultKeyMap(),
+		keyMap:  DefaultDialogOverlayKeyMap(),
 	}
 }
 
@@ -38,7 +51,7 @@ func (d *Overlay) ContainsDialog(dialogID string) bool {
 }
 
 // AddDialog adds a new dialog to the stack.
-func (d *Overlay) AddDialog(dialog Model) {
+func (d *Overlay) AddDialog(dialog Dialog) {
 	d.dialogs = append(d.dialogs, dialog)
 }
 

internal/ui/dialog/keymap.go 🔗

@@ -1,57 +0,0 @@
-package dialog
-
-import "github.com/charmbracelet/bubbles/v2/key"
-
-// KeyMap defines key bindings for dialogs.
-type KeyMap struct {
-	Close key.Binding
-}
-
-// DefaultKeyMap returns the default key bindings for dialogs.
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-		),
-	}
-}
-
-// QuitKeyMap represents key bindings for the quit dialog.
-type QuitKeyMap struct {
-	LeftRight,
-	EnterSpace,
-	Yes,
-	No,
-	Tab,
-	Close key.Binding
-}
-
-// DefaultQuitKeyMap returns the default key bindings for the quit dialog.
-func DefaultQuitKeyMap() QuitKeyMap {
-	return QuitKeyMap{
-		LeftRight: key.NewBinding(
-			key.WithKeys("left", "right"),
-			key.WithHelp("←/→", "switch options"),
-		),
-		EnterSpace: key.NewBinding(
-			key.WithKeys("enter", " "),
-			key.WithHelp("enter/space", "confirm"),
-		),
-		Yes: key.NewBinding(
-			key.WithKeys("y", "Y", "ctrl+c"),
-			key.WithHelp("y/Y/ctrl+c", "yes"),
-		),
-		No: key.NewBinding(
-			key.WithKeys("n", "N"),
-			key.WithHelp("n/N", "no"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "switch options"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}

internal/ui/common/common.go → internal/ui/model.go 🔗

@@ -1,4 +1,4 @@
-package common
+package ui
 
 import tea "github.com/charmbracelet/bubbletea/v2"
 

internal/ui/dialog/quit.go → internal/ui/quit_dialog.go 🔗

@@ -1,4 +1,4 @@
-package dialog
+package ui
 
 import (
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -6,27 +6,69 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
-// Quit represents a confirmation dialog for quitting the application.
-type Quit struct {
-	keyMap     QuitKeyMap
+// QuitDialogKeyMap represents key bindings for the quit dialog.
+type QuitDialogKeyMap struct {
+	LeftRight,
+	EnterSpace,
+	Yes,
+	No,
+	Tab,
+	Close key.Binding
+}
+
+// DefaultQuitKeyMap returns the default key bindings for the quit dialog.
+func DefaultQuitKeyMap() QuitDialogKeyMap {
+	return QuitDialogKeyMap{
+		LeftRight: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "switch options"),
+		),
+		EnterSpace: key.NewBinding(
+			key.WithKeys("enter", " "),
+			key.WithHelp("enter/space", "confirm"),
+		),
+		Yes: key.NewBinding(
+			key.WithKeys("y", "Y", "ctrl+c"),
+			key.WithHelp("y/Y/ctrl+c", "yes"),
+		),
+		No: key.NewBinding(
+			key.WithKeys("n", "N"),
+			key.WithHelp("n/N", "no"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch options"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// QuitDialog represents a confirmation dialog for quitting the application.
+type QuitDialog struct {
+	com        *Common
+	keyMap     QuitDialogKeyMap
 	selectedNo bool // true if "No" button is selected
 }
 
-// NewQuit creates a new quit confirmation dialog.
-func NewQuit() *Quit {
-	q := &Quit{
+// NewQuitDialog creates a new quit confirmation dialog.
+func NewQuitDialog(com *Common) *QuitDialog {
+	q := &QuitDialog{
+		com:    com,
 		keyMap: DefaultQuitKeyMap(),
 	}
 	return q
 }
 
 // ID implements [Model].
-func (*Quit) ID() string {
+func (*QuitDialog) ID() string {
 	return "quit"
 }
 
 // Update implements [Model].
-func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) {
+func (q *QuitDialog) Update(msg tea.Msg) (Dialog, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -49,19 +91,15 @@ func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) {
 }
 
 // View implements [Model].
-func (q *Quit) View() string {
+func (q *QuitDialog) View() string {
 	const question = "Are you sure you want to quit?"
-
-	baseStyle := lipgloss.NewStyle()
-	yesStyle := lipgloss.NewStyle()
-	noStyle := yesStyle
+	baseStyle := q.com.Styles.Base
+	yesStyle := q.com.Styles.ButtonSelected
+	noStyle := q.com.Styles.ButtonUnselected
 
 	if q.selectedNo {
-		noStyle = noStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15"))
-		yesStyle = yesStyle.Background(lipgloss.Color("15"))
-	} else {
-		yesStyle = yesStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15"))
-		noStyle = noStyle.Background(lipgloss.Color("15"))
+		noStyle = q.com.Styles.ButtonSelected
+		yesStyle = q.com.Styles.ButtonUnselected
 	}
 
 	const horizontalPadding = 3
@@ -83,16 +121,11 @@ func (q *Quit) View() string {
 		),
 	)
 
-	quitDialogStyle := baseStyle.
-		Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(lipgloss.Color("15"))
-
-	return quitDialogStyle.Render(content)
+	return q.com.Styles.BorderFocus.Render(content)
 }
 
 // ShortHelp implements [help.KeyMap].
-func (q *Quit) ShortHelp() []key.Binding {
+func (q *QuitDialog) ShortHelp() []key.Binding {
 	return []key.Binding{
 		q.keyMap.LeftRight,
 		q.keyMap.EnterSpace,
@@ -100,7 +133,7 @@ func (q *Quit) ShortHelp() []key.Binding {
 }
 
 // FullHelp implements [help.KeyMap].
-func (q *Quit) FullHelp() [][]key.Binding {
+func (q *QuitDialog) FullHelp() [][]key.Binding {
 	return [][]key.Binding{
 		{q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
 		{q.keyMap.Tab, q.keyMap.Close},

internal/ui/styles.go 🔗

@@ -0,0 +1,194 @@
+package ui
+
+import (
+	"github.com/charmbracelet/bubbles/v2/filepicker"
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/textarea"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/glamour/v2/ansi"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+const (
+	CheckIcon    string = "✓"
+	ErrorIcon    string = "×"
+	WarningIcon  string = "⚠"
+	InfoIcon     string = "ⓘ"
+	HintIcon     string = "∵"
+	SpinnerIcon  string = "..."
+	LoadingIcon  string = "⟳"
+	DocumentIcon string = "🖼"
+	ModelIcon    string = "◇"
+
+	ToolPending string = "●"
+	ToolSuccess string = "✓"
+	ToolError   string = "×"
+
+	BorderThin  string = "│"
+	BorderThick string = "▌"
+)
+
+type Styles struct {
+	WindowTooSmall lipgloss.Style
+
+	// Reusable text styles
+	Base   lipgloss.Style
+	Muted  lipgloss.Style
+	Subtle lipgloss.Style
+
+	// Tags
+	TagBase  lipgloss.Style
+	TagError lipgloss.Style
+	TagInfo  lipgloss.Style
+
+	// Headers
+	HeaderTool       lipgloss.Style
+	HeaderToolNested lipgloss.Style
+
+	// Panels
+	PanelMuted lipgloss.Style
+	PanelBase  lipgloss.Style
+
+	// Line numbers for code blocks
+	LineNumber lipgloss.Style
+
+	// Message borders
+	FocusedMessageBorder lipgloss.Border
+
+	// Tool calls
+	ToolCallPending   lipgloss.Style
+	ToolCallError     lipgloss.Style
+	ToolCallSuccess   lipgloss.Style
+	ToolCallCancelled lipgloss.Style
+	EarlyStateMessage lipgloss.Style
+
+	// Text selection
+	TextSelection lipgloss.Style
+
+	// LSP and MCP status indicators
+	ItemOfflineIcon lipgloss.Style
+	ItemBusyIcon    lipgloss.Style
+	ItemErrorIcon   lipgloss.Style
+	ItemOnlineIcon  lipgloss.Style
+
+	// Markdown & Chroma
+	Markdown ansi.StyleConfig
+
+	// Inputs
+	TextInput textinput.Styles
+	TextArea  textarea.Styles
+
+	// Help
+	Help help.Styles
+
+	// Diff
+	Diff diffview.Style
+
+	// FilePicker
+	FilePicker filepicker.Styles
+
+	// Buttons
+	ButtonSelected   lipgloss.Style
+	ButtonUnselected lipgloss.Style
+
+	// Borders
+	BorderFocus lipgloss.Style
+	BorderBlur  lipgloss.Style
+}
+
+func DefaultStyles() Styles {
+	var (
+		// primary   = charmtone.Charple
+		secondary = charmtone.Dolly
+		// tertiary  = charmtone.Bok
+		// accent    = charmtone.Zest
+
+		// Backgrounds
+		bgBase        = charmtone.Pepper
+		bgBaseLighter = charmtone.BBQ
+		bgSubtle      = charmtone.Charcoal
+		// bgOverlay     = charmtone.Iron
+
+		// Foregrounds
+		fgBase      = charmtone.Ash
+		fgMuted     = charmtone.Squid
+		fgHalfMuted = charmtone.Smoke
+		fgSubtle    = charmtone.Oyster
+		// fgSelected  = charmtone.Salt
+
+		// Borders
+		// border      = charmtone.Charcoal
+		borderFocus = charmtone.Charple
+
+		// Status
+		// success = charmtone.Guac
+		// error   = charmtone.Sriracha
+		// warning = charmtone.Zest
+		// info    = charmtone.Malibu
+
+		// Colors
+		white = charmtone.Butter
+
+		blueLight = charmtone.Sardine
+		blue      = charmtone.Malibu
+
+		// yellow = charmtone.Mustard
+		// citron = charmtone.Citron
+
+		green     = charmtone.Julep
+		greenDark = charmtone.Guac
+		// greenLight = charmtone.Bok
+
+		// red      = charmtone.Coral
+		redDark = charmtone.Sriracha
+		// redLight = charmtone.Salmon
+		// cherry   = charmtone.Cherry
+	)
+
+	s := Styles{}
+
+	// borders
+	s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
+
+	// text presets
+	s.Base = lipgloss.NewStyle().Foreground(fgBase)
+	s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
+	s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	s.WindowTooSmall = s.Muted
+
+	// tag presets
+	s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white)
+	s.TagError = s.TagBase.Background(redDark)
+	s.TagInfo = s.TagBase.Background(blueLight)
+
+	// headers
+	s.HeaderTool = lipgloss.NewStyle().Foreground(blue)
+	s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted)
+
+	// panels
+	s.PanelMuted = s.Muted.Background(bgBaseLighter)
+	s.PanelBase = lipgloss.NewStyle().Background(bgBase)
+
+	// code line number
+	s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
+
+	// Tool calls
+	s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending)
+	s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError)
+	s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess)
+	// Cancelled uses muted tone but same glyph as pending
+	s.ToolCallCancelled = s.Muted.SetString(ToolPending)
+	s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
+
+	// Buttons
+	s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary)
+	s.ButtonUnselected = s.Base.Background(bgSubtle)
+
+	// Borders
+	s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
+
+	return s
+}

internal/ui/ui.go 🔗

@@ -6,7 +6,6 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/lipgloss/v2"
 	uv "github.com/charmbracelet/ultraviolet"
 )
@@ -17,29 +16,33 @@ const (
 	uiStateMain uiState = iota
 )
 
-type Model struct {
-	app           *app.App
+type UI struct {
+	app *app.App
+	com *Common
+
 	width, height int
 	state         uiState
 
 	keyMap KeyMap
+	styles Styles
 
-	dialog *dialog.Overlay
+	dialog *Overlay
 }
 
-func New(app *app.App) *Model {
-	return &Model{
+func New(com *Common, app *app.App) *UI {
+	return &UI{
 		app:    app,
-		dialog: dialog.NewOverlay(),
+		com:    com,
+		dialog: NewDialogOverlay(),
 		keyMap: DefaultKeyMap(),
 	}
 }
 
-func (m *Model) Init() tea.Cmd {
+func (m *UI) Init() tea.Cmd {
 	return nil
 }
 
-func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
@@ -50,7 +53,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case uiStateMain:
 			switch {
 			case key.Matches(msg, m.keyMap.Quit):
-				quitDialog := dialog.NewQuit()
+				quitDialog := NewQuitDialog(m.com)
 				if !m.dialog.ContainsDialog(quitDialog.ID()) {
 					m.dialog.AddDialog(quitDialog)
 					return m, nil
@@ -68,7 +71,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *Model) View() tea.View {
+func (m *UI) View() tea.View {
 	var v tea.View
 
 	// The screen area we're working with