Detailed changes
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/tui"
+ "github.com/charmbracelet/crush/internal/ui"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/fang"
"github.com/charmbracelet/lipgloss/v2"
@@ -81,8 +82,10 @@ crush -y
event.AppInitialized()
// Set up the TUI.
+ // ui := tui.New(app)
+ ui := ui.New(app)
program := tea.NewProgram(
- tui.New(app),
+ ui,
tea.WithContext(cmd.Context()),
tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
@@ -0,0 +1,9 @@
+package common
+
+import tea "github.com/charmbracelet/bubbletea/v2"
+
+// Model represents a common interface for UI components.
+type Model[T any] interface {
+ Update(msg tea.Msg) (T, tea.Cmd)
+ View() string
+}
@@ -0,0 +1,123 @@
+package dialog
+
+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]
+ ID() string
+}
+
+// Overlay manages multiple dialogs as an overlay.
+type Overlay struct {
+ dialogs []Model
+ keyMap KeyMap
+}
+
+// NewOverlay creates a new [Overlay] instance.
+func NewOverlay(dialogs ...Model) *Overlay {
+ return &Overlay{
+ dialogs: dialogs,
+ keyMap: DefaultKeyMap(),
+ }
+}
+
+// ContainsDialog checks if a dialog with the specified ID exists.
+func (d *Overlay) ContainsDialog(dialogID string) bool {
+ for _, dialog := range d.dialogs {
+ if dialog.ID() == dialogID {
+ return true
+ }
+ }
+ return false
+}
+
+// AddDialog adds a new dialog to the stack.
+func (d *Overlay) AddDialog(dialog Model) {
+ d.dialogs = append(d.dialogs, dialog)
+}
+
+// BringToFront brings the dialog with the specified ID to the front.
+func (d *Overlay) BringToFront(dialogID string) {
+ for i, dialog := range d.dialogs {
+ if dialog.ID() == dialogID {
+ // Move the dialog to the end of the slice
+ d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
+ d.dialogs = append(d.dialogs, dialog)
+ return
+ }
+ }
+}
+
+// Update handles dialog updates.
+func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
+ if len(d.dialogs) == 0 {
+ return d, nil
+ }
+
+ idx := len(d.dialogs) - 1 // active dialog is the last one
+ dialog := d.dialogs[idx]
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if key.Matches(msg, d.keyMap.Close) {
+ // Close the current dialog
+ d.removeDialog(idx)
+ return d, nil
+ }
+ }
+
+ updatedDialog, cmd := dialog.Update(msg)
+ if updatedDialog == nil {
+ // Dialog requested to be closed
+ d.removeDialog(idx)
+ return d, cmd
+ }
+
+ // Update the dialog in the stack
+ d.dialogs[idx] = updatedDialog
+
+ return d, cmd
+}
+
+// View implements [Model].
+func (d *Overlay) View() string {
+ if len(d.dialogs) == 0 {
+ return ""
+ }
+
+ // Compose all the dialogs into a single view
+ canvas := lipgloss.NewCanvas()
+ for _, dialog := range d.dialogs {
+ layer := lipgloss.NewLayer(dialog.View())
+ canvas.AddLayers(layer)
+ }
+
+ return canvas.Render()
+}
+
+// ShortHelp implements [help.KeyMap].
+func (d *Overlay) ShortHelp() []key.Binding {
+ return []key.Binding{
+ d.keyMap.Close,
+ }
+}
+
+// FullHelp implements [help.KeyMap].
+func (d *Overlay) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {d.keyMap.Close},
+ }
+}
+
+// removeDialog removes a dialog from the stack.
+func (d *Overlay) removeDialog(idx int) {
+ if idx < 0 || idx >= len(d.dialogs) {
+ return
+ }
+ d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
+}
@@ -0,0 +1,57 @@
+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"),
+ ),
+ }
+}
@@ -0,0 +1,108 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+// Quit represents a confirmation dialog for quitting the application.
+type Quit struct {
+ keyMap QuitKeyMap
+ selectedNo bool // true if "No" button is selected
+}
+
+// NewQuit creates a new quit confirmation dialog.
+func NewQuit() *Quit {
+ q := &Quit{
+ keyMap: DefaultQuitKeyMap(),
+ }
+ return q
+}
+
+// ID implements [Model].
+func (*Quit) ID() string {
+ return "quit"
+}
+
+// Update implements [Model].
+func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
+ q.selectedNo = !q.selectedNo
+ return q, nil
+ case key.Matches(msg, q.keyMap.EnterSpace):
+ if !q.selectedNo {
+ return q, tea.Quit
+ }
+ return nil, nil
+ case key.Matches(msg, q.keyMap.Yes):
+ return q, tea.Quit
+ case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
+ return nil, nil
+ }
+ }
+
+ return q, nil
+}
+
+// View implements [Model].
+func (q *Quit) View() string {
+ const question = "Are you sure you want to quit?"
+
+ baseStyle := lipgloss.NewStyle()
+ yesStyle := lipgloss.NewStyle()
+ noStyle := yesStyle
+
+ 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"))
+ }
+
+ const horizontalPadding = 3
+ yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
+ yesStyle.PaddingRight(horizontalPadding).Render("ep!")
+ noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
+ noStyle.PaddingRight(horizontalPadding).Render("ope")
+
+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
+ )
+
+ content := baseStyle.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Center,
+ question,
+ "",
+ buttons,
+ ),
+ )
+
+ quitDialogStyle := baseStyle.
+ Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("15"))
+
+ return quitDialogStyle.Render(content)
+}
+
+// ShortHelp implements [help.KeyMap].
+func (q *Quit) ShortHelp() []key.Binding {
+ return []key.Binding{
+ q.keyMap.LeftRight,
+ q.keyMap.EnterSpace,
+ }
+}
+
+// FullHelp implements [help.KeyMap].
+func (q *Quit) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
+ {q.keyMap.Tab, q.keyMap.Close},
+ }
+}
@@ -0,0 +1,36 @@
+package ui
+
+import "github.com/charmbracelet/bubbles/v2/key"
+
+type KeyMap struct {
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ Suspend key.Binding
+ Sessions key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Quit: key.NewBinding(
+ key.WithKeys("ctrl+c"),
+ key.WithHelp("ctrl+c", "quit"),
+ ),
+ Help: key.NewBinding(
+ key.WithKeys("ctrl+g"),
+ key.WithHelp("ctrl+g", "more"),
+ ),
+ Commands: key.NewBinding(
+ key.WithKeys("ctrl+p"),
+ key.WithHelp("ctrl+p", "commands"),
+ ),
+ Suspend: key.NewBinding(
+ key.WithKeys("ctrl+z"),
+ key.WithHelp("ctrl+z", "suspend"),
+ ),
+ Sessions: key.NewBinding(
+ key.WithKeys("ctrl+s"),
+ key.WithHelp("ctrl+s", "sessions"),
+ ),
+ }
+}
@@ -0,0 +1,103 @@
+package ui
+
+import (
+ "image"
+
+ "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"
+)
+
+type uiState uint8
+
+const (
+ uiStateMain uiState = iota
+)
+
+type Model struct {
+ app *app.App
+ width, height int
+ state uiState
+
+ keyMap KeyMap
+
+ dialog *dialog.Overlay
+}
+
+func New(app *app.App) *Model {
+ return &Model{
+ app: app,
+ dialog: dialog.NewOverlay(),
+ keyMap: DefaultKeyMap(),
+ }
+}
+
+func (m *Model) Init() tea.Cmd {
+ return nil
+}
+
+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ case tea.KeyPressMsg:
+ switch m.state {
+ case uiStateMain:
+ switch {
+ case key.Matches(msg, m.keyMap.Quit):
+ quitDialog := dialog.NewQuit()
+ if !m.dialog.ContainsDialog(quitDialog.ID()) {
+ m.dialog.AddDialog(quitDialog)
+ return m, nil
+ }
+ }
+ }
+ }
+
+ updatedDialog, cmd := m.dialog.Update(msg)
+ m.dialog = updatedDialog
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+func (m *Model) View() tea.View {
+ var v tea.View
+
+ // The screen area we're working with
+ area := image.Rect(0, 0, m.width, m.height)
+ layers := []*lipgloss.Layer{}
+
+ if dialogView := m.dialog.View(); dialogView != "" {
+ dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
+ dialogArea := centerRect(area, dialogWidth, dialogHeight)
+ layers = append(layers,
+ lipgloss.NewLayer(dialogView).
+ X(dialogArea.Min.X).
+ Y(dialogArea.Min.Y),
+ )
+ }
+
+ v.Layer = lipgloss.NewCanvas(layers...)
+
+ return v
+}
+
+// centerRect returns a new [Rectangle] centered within the given area with the
+// specified width and height.
+func centerRect(area uv.Rectangle, width, height int) uv.Rectangle {
+ centerX := area.Min.X + area.Dx()/2
+ centerY := area.Min.Y + area.Dy()/2
+ minX := centerX - width/2
+ minY := centerY - height/2
+ maxX := minX + width
+ maxY := minY + height
+ return image.Rect(minX, minY, maxX, maxY)
+}