dialog.go

  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}