1package dialog
2
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/ui/component"
7 "github.com/charmbracelet/lipgloss/v2"
8)
9
10// OverlayKeyMap defines key bindings for dialogs.
11type OverlayKeyMap struct {
12 Close key.Binding
13}
14
15// DefaultOverlayKeyMap returns the default key bindings for dialogs.
16func DefaultOverlayKeyMap() OverlayKeyMap {
17 return OverlayKeyMap{
18 Close: key.NewBinding(
19 key.WithKeys("esc", "alt+esc"),
20 ),
21 }
22}
23
24// Dialog is a component that can be displayed on top of the UI.
25type Dialog interface {
26 component.Model[Dialog]
27 ID() string
28}
29
30// Overlay manages multiple dialogs as an overlay.
31type Overlay struct {
32 dialogs []Dialog
33 keyMap OverlayKeyMap
34}
35
36// NewOverlay creates a new [Overlay] instance.
37func NewOverlay(dialogs ...Dialog) *Overlay {
38 return &Overlay{
39 dialogs: dialogs,
40 keyMap: DefaultOverlayKeyMap(),
41 }
42}
43
44// ContainsDialog checks if a dialog with the specified ID exists.
45func (d *Overlay) ContainsDialog(dialogID string) bool {
46 for _, dialog := range d.dialogs {
47 if dialog.ID() == dialogID {
48 return true
49 }
50 }
51 return false
52}
53
54// AddDialog adds a new dialog to the stack.
55func (d *Overlay) AddDialog(dialog Dialog) {
56 d.dialogs = append(d.dialogs, dialog)
57}
58
59// BringToFront brings the dialog with the specified ID to the front.
60func (d *Overlay) BringToFront(dialogID string) {
61 for i, dialog := range d.dialogs {
62 if dialog.ID() == dialogID {
63 // Move the dialog to the end of the slice
64 d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
65 d.dialogs = append(d.dialogs, dialog)
66 return
67 }
68 }
69}
70
71// Update handles dialog updates.
72func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
73 if len(d.dialogs) == 0 {
74 return d, nil
75 }
76
77 idx := len(d.dialogs) - 1 // active dialog is the last one
78 dialog := d.dialogs[idx]
79 switch msg := msg.(type) {
80 case tea.KeyPressMsg:
81 if key.Matches(msg, d.keyMap.Close) {
82 // Close the current dialog
83 d.removeDialog(idx)
84 return d, nil
85 }
86 }
87
88 updatedDialog, cmd := dialog.Update(msg)
89 if updatedDialog == nil {
90 // Dialog requested to be closed
91 d.removeDialog(idx)
92 return d, cmd
93 }
94
95 // Update the dialog in the stack
96 d.dialogs[idx] = updatedDialog
97
98 return d, cmd
99}
100
101// View implements [Model].
102func (d *Overlay) View() string {
103 if len(d.dialogs) == 0 {
104 return ""
105 }
106
107 // Compose all the dialogs into a single view
108 canvas := lipgloss.NewCanvas()
109 for _, dialog := range d.dialogs {
110 layer := lipgloss.NewLayer(dialog.View())
111 canvas.AddLayers(layer)
112 }
113
114 return canvas.Render()
115}
116
117// ShortHelp implements [help.KeyMap].
118func (d *Overlay) ShortHelp() []key.Binding {
119 return []key.Binding{
120 d.keyMap.Close,
121 }
122}
123
124// FullHelp implements [help.KeyMap].
125func (d *Overlay) FullHelp() [][]key.Binding {
126 return [][]key.Binding{
127 {d.keyMap.Close},
128 }
129}
130
131// removeDialog removes a dialog from the stack.
132func (d *Overlay) removeDialog(idx int) {
133 if idx < 0 || idx >= len(d.dialogs) {
134 return
135 }
136 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
137}