on main help sign in
refactor(ui): dialog: use Action pattern and lipgloss layers
18ee540Ayman Bagabas
internal/ui/dialog/dialog.go+41−44
@@ -4,7 +4,12 @@ import (
4 "charm.land/bubbles/v2/key"
4 "charm.land/bubbles/v2/key"
5 tea "charm.land/bubbletea/v2"
5 tea "charm.land/bubbletea/v2"
6 "charm.land/lipgloss/v2"
6 "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"),
8)
13)
9
14
10// OverlayKeyMap defines key bindings for dialogs.
15// OverlayKeyMap defines key bindings for dialogs.
@@ -12,35 +17,50 @@ type OverlayKeyMap struct {
12 Close key.Binding
17 Close key.Binding
13}
18}
14
19
15−// DefaultOverlayKeyMap returns the default key bindings for dialogs.
20+// ActionType represents the type of action taken by a dialog.
16−func DefaultOverlayKeyMap() OverlayKeyMap {
21+type ActionType int
17− return OverlayKeyMap{
22+
18− Close: key.NewBinding(
23+const (
19− key.WithKeys("esc", "alt+esc"),
24+ // ActionNone indicates no action.
20− ),
25+ ActionNone ActionType = iota
21− }
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
22}
35}
23
36
24// Dialog is a component that can be displayed on top of the UI.
37// Dialog is a component that can be displayed on top of the UI.
25type Dialog interface {
38type Dialog interface {
26− common.Model[Dialog]
27 ID() string
39 ID() string
40+ Update(msg tea.Msg) (Action, tea.Cmd)
41+ Layer() *lipgloss.Layer
28}
42}
29
43
30// Overlay manages multiple dialogs as an overlay.
44// Overlay manages multiple dialogs as an overlay.
31type Overlay struct {
45type Overlay struct {
32 dialogs []Dialog
46 dialogs []Dialog
33− keyMap OverlayKeyMap
34}
47}
35
48
36// NewOverlay creates a new [Overlay] instance.
49// NewOverlay creates a new [Overlay] instance.
37func NewOverlay(dialogs ...Dialog) *Overlay {
50func NewOverlay(dialogs ...Dialog) *Overlay {
38 return &Overlay{
51 return &Overlay{
39 dialogs: dialogs,
52 dialogs: dialogs,
40− keyMap: DefaultOverlayKeyMap(),
41 }
53 }
42}
54}
43
55
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+
44// HasDialogs checks if there are any active dialogs.
64// HasDialogs checks if there are any active dialogs.
45func (d *Overlay) HasDialogs() bool {
65func (d *Overlay) HasDialogs() bool {
46 return len(d.dialogs) > 0
66 return len(d.dialogs) > 0
@@ -83,54 +103,31 @@ func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
83 dialog := d.dialogs[idx]
103 dialog := d.dialogs[idx]
84 switch msg := msg.(type) {
104 switch msg := msg.(type) {
85 case tea.KeyPressMsg:
105 case tea.KeyPressMsg:
86− if key.Matches(msg, d.keyMap.Close) {
106+ if key.Matches(msg, CloseKey) {
87 // Close the current dialog
107 // Close the current dialog
88 d.removeDialog(idx)
108 d.removeDialog(idx)
89 return d, nil
109 return d, nil
90 }
110 }
91 }
111 }
92
112
93− updatedDialog, cmd := dialog.Update(msg)
113+ action, cmd := dialog.Update(msg)
94− if updatedDialog == nil {
114+ switch action.Type {
95− // Dialog requested to be closed
115+ case ActionClose:
116+ // Close the current dialog
96 d.removeDialog(idx)
117 d.removeDialog(idx)
97 return d, cmd
118 return d, cmd
98 }
119 }
99
120
100− // Update the dialog in the stack
101− d.dialogs[idx] = updatedDialog
102−
103 return d, cmd
121 return d, cmd
104}
122}
105
123
106−// View implements [Model].
124+// Layers returns the current stack of dialogs as lipgloss layers.
107−func (d *Overlay) View() string {
125+func (d *Overlay) Layers() []*lipgloss.Layer {
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))
126+ layers := make([]*lipgloss.Layer, len(d.dialogs))
114 for i, dialog := range d.dialogs {
127 for i, dialog := range d.dialogs {
115− dialogs[i] = lipgloss.NewLayer(dialog.View())
128+ layers[i] = dialog.Layer()
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},
133 }
129 }
130+ return layers
134}
131}
135
132
136// removeDialog removes a dialog from the stack.
133// removeDialog removes a dialog from the stack.
internal/ui/dialog/quit.go+10−10
@@ -73,30 +73,30 @@ func (*Quit) ID() string {
73}
73}
74
74
75// Update implements [Model].
75// 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) {
77 switch msg := msg.(type) {
77 switch msg := msg.(type) {
78 case tea.KeyPressMsg:
78 case tea.KeyPressMsg:
79 switch {
79 switch {
80 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
80 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
81 q.selectedNo = !q.selectedNo
81 q.selectedNo = !q.selectedNo
82− return q, nil
82+ return Action{}, nil
83 case key.Matches(msg, q.keyMap.EnterSpace):
83 case key.Matches(msg, q.keyMap.EnterSpace):
84 if !q.selectedNo {
84 if !q.selectedNo {
85− return q, tea.Quit
85+ return Action{}, tea.Quit
86 }
86 }
87− return nil, nil
87+ return Action{}, nil
88 case key.Matches(msg, q.keyMap.Yes):
88 case key.Matches(msg, q.keyMap.Yes):
89− return q, tea.Quit
89+ return Action{}, tea.Quit
90 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
90 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
91− return nil, nil
91+ return Action{}, nil
92 }
92 }
93 }
93 }
94
94
95− return q, nil
95+ return Action{}, nil
96}
96}
97
97
98−// View implements [Model].
98+// Layer implements [Model].
99−func (q *Quit) View() string {
99+func (q *Quit) Layer() *lipgloss.Layer {
100 const question = "Are you sure you want to quit?"
100 const question = "Are you sure you want to quit?"
101 baseStyle := q.com.Styles.Base
101 baseStyle := q.com.Styles.Base
102 buttonOpts := []common.ButtonOpts{
102 buttonOpts := []common.ButtonOpts{
@@ -113,7 +113,7 @@ func (q *Quit) View() string {
113 ),
113 ),
114 )
114 )
115
115
116− return q.com.Styles.BorderFocus.Render(content)
116+ return lipgloss.NewLayer(q.com.Styles.BorderFocus.Render(content))
117}
117}
118
118
119// ShortHelp implements [help.KeyMap].
119// ShortHelp implements [help.KeyMap].
internal/ui/model/ui.go+40−21
@@ -335,28 +335,48 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
335}
335}
336
336
337func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
337func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
338− if m.dialog.HasDialogs() {
338+ handleQuitKeys := func(msg tea.KeyPressMsg) bool {
339− return m.updateDialogs(msg)
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
340 }
347 }
341
348
342− handleGlobalKeys := func(msg tea.KeyPressMsg) {
349+ handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
350+ if handleQuitKeys(msg) {
351+ return true
352+ }
343 switch {
353 switch {
344 case key.Matches(msg, m.keyMap.Tab):
354 case key.Matches(msg, m.keyMap.Tab):
345 case key.Matches(msg, m.keyMap.Help):
355 case key.Matches(msg, m.keyMap.Help):
346 m.help.ShowAll = !m.help.ShowAll
356 m.help.ShowAll = !m.help.ShowAll
347 m.updateLayoutAndSize()
357 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
358+ return true
352− }
353 case key.Matches(msg, m.keyMap.Commands):
359 case key.Matches(msg, m.keyMap.Commands):
354 // TODO: Implement me
360 // TODO: Implement me
355 case key.Matches(msg, m.keyMap.Models):
361 case key.Matches(msg, m.keyMap.Models):
356 // TODO: Implement me
362 // TODO: Implement me
357 case key.Matches(msg, m.keyMap.Sessions):
363 case key.Matches(msg, m.keyMap.Sessions):
358 // TODO: Implement me
364 // TODO: Implement me
365+ return true
359 }
366 }
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
360 }
380 }
361
381
362 switch m.state {
382 switch m.state {
@@ -501,12 +521,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
501
521
502 // This needs to come last to overlay on top of everything
522 // This needs to come last to overlay on top of everything
503 if m.dialog.HasDialogs() {
523 if m.dialog.HasDialogs() {
504− if dialogView := m.dialog.View(); dialogView != "" {
524+ dialogLayers := m.dialog.Layers()
505− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
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()
506− dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
531+ layerArea := common.CenterRect(area, layerW, layerH)
507− dialog := uv.NewStyledString(dialogView)
532+ layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y))
508− dialog.Draw(scr, dialogArea)
509 }
533 }
534+
535+ comp := lipgloss.NewCompositor(layers...)
536+ comp.Draw(scr, area)
510 }
537 }
511}
538}
512
539
@@ -650,14 +677,6 @@ func (m *UI) FullHelp() [][]key.Binding {
650 return binds
677 return binds
651}
678}
652
679
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−
661// updateFocused updates the focused model (chat or editor) with the given message
680// updateFocused updates the focused model (chat or editor) with the given message
662// and appends any resulting commands to the cmds slice.
681// and appends any resulting commands to the cmds slice.
663func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
682func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {