diff --git a/internal/ui/common.go b/internal/ui/common.go new file mode 100644 index 0000000000000000000000000000000000000000..72a0bae02667aad6a0b7028b2e1fcdd017549137 --- /dev/null +++ b/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(), + } +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog.go similarity index 77% rename from internal/ui/dialog/dialog.go rename to internal/ui/dialog.go index 6276bc7c66dd6cbfd99896bc8987958388b7e87e..bfae19d05b8f3fc53e7988c8c29ed2454ffb17a9 100644 --- a/internal/ui/dialog/dialog.go +++ b/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) } diff --git a/internal/ui/dialog/keymap.go b/internal/ui/dialog/keymap.go deleted file mode 100644 index cd52ec88f9fea060f19335c78e6574ee1bd17b21..0000000000000000000000000000000000000000 --- a/internal/ui/dialog/keymap.go +++ /dev/null @@ -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"), - ), - } -} diff --git a/internal/ui/common/common.go b/internal/ui/model.go similarity index 92% rename from internal/ui/common/common.go rename to internal/ui/model.go index 095959b2b34bf864a4bf400d956f63a66b1afbf5..ec8876b9adb643530d732a6f92bab5d28a8c1938 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/model.go @@ -1,4 +1,4 @@ -package common +package ui import tea "github.com/charmbracelet/bubbletea/v2" diff --git a/internal/ui/dialog/quit.go b/internal/ui/quit_dialog.go similarity index 50% rename from internal/ui/dialog/quit.go rename to internal/ui/quit_dialog.go index 48f92a59817440f9d373d9e504e83ef719f11f7a..b099ba671f1df17ef7d96c19a94ec91d50d29d24 100644 --- a/internal/ui/dialog/quit.go +++ b/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}, diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..11a3871317b720f63b1819a1a7590e6282797e18 --- /dev/null +++ b/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 +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2c2ce28e0d2a5f144a8025a43a6c9a01ae778c65..c09379203ae67bec3175d75de06b65df881c538d 100644 --- a/internal/ui/ui.go +++ b/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