refactor(ui): simplify dialog model and rendering

Ayman Bagabas created

Change summary

internal/ui/common/markdown.go |  4 +-
internal/ui/dialog/dialog.go   | 55 ++++++++++-------------------------
internal/ui/dialog/quit.go     | 20 ++++++------
internal/ui/dialog/sessions.go | 14 ++++----
internal/ui/model/ui.go        | 14 --------
internal/ui/styles/styles.go   |  2 
6 files changed, 37 insertions(+), 72 deletions(-)

Detailed changes

internal/ui/common/markdown.go 🔗

@@ -1,9 +1,9 @@
 package common
 
 import (
+	"charm.land/glamour/v2"
+	gstyles "charm.land/glamour/v2/styles"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/glamour/v2"
-	gstyles "github.com/charmbracelet/glamour/v2/styles"
 )
 
 // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with

internal/ui/dialog/dialog.go 🔗

@@ -4,6 +4,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // CloseKey is the default key binding to close dialogs.
@@ -12,35 +14,11 @@ var CloseKey = key.NewBinding(
 	key.WithHelp("esc", "exit"),
 )
 
-// OverlayKeyMap defines key bindings for dialogs.
-type OverlayKeyMap struct {
-	Close key.Binding
-}
-
-// 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
-	// ActionSelect indicates that an item has been selected.
-	ActionSelect
-)
-
-// 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 {
 	ID() string
-	Update(msg tea.Msg) (Action, tea.Cmd)
-	Layer() *lipgloss.Layer
+	Update(msg tea.Msg) tea.Cmd
+	View() string
 }
 
 // Overlay manages multiple dialogs as an overlay.
@@ -122,27 +100,26 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
 		}
 	}
 
-	action, cmd := dialog.Update(msg)
-	switch action.Type {
-	case ActionClose:
+	if cmd := dialog.Update(msg); cmd != nil {
 		// Close the current dialog
 		d.removeDialog(idx)
 		return d, cmd
-	case ActionSelect:
-		// Pass the action up (without modifying the dialog stack)
-		return d, cmd
 	}
 
-	return d, cmd
+	return d, nil
 }
 
-// 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 {
-		layers[i] = dialog.Layer()
+// Draw renders the overlay and its dialogs.
+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
+	for _, dialog := range d.dialogs {
+		view := dialog.View()
+		viewWidth := lipgloss.Width(view)
+		viewHeight := lipgloss.Height(view)
+		center := common.CenterRect(area, viewWidth, viewHeight)
+		if area.Overlaps(center) {
+			uv.NewStyledString(view).Draw(scr, center)
+		}
 	}
-	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) (Action, tea.Cmd) {
+func (q *Quit) Update(msg tea.Msg) 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 Action{}, nil
+			return nil
 		case key.Matches(msg, q.keyMap.EnterSpace):
 			if !q.selectedNo {
-				return Action{}, tea.Quit
+				return tea.Quit
 			}
-			return Action{}, nil
+			return nil
 		case key.Matches(msg, q.keyMap.Yes):
-			return Action{}, tea.Quit
+			return tea.Quit
 		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
-			return Action{}, nil
+			return nil
 		}
 	}
 
-	return Action{}, nil
+	return nil
 }
 
-// Layer implements [Model].
-func (q *Quit) Layer() *lipgloss.Layer {
+// View implements [Dialog].
+func (q *Quit) View() string {
 	const question = "Are you sure you want to quit?"
 	baseStyle := q.com.Styles.Base
 	buttonOpts := []common.ButtonOpts{
@@ -113,7 +113,7 @@ func (q *Quit) Layer() *lipgloss.Layer {
 		),
 	)
 
-	return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content))
+	return q.com.Styles.BorderFocus.Render(content)
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/dialog/sessions.go 🔗

@@ -100,7 +100,7 @@ func (s *Session) ID() string {
 }
 
 // Update implements Dialog.
-func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) {
+func (s *Session) Update(msg tea.Msg) tea.Cmd {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -115,20 +115,20 @@ func (s *Session) Update(msg tea.Msg) (Action, tea.Cmd) {
 		case key.Matches(msg, s.keyMap.Select):
 			if item := s.list.SelectedItem(); item != nil {
 				sessionItem := item.(*SessionItem)
-				return Action{Type: ActionSelect, Payload: sessionItem.Session}, SessionSelectCmd(sessionItem.Session)
+				return SessionSelectCmd(sessionItem.Session)
 			}
 		default:
 			var cmd tea.Cmd
 			s.input, cmd = s.input.Update(msg)
 			s.list.SetFilter(s.input.Value())
-			return Action{}, cmd
+			return cmd
 		}
 	}
-	return Action{}, nil
+	return nil
 }
 
-// Layer implements Dialog.
-func (s *Session) Layer() *lipgloss.Layer {
+// View implements [Dialog].
+func (s *Session) View() string {
 	titleStyle := s.com.Styles.Dialog.Title
 	helpStyle := s.com.Styles.Dialog.HelpView
 	dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
@@ -156,7 +156,7 @@ func (s *Session) Layer() *lipgloss.Layer {
 		helpStyle.Render(s.help.View(s)),
 	}, "\n")
 
-	return lipgloss.NewLayer(dialogStyle.Render(content))
+	return dialogStyle.Render(content)
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/model/ui.go 🔗

@@ -535,19 +535,7 @@ 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() {
-		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)
+		m.dialog.Draw(scr, area)
 	}
 }
 

internal/ui/styles/styles.go 🔗

@@ -8,10 +8,10 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/glamour/v2/ansi"
 	"charm.land/lipgloss/v2"
 	"github.com/alecthomas/chroma/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
-	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
 )