refactor(ui): dialog: use Action pattern and lipgloss layers

Ayman Bagabas created

Change summary

internal/ui/dialog/dialog.go | 85 ++++++++++++++++++-------------------
internal/ui/dialog/quit.go   | 20 ++++----
internal/ui/model/ui.go      | 61 +++++++++++++++++---------
3 files changed, 91 insertions(+), 75 deletions(-)

Detailed changes

internal/ui/dialog/dialog.go 🔗

@@ -4,7 +4,12 @@ import (
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+// CloseKey is the default key binding to close dialogs.
+var CloseKey = key.NewBinding(
+	key.WithKeys("esc", "alt+esc"),
+	key.WithHelp("esc", "exit"),
 )
 
 // OverlayKeyMap defines key bindings for dialogs.
@@ -12,35 +17,50 @@ type OverlayKeyMap struct {
 	Close key.Binding
 }
 
-// DefaultOverlayKeyMap returns the default key bindings for dialogs.
-func DefaultOverlayKeyMap() OverlayKeyMap {
-	return OverlayKeyMap{
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-		),
-	}
+// ActionType represents the type of action taken by a dialog.
+type ActionType int
+
+const (
+	// ActionNone indicates no action.
+	ActionNone ActionType = iota
+	// ActionClose indicates that the dialog should be closed.
+	ActionClose
+)
+
+// Action represents an action taken by a dialog.
+// It can be used to signal closing or other operations.
+type Action struct {
+	Type    ActionType
+	Payload any
 }
 
 // Dialog is a component that can be displayed on top of the UI.
 type Dialog interface {
-	common.Model[Dialog]
 	ID() string
+	Update(msg tea.Msg) (Action, tea.Cmd)
+	Layer() *lipgloss.Layer
 }
 
 // Overlay manages multiple dialogs as an overlay.
 type Overlay struct {
 	dialogs []Dialog
-	keyMap  OverlayKeyMap
 }
 
 // NewOverlay creates a new [Overlay] instance.
 func NewOverlay(dialogs ...Dialog) *Overlay {
 	return &Overlay{
 		dialogs: dialogs,
-		keyMap:  DefaultOverlayKeyMap(),
 	}
 }
 
+// IsFrontDialog checks if the dialog with the specified ID is at the front.
+func (d *Overlay) IsFrontDialog(dialogID string) bool {
+	if len(d.dialogs) == 0 {
+		return false
+	}
+	return d.dialogs[len(d.dialogs)-1].ID() == dialogID
+}
+
 // HasDialogs checks if there are any active dialogs.
 func (d *Overlay) HasDialogs() bool {
 	return len(d.dialogs) > 0
@@ -83,54 +103,31 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
 	dialog := d.dialogs[idx]
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
-		if key.Matches(msg, d.keyMap.Close) {
+		if key.Matches(msg, CloseKey) {
 			// Close the current dialog
 			d.removeDialog(idx)
 			return d, nil
 		}
 	}
 
-	updatedDialog, cmd := dialog.Update(msg)
-	if updatedDialog == nil {
-		// Dialog requested to be closed
+	action, cmd := dialog.Update(msg)
+	switch action.Type {
+	case ActionClose:
+		// Close the current dialog
 		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
-	dialogs := make([]*lipgloss.Layer, len(d.dialogs))
+// Layers returns the current stack of dialogs as lipgloss layers.
+func (d *Overlay) Layers() []*lipgloss.Layer {
+	layers := make([]*lipgloss.Layer, len(d.dialogs))
 	for i, dialog := range d.dialogs {
-		dialogs[i] = lipgloss.NewLayer(dialog.View())
-	}
-
-	comp := lipgloss.NewCompositor(dialogs...)
-	return comp.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},
+		layers[i] = dialog.Layer()
 	}
+	return layers
 }
 
 // removeDialog removes a dialog from the stack.

internal/ui/dialog/quit.go 🔗

@@ -73,30 +73,30 @@ func (*Quit) ID() string {
 }
 
 // Update implements [Model].
-func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) {
+func (q *Quit) Update(msg tea.Msg) (Action, 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
+			return Action{}, nil
 		case key.Matches(msg, q.keyMap.EnterSpace):
 			if !q.selectedNo {
-				return q, tea.Quit
+				return Action{}, tea.Quit
 			}
-			return nil, nil
+			return Action{}, nil
 		case key.Matches(msg, q.keyMap.Yes):
-			return q, tea.Quit
+			return Action{}, tea.Quit
 		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
-			return nil, nil
+			return Action{}, nil
 		}
 	}
 
-	return q, nil
+	return Action{}, nil
 }
 
