feat(ui): add basic dialog and new UI structure

Ayman Bagabas created

Change summary

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(-)

Detailed changes

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
 

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
+}

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:]...)
+}

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

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},
+	}
+}

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

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)
+}