1package dialog
2
3import (
4 "charm.land/bubbles/v2/key"
5 tea "charm.land/bubbletea/v2"
6 "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/crush/internal/ui/common"
8 uv "github.com/charmbracelet/ultraviolet"
9)
10
11// Dialog sizing constants.
12const (
13 // defaultDialogMaxWidth is the maximum width for standard dialogs.
14 defaultDialogMaxWidth = 120
15 // defaultDialogHeight is the default height for standard dialogs.
16 defaultDialogHeight = 30
17 // titleContentHeight is the height of the title content line.
18 titleContentHeight = 1
19 // inputContentHeight is the height of the input content line.
20 inputContentHeight = 1
21)
22
23// CloseKey is the default key binding to close dialogs.
24var CloseKey = key.NewBinding(
25 key.WithKeys("esc", "alt+esc"),
26 key.WithHelp("esc", "exit"),
27)
28
29// Action represents an action taken in a dialog after handling a message.
30type Action any
31
32// Dialog is a component that can be displayed on top of the UI.
33type Dialog interface {
34 // ID returns the unique identifier of the dialog.
35 ID() string
36 // HandleMsg processes a message and returns an action. An [Action] can be
37 // anything and the caller is responsible for handling it appropriately.
38 HandleMsg(msg tea.Msg) Action
39 // Draw draws the dialog onto the provided screen within the specified area
40 // and returns the desired cursor position on the screen.
41 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
42}
43
44// LoadingDialog is a dialog that can show a loading state.
45type LoadingDialog interface {
46 StartLoading() tea.Cmd
47 StopLoading()
48}
49
50// Overlay manages multiple dialogs as an overlay.
51type Overlay struct {
52 dialogs []Dialog
53}
54
55// NewOverlay creates a new [Overlay] instance.
56func NewOverlay(dialogs ...Dialog) *Overlay {
57 return &Overlay{
58 dialogs: dialogs,
59 }
60}
61
62// HasDialogs checks if there are any active dialogs.
63func (d *Overlay) HasDialogs() bool {
64 return len(d.dialogs) > 0
65}
66
67// ContainsDialog checks if a dialog with the specified ID exists.
68func (d *Overlay) ContainsDialog(dialogID string) bool {
69 for _, dialog := range d.dialogs {
70 if dialog.ID() == dialogID {
71 return true
72 }
73 }
74 return false
75}
76
77// OpenDialog opens a new dialog to the stack.
78func (d *Overlay) OpenDialog(dialog Dialog) {
79 d.dialogs = append(d.dialogs, dialog)
80}
81
82// CloseDialog closes the dialog with the specified ID from the stack.
83func (d *Overlay) CloseDialog(dialogID string) {
84 for i, dialog := range d.dialogs {
85 if dialog.ID() == dialogID {
86 d.removeDialog(i)
87 return
88 }
89 }
90}
91
92// CloseFrontDialog closes the front dialog in the stack.
93func (d *Overlay) CloseFrontDialog() {
94 if len(d.dialogs) == 0 {
95 return
96 }
97 d.removeDialog(len(d.dialogs) - 1)
98}
99
100// Dialog returns the dialog with the specified ID, or nil if not found.
101func (d *Overlay) Dialog(dialogID string) Dialog {
102 for _, dialog := range d.dialogs {
103 if dialog.ID() == dialogID {
104 return dialog
105 }
106 }
107 return nil
108}
109
110// DialogLast returns the front dialog, or nil if there are no dialogs.
111func (d *Overlay) DialogLast() Dialog {
112 if len(d.dialogs) == 0 {
113 return nil
114 }
115 return d.dialogs[len(d.dialogs)-1]
116}
117
118// BringToFront brings the dialog with the specified ID to the front.
119func (d *Overlay) BringToFront(dialogID string) {
120 for i, dialog := range d.dialogs {
121 if dialog.ID() == dialogID {
122 // Move the dialog to the end of the slice
123 d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
124 d.dialogs = append(d.dialogs, dialog)
125 return
126 }
127 }
128}
129
130// Update handles dialog updates.
131func (d *Overlay) Update(msg tea.Msg) tea.Msg {
132 if len(d.dialogs) == 0 {
133 return nil
134 }
135
136 idx := len(d.dialogs) - 1 // active dialog is the last one
137 dialog := d.dialogs[idx]
138 if dialog == nil {
139 return nil
140 }
141
142 return dialog.HandleMsg(msg)
143}
144
145// StartLoading starts the loading state for the front dialog if it
146// implements [LoadingDialog].
147func (d *Overlay) StartLoading() tea.Cmd {
148 dialog := d.DialogLast()
149 if ld, ok := dialog.(LoadingDialog); ok {
150 return ld.StartLoading()
151 }
152 return nil
153}
154
155// StopLoading stops the loading state for the front dialog if it
156// implements [LoadingDialog].
157func (d *Overlay) StopLoading() {
158 dialog := d.DialogLast()
159 if ld, ok := dialog.(LoadingDialog); ok {
160 ld.StopLoading()
161 }
162}
163
164// DrawCenterCursor draws the given string view centered in the screen area and
165// adjusts the cursor position accordingly.
166func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
167 width, height := lipgloss.Size(view)
168 center := common.CenterRect(area, width, height)
169 if cur != nil {
170 cur.X += center.Min.X
171 cur.Y += center.Min.Y
172 }
173
174 uv.NewStyledString(view).Draw(scr, center)
175}
176
177// DrawCenter draws the given string view centered in the screen area.
178func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
179 DrawCenterCursor(scr, area, view, nil)
180}
181
182// Draw renders the overlay and its dialogs.
183func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
184 var cur *tea.Cursor
185 for _, dialog := range d.dialogs {
186 cur = dialog.Draw(scr, area)
187 }
188 return cur
189}
190
191// removeDialog removes a dialog from the stack.
192func (d *Overlay) removeDialog(idx int) {
193 if idx < 0 || idx >= len(d.dialogs) {
194 return
195 }
196 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
197}