-// View implements [Model].
-func (q *Quit) View() string {
+// Layer implements [Model].
+func (q *Quit) Layer() *lipgloss.Layer {
 	const question = "Are you sure you want to quit?"
 	baseStyle := q.com.Styles.Base
 	buttonOpts := []common.ButtonOpts{
@@ -113,7 +113,7 @@ func (q *Quit) View() string {
 		),
 	)
 
-	return q.com.Styles.BorderFocus.Render(content)
+	return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content))
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/model/ui.go 🔗

@@ -335,28 +335,48 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
-	if m.dialog.HasDialogs() {
-		return m.updateDialogs(msg)
+	handleQuitKeys := func(msg tea.KeyPressMsg) bool {
+		switch {
+		case key.Matches(msg, m.keyMap.Quit):
+			if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
+				m.dialog.AddDialog(dialog.NewQuit(m.com))
+				return true
+			}
+		}
+		return false
 	}
 
-	handleGlobalKeys := func(msg tea.KeyPressMsg) {
+	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
+		if handleQuitKeys(msg) {
+			return true
+		}
 		switch {
 		case key.Matches(msg, m.keyMap.Tab):
 		case key.Matches(msg, m.keyMap.Help):
 			m.help.ShowAll = !m.help.ShowAll
 			m.updateLayoutAndSize()
-		case key.Matches(msg, m.keyMap.Quit):
-			if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
-				m.dialog.AddDialog(dialog.NewQuit(m.com))
-				return
-			}
+			return true
 		case key.Matches(msg, m.keyMap.Commands):
 			// TODO: Implement me
 		case key.Matches(msg, m.keyMap.Models):
 			// TODO: Implement me
 		case key.Matches(msg, m.keyMap.Sessions):
 			// TODO: Implement me
+			return true
 		}
+		return false
+	}
+
+	if m.dialog.HasDialogs() {
+		// Always handle quit keys first
+		if handleQuitKeys(msg) {
+			return cmds
+		}
+
+		updatedDialog, cmd := m.dialog.Update(msg)
+		m.dialog = updatedDialog
+		cmds = append(cmds, cmd)
+		return cmds
 	}
 
 	switch m.state {
@@ -501,12 +521,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 
 	// This needs to come last to overlay on top of everything
 	if m.dialog.HasDialogs() {
-		if dialogView := m.dialog.View(); dialogView != "" {
-			dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
-			dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
-			dialog := uv.NewStyledString(dialogView)
-			dialog.Draw(scr, dialogArea)
+		dialogLayers := m.dialog.Layers()
+		layers := make([]*lipgloss.Layer, 0)
+		for _, layer := range dialogLayers {
+			if layer == nil {
+				continue
+			}
+			layerW, layerH := layer.Width(), layer.Height()
+			layerArea := common.CenterRect(area, layerW, layerH)
+			layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y))
 		}
+
+		comp := lipgloss.NewCompositor(layers...)
+		comp.Draw(scr, area)
 	}
 }
 
@@ -650,14 +677,6 @@ func (m *UI) FullHelp() [][]key.Binding {
 	return binds
 }
 
-// updateDialogs updates the dialog overlay with the given message and returns cmds
-func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
-	updatedDialog, cmd := m.dialog.Update(msg)
-	m.dialog = updatedDialog
-	cmds = append(cmds, cmd)
-	return cmds
-}
-
 // updateFocused updates the focused model (chat or editor) with the given message
 // and appends any resulting commands to the cmds slice.
 func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {