1package dialog
2
3import (
4 "time"
5
6 "charm.land/bubbles/v2/key"
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/ui/common"
10 uv "github.com/charmbracelet/ultraviolet"
11)
12
13// Dialog sizing constants.
14const (
15 // defaultDialogMaxWidth is the maximum width for standard dialogs.
16 defaultDialogMaxWidth = 70
17 // defaultDialogHeight is the default height for standard dialogs.
18 defaultDialogHeight = 20
19 // titleContentHeight is the height of the title content line.
20 titleContentHeight = 1
21 // inputContentHeight is the height of the input content line.
22 inputContentHeight = 1
23)
24
25// CloseKey is the default key binding to close dialogs.
26var CloseKey = key.NewBinding(
27 key.WithKeys("esc", "alt+esc"),
28 key.WithHelp("esc", "exit"),
29)
30
31// Action represents an action taken in a dialog after handling a message.
32type Action any
33
34// Dialog is a component that can be displayed on top of the UI.
35type Dialog interface {
36 // ID returns the unique identifier of the dialog.
37 ID() string
38 // HandleMsg processes a message and returns an action. An [Action] can be
39 // anything and the caller is responsible for handling it appropriately.
40 HandleMsg(msg tea.Msg) Action
41 // Draw draws the dialog onto the provided screen within the specified area
42 // and returns the desired cursor position on the screen.
43 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
44}
45
46// LoadingDialog is a dialog that can show a loading state.
47type LoadingDialog interface {
48 StartLoading() tea.Cmd
49 StopLoading()
50}
51
52// Grace period constants for dialogs that open asynchronously and may
53// receive in-flight keystrokes from a previously focused component.
54const (
55 // graceQuietPeriod is how long input must be quiet before the dialog
56 // arms. Each absorbed keystroke resets this timer.
57 graceQuietPeriod = 200 * time.Millisecond
58 // graceMaxDelay is the absolute ceiling: the dialog always arms after
59 // this duration regardless of input activity. Prevents auto-repeat
60 // from keeping the dialog disarmed indefinitely.
61 graceMaxDelay = 1500 * time.Millisecond
62)
63
64// Overlay manages multiple dialogs as an overlay.
65type Overlay struct {
66 dialogs []Dialog
67
68 // Grace period state for the front dialog. Only active when the
69 // dialog was opened via OpenDialogWithGrace.
70 graceOpenedAt time.Time
71 graceLastInputAt time.Time
72}
73
74// NewOverlay creates a new [Overlay] instance.
75func NewOverlay(dialogs ...Dialog) *Overlay {
76 return &Overlay{
77 dialogs: dialogs,
78 }
79}
80
81// HasDialogs checks if there are any active dialogs.
82func (d *Overlay) HasDialogs() bool {
83 return len(d.dialogs) > 0
84}
85
86// ContainsDialog checks if a dialog with the specified ID exists.
87func (d *Overlay) ContainsDialog(dialogID string) bool {
88 for _, dialog := range d.dialogs {
89 if dialog.ID() == dialogID {
90 return true
91 }
92 }
93 return false
94}
95
96// OpenDialog opens a new dialog to the stack.
97func (d *Overlay) OpenDialog(dialog Dialog) {
98 d.dialogs = append(d.dialogs, dialog)
99 d.graceOpenedAt = time.Time{}
100 d.graceLastInputAt = time.Time{}
101}
102
103// OpenDialogWithGrace opens a dialog with an input grace period. All
104// keystrokes are absorbed until either the input has been quiet for
105// graceQuietPeriod or graceMaxDelay has elapsed since opening, whichever
106// comes first. Use this for dialogs that open asynchronously (e.g.
107// permission prompts) where in-flight keystrokes from a previously
108// focused component could act on the dialog before the user sees it.
109func (d *Overlay) OpenDialogWithGrace(dialog Dialog) {
110 now := time.Now()
111 d.dialogs = append(d.dialogs, dialog)
112 d.graceOpenedAt = now
113 d.graceLastInputAt = now
114}
115
116// inGracePeriod reports whether the front dialog is still within its
117// input grace period. Returns false if no grace period is active.
118func (d *Overlay) inGracePeriod() bool {
119 if d.graceOpenedAt.IsZero() {
120 return false
121 }
122 if time.Since(d.graceOpenedAt) >= graceMaxDelay {
123 return false
124 }
125 if time.Since(d.graceLastInputAt) >= graceQuietPeriod {
126 return false
127 }
128 return true
129}
130
131// CloseDialog closes the dialog with the specified ID from the stack.
132func (d *Overlay) CloseDialog(dialogID string) {
133 for i, dialog := range d.dialogs {
134 if dialog.ID() == dialogID {
135 d.removeDialog(i)
136 return
137 }
138 }
139}
140
141// CloseFrontDialog closes the front dialog in the stack.
142func (d *Overlay) CloseFrontDialog() {
143 if len(d.dialogs) == 0 {
144 return
145 }
146 d.removeDialog(len(d.dialogs) - 1)
147}
148
149func (d *Overlay) removeDialog(idx int) {
150 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
151 // Clear grace state when the front dialog changes.
152 if idx == len(d.dialogs) {
153 d.graceOpenedAt = time.Time{}
154 d.graceLastInputAt = time.Time{}
155 }
156}
157
158// Dialog returns the dialog with the specified ID, or nil if not found.
159func (d *Overlay) Dialog(dialogID string) Dialog {
160 for _, dialog := range d.dialogs {
161 if dialog.ID() == dialogID {
162 return dialog
163 }
164 }
165 return nil
166}
167
168// DialogLast returns the front dialog, or nil if there are no dialogs.
169func (d *Overlay) DialogLast() Dialog {
170 if len(d.dialogs) == 0 {
171 return nil
172 }
173 return d.dialogs[len(d.dialogs)-1]
174}
175
176// BringToFront brings the dialog with the specified ID to the front.
177func (d *Overlay) BringToFront(dialogID string) {
178 for i, dialog := range d.dialogs {
179 if dialog.ID() == dialogID {
180 // Move the dialog to the end of the slice
181 d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
182 d.dialogs = append(d.dialogs, dialog)
183 return
184 }
185 }
186}
187
188// Update handles dialog updates.
189func (d *Overlay) Update(msg tea.Msg) tea.Msg {
190 if len(d.dialogs) == 0 {
191 return nil
192 }
193
194 // Absorb keystrokes during the grace period for async dialogs.
195 if _, ok := msg.(tea.KeyPressMsg); ok && d.inGracePeriod() {
196 d.graceLastInputAt = time.Now()
197 return nil
198 }
199
200 idx := len(d.dialogs) - 1 // active dialog is the last one
201 dialog := d.dialogs[idx]
202 if dialog == nil {
203 return nil
204 }
205
206 return dialog.HandleMsg(msg)
207}
208
209// StartLoading starts the loading state for the front dialog if it
210// implements [LoadingDialog].
211func (d *Overlay) StartLoading() tea.Cmd {
212 dialog := d.DialogLast()
213 if ld, ok := dialog.(LoadingDialog); ok {
214 return ld.StartLoading()
215 }
216 return nil
217}
218
219// StopLoading stops the loading state for the front dialog if it
220// implements [LoadingDialog].
221func (d *Overlay) StopLoading() {
222 dialog := d.DialogLast()
223 if ld, ok := dialog.(LoadingDialog); ok {
224 ld.StopLoading()
225 }
226}
227
228// DrawCenterCursor draws the given string view centered in the screen area and
229// adjusts the cursor position accordingly.
230func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
231 width, height := lipgloss.Size(view)
232 center := common.CenterRect(area, width, height)
233 if cur != nil {
234 cur.X += center.Min.X
235 cur.Y += center.Min.Y
236 }
237 uv.NewStyledString(view).Draw(scr, center)
238}
239
240// DrawCenter draws the given string view centered in the screen area.
241func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
242 DrawCenterCursor(scr, area, view, nil)
243}
244
245// DrawOnboarding draws the given string view centered in the screen area.
246func DrawOnboarding(scr uv.Screen, area uv.Rectangle, view string) {
247 DrawOnboardingCursor(scr, area, view, nil)
248}
249
250// DrawOnboardingCursor draws the given string view positioned at the bottom
251// left area of the screen.
252func DrawOnboardingCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
253 width, height := lipgloss.Size(view)
254 bottomLeft := common.BottomLeftRect(area, width, height)
255 if cur != nil {
256 cur.X += bottomLeft.Min.X
257 cur.Y += bottomLeft.Min.Y
258 }
259 uv.NewStyledString(view).Draw(scr, bottomLeft)
260}
261
262// Draw renders the overlay and its dialogs.
263func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
264 var cur *tea.Cursor
265 for _, dialog := range d.dialogs {
266 cur = dialog.Draw(scr, area)
267 }
268 return cur
269}