on main help sign in
refactor(ui): dialog: use Action pattern and lipgloss layers
commit18ee5408c46833f1d9328b1e51913e8d2988e97d authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 12 Dec 2025, 13:10 (1 year ago) parent6444f72
3 files changed+91−75
internal/ui/dialog/dialog.go+41−44 internal/ui/dialog/quit.go+10−10 internal/ui/model/ui.go+40−21
internal/ui/dialog/dialog.go+41−44
@@ -4,7 +4,12 @@ import (
44 "charm.land/bubbles/v2/key"
55 tea "charm.land/bubbletea/v2"
66 "charm.land/lipgloss/v2"
7− "github.com/charmbracelet/crush/internal/ui/common"
7+)
8+
9+// CloseKey is the default key binding to close dialogs.
10+var CloseKey = key.NewBinding(
11+ key.WithKeys("esc", "alt+esc"),
12+ key.WithHelp("esc", "exit"),
813)
914
1015// OverlayKeyMap defines key bindings for dialogs.
@@ -12,35 +17,50 @@ type OverlayKeyMap struct {
1217 Close key.Binding
1318}
1419
15−// DefaultOverlayKeyMap returns the default key bindings for dialogs.
16−func DefaultOverlayKeyMap() OverlayKeyMap {
17− return OverlayKeyMap{
18− Close: key.NewBinding(
19− key.WithKeys("esc", "alt+esc"),
20− ),
21− }
20+// ActionType represents the type of action taken by a dialog.
21+type ActionType int
22+
23+const (
24+ // ActionNone indicates no action.
25+ ActionNone ActionType = iota
26+ // ActionClose indicates that the dialog should be closed.
27+ ActionClose
28+)
29+
30+// Action represents an action taken by a dialog.
31+// It can be used to signal closing or other operations.
32+type Action struct {
33+ Type ActionType
34+ Payload any
2235}
2336
2437// Dialog is a component that can be displayed on top of the UI.
2538type Dialog interface {
26− common.Model[Dialog]
2739 ID() string
40+ Update(msg tea.Msg) (Action, tea.Cmd)
41+ Layer() *lipgloss.Layer
2842}
2943
3044// Overlay manages multiple dialogs as an overlay.
3145type Overlay struct {
3246 dialogs []Dialog
33− keyMap OverlayKeyMap
3447}
3548
3649// NewOverlay creates a new [Overlay] instance.
3750func NewOverlay(dialogs ...Dialog) *Overlay {
3851 return &Overlay{
3952 dialogs: dialogs,
40− keyMap: DefaultOverlayKeyMap(),
4153 }
4254}
4355
56+// IsFrontDialog checks if the dialog with the specified ID is at the front.
57+func (d *Overlay) IsFrontDialog(dialogID string) bool {
58+ if len(d.dialogs) == 0 {
59+ return false
60+ }
61+ return d.dialogs[len(d.dialogs)-1].ID() == dialogID
62+}
63+
4464// HasDialogs checks if there are any active dialogs.
4565func (d *Overlay) HasDialogs() bool {
4666 return len(d.dialogs) > 0
@@ -83,54 +103,31 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
83103 dialog := d.dialogs[idx]
84104 switch msg := msg.(type) {
85105 case tea.KeyPressMsg:
86− if key.Matches(msg, d.keyMap.Close) {
106+ if key.Matches(msg, CloseKey) {
87107 // Close the current dialog
88108 d.removeDialog(idx)
89109 return d, nil
90110 }
91111 }
92112
93− updatedDialog, cmd := dialog.Update(msg)
94− if updatedDialog == nil {
95− // Dialog requested to be closed
113+ action, cmd := dialog.Update(msg)
114+ switch action.Type {
115+ case ActionClose:
116+ // Close the current dialog
96117 d.removeDialog(idx)
97118 return d, cmd
98119 }
99120
100− // Update the dialog in the stack
101− d.dialogs[idx] = updatedDialog
102−
103121 return d, cmd
104122}
105123
106−// View implements [Model].
107−func (d *Overlay) View() string {
108− if len(d.dialogs) == 0 {
109− return ""
110− }
111−
112− // Compose all the dialogs into a single view
113− dialogs := make([]*lipgloss.Layer, len(d.dialogs))
124+// Layers returns the current stack of dialogs as lipgloss layers.
125+func (d *Overlay) Layers() []*lipgloss.Layer {
126+ layers := make([]*lipgloss.Layer, len(d.dialogs))
114127 for i, dialog := range d.dialogs {
115− dialogs[i] = lipgloss.NewLayer(dialog.View())
116− }
117−
118− comp := lipgloss.NewCompositor(dialogs...)
119− return comp.Render()
120−}
121−
122−// ShortHelp implements [help.KeyMap].
123−func (d *Overlay) ShortHelp() []key.Binding {
124− return []key.Binding{
125− d.keyMap.Close,
126− }
127−}
128−
129−// FullHelp implements [help.KeyMap].
130−func (d *Overlay) FullHelp() [][]key.Binding {
131− return [][]key.Binding{
132− {d.keyMap.Close},
128+ layers[i] = dialog.Layer()
133129 }
130+ return layers
134131}
135132
136133// removeDialog removes a dialog from the stack.
internal/ui/dialog/quit.go+10−10
@@ -73,30 +73,30 @@ func (*Quit) ID() string {
7373}
7474
7575// Update implements [Model].
76−func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) {
76+func (q *Quit) Update(msg tea.Msg) (Action, tea.Cmd) {
7777 switch msg := msg.(type) {
7878 case tea.KeyPressMsg:
7979 switch {
8080 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
8181 q.selectedNo = !q.selectedNo
82− return q, nil
82+ return Action{}, nil
8383 case key.Matches(msg, q.keyMap.EnterSpace):
8484 if !q.selectedNo {
85− return q, tea.Quit
85+ return Action{}, tea.Quit
8686 }
87− return nil, nil
87+ return Action{}, nil
8888 case key.Matches(msg, q.keyMap.Yes):
89− return q, tea.Quit
89+ return Action{}, tea.Quit
9090 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
91− return nil, nil
91+ return Action{}, nil
9292 }
9393 }
9494
95− return q, nil
95+ return Action{}, nil
9696}
9797
98−// View implements [Model].
99−func (q *Quit) View() string {
98+// Layer implements [Model].
99+func (q *Quit) Layer() *lipgloss.Layer {
100100 const question = "Are you sure you want to quit?"
101101 baseStyle := q.com.Styles.Base
102102 buttonOpts := []common.ButtonOpts{
@@ -113,7 +113,7 @@ func (q *Quit) View() string {
113113 ),
114114 )
115115
116− return q.com.Styles.BorderFocus.Render(content)
116+ return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content))
117117}
118118
119119// ShortHelp implements [help.KeyMap].
internal/ui/model/ui.go+40−21
@@ -335,28 +335,48 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
335335}
336336
337337func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
338− if m.dialog.HasDialogs() {
339− return m.updateDialogs(msg)
338+ handleQuitKeys := func(msg tea.KeyPressMsg) bool {
339+ switch {
340+ case key.Matches(msg, m.keyMap.Quit):
341+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
342+ m.dialog.AddDialog(dialog.NewQuit(m.com))
343+ return true
344+ }
345+ }
346+ return false
340347 }
341348
342− handleGlobalKeys := func(msg tea.KeyPressMsg) {
349+ handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
350+ if handleQuitKeys(msg) {
351+ return true
352+ }
343353 switch {
344354 case key.Matches(msg, m.keyMap.Tab):
345355 case key.Matches(msg, m.keyMap.Help):
346356 m.help.ShowAll = !m.help.ShowAll
347357 m.updateLayoutAndSize()
348− case key.Matches(msg, m.keyMap.Quit):
349− if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
350− m.dialog.AddDialog(dialog.NewQuit(m.com))
351− return
352− }
358+ return true
353359 case key.Matches(msg, m.keyMap.Commands):
354360 // TODO: Implement me
355361 case key.Matches(msg, m.keyMap.Models):
356362 // TODO: Implement me
357363 case key.Matches(msg, m.keyMap.Sessions):
358364 // TODO: Implement me
365+ return true
359366 }
367+ return false
368+ }
369+
370+ if m.dialog.HasDialogs() {
371+ // Always handle quit keys first
372+ if handleQuitKeys(msg) {
373+ return cmds
374+ }
375+
376+ updatedDialog, cmd := m.dialog.Update(msg)
377+ m.dialog = updatedDialog
378+ cmds = append(cmds, cmd)
379+ return cmds
360380 }
361381
362382 switch m.state {
@@ -501,12 +521,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
501521
502522 // This needs to come last to overlay on top of everything
503523 if m.dialog.HasDialogs() {
504− if dialogView := m.dialog.View(); dialogView != "" {
505− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
506− dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
507− dialog := uv.NewStyledString(dialogView)
508− dialog.Draw(scr, dialogArea)
524+ dialogLayers := m.dialog.Layers()
525+ layers := make([]*lipgloss.Layer, 0)
526+ for _, layer := range dialogLayers {
527+ if layer == nil {
528+ continue
529+ }
530+ layerW, layerH := layer.Width(), layer.Height()
531+ layerArea := common.CenterRect(area, layerW, layerH)
532+ layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y))
509533 }
534+
535+ comp := lipgloss.NewCompositor(layers...)
536+ comp.Draw(scr, area)
510537 }
511538}
512539
@@ -650,14 +677,6 @@ func (m *UI) FullHelp() [][]key.Binding {
650677 return binds
651678}
652679
653−// updateDialogs updates the dialog overlay with the given message and returns cmds
654−func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
655− updatedDialog, cmd := m.dialog.Update(msg)
656− m.dialog = updatedDialog
657− cmds = append(cmds, cmd)
658− return cmds
659−}
660−
661680// updateFocused updates the focused model (chat or editor) with the given message
662681// and appends any resulting commands to the cmds slice.
663682func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {