From 27fa971aff9352dc74acf834d2a5ca87800d4f8b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 22 Oct 2025 16:22:26 -0400 Subject: [PATCH] feat(ui): add basic dialog and new UI structure --- internal/cmd/root.go | 5 +- internal/ui/common/common.go | 9 +++ internal/ui/dialog/dialog.go | 123 +++++++++++++++++++++++++++++++++++ internal/ui/dialog/keymap.go | 57 ++++++++++++++++ internal/ui/dialog/quit.go | 108 ++++++++++++++++++++++++++++++ internal/ui/keys.go | 36 ++++++++++ internal/ui/ui.go | 103 +++++++++++++++++++++++++++++ 7 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 internal/ui/common/common.go create mode 100644 internal/ui/dialog/dialog.go create mode 100644 internal/ui/dialog/keymap.go create mode 100644 internal/ui/dialog/quit.go create mode 100644 internal/ui/keys.go create mode 100644 internal/ui/ui.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 005f2e86f7012b265fb619580c7cc2eec2e4de03..fbd86bd7592d4bd8a65820d2131fdb9873de8200 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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 diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go new file mode 100644 index 0000000000000000000000000000000000000000..095959b2b34bf864a4bf400d956f63a66b1afbf5 --- /dev/null +++ b/internal/ui/common/common.go @@ -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 +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go new file mode 100644 index 0000000000000000000000000000000000000000..6276bc7c66dd6cbfd99896bc8987958388b7e87e --- /dev/null +++ b/internal/ui/dialog/dialog.go @@ -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:]...) +} diff --git a/internal/ui/dialog/keymap.go b/internal/ui/dialog/keymap.go new file mode 100644 index 0000000000000000000000000000000000000000..cd52ec88f9fea060f19335c78e6574ee1bd17b21 --- /dev/null +++ b/internal/ui/dialog/keymap.go @@ -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"), + ), + } +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go new file mode 100644 index 0000000000000000000000000000000000000000..48f92a59817440f9d373d9e504e83ef719f11f7a --- /dev/null +++ b/internal/ui/dialog/quit.go @@ -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}, + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..335840fa06c7927588410c71140d3537881a434b --- /dev/null +++ b/internal/ui/keys.go @@ -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"), + ), + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..2c2ce28e0d2a5f144a8025a43a6c9a01ae778c65 --- /dev/null +++ b/internal/ui/ui.go @@ -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) +}