dialog.go

  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}