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 interface{}
 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// Overlay manages multiple dialogs as an overlay.
 45type Overlay struct {
 46	dialogs []Dialog
 47}
 48
 49// NewOverlay creates a new [Overlay] instance.
 50func NewOverlay(dialogs ...Dialog) *Overlay {
 51	return &Overlay{
 52		dialogs: dialogs,
 53	}
 54}
 55
 56// HasDialogs checks if there are any active dialogs.
 57func (d *Overlay) HasDialogs() bool {
 58	return len(d.dialogs) > 0
 59}
 60
 61// ContainsDialog checks if a dialog with the specified ID exists.
 62func (d *Overlay) ContainsDialog(dialogID string) bool {
 63	for _, dialog := range d.dialogs {
 64		if dialog.ID() == dialogID {
 65			return true
 66		}
 67	}
 68	return false
 69}
 70
 71// OpenDialog opens a new dialog to the stack.
 72func (d *Overlay) OpenDialog(dialog Dialog) {
 73	d.dialogs = append(d.dialogs, dialog)
 74}
 75
 76// CloseDialog closes the dialog with the specified ID from the stack.
 77func (d *Overlay) CloseDialog(dialogID string) {
 78	for i, dialog := range d.dialogs {
 79		if dialog.ID() == dialogID {
 80			d.removeDialog(i)
 81			return
 82		}
 83	}
 84}
 85
 86// CloseFrontDialog closes the front dialog in the stack.
 87func (d *Overlay) CloseFrontDialog() {
 88	if len(d.dialogs) == 0 {
 89		return
 90	}
 91	d.removeDialog(len(d.dialogs) - 1)
 92}
 93
 94// Dialog returns the dialog with the specified ID, or nil if not found.
 95func (d *Overlay) Dialog(dialogID string) Dialog {
 96	for _, dialog := range d.dialogs {
 97		if dialog.ID() == dialogID {
 98			return dialog
 99		}
100	}
101	return nil
102}
103
104// DialogLast returns the front dialog, or nil if there are no dialogs.
105func (d *Overlay) DialogLast() Dialog {
106	if len(d.dialogs) == 0 {
107		return nil
108	}
109	return d.dialogs[len(d.dialogs)-1]
110}
111
112// BringToFront brings the dialog with the specified ID to the front.
113func (d *Overlay) BringToFront(dialogID string) {
114	for i, dialog := range d.dialogs {
115		if dialog.ID() == dialogID {
116			// Move the dialog to the end of the slice
117			d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
118			d.dialogs = append(d.dialogs, dialog)
119			return
120		}
121	}
122}
123
124// Update handles dialog updates.
125func (d *Overlay) Update(msg tea.Msg) tea.Msg {
126	if len(d.dialogs) == 0 {
127		return nil
128	}
129
130	idx := len(d.dialogs) - 1 // active dialog is the last one
131	dialog := d.dialogs[idx]
132	if dialog == nil {
133		return nil
134	}
135
136	return dialog.HandleMsg(msg)
137}
138
139// DrawCenterCursor draws the given string view centered in the screen area and
140// adjusts the cursor position accordingly.
141func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
142	width, height := lipgloss.Size(view)
143	center := common.CenterRect(area, width, height)
144	if cur != nil {
145		cur.X += center.Min.X
146		cur.Y += center.Min.Y
147	}
148
149	uv.NewStyledString(view).Draw(scr, center)
150}
151
152// DrawCenter draws the given string view centered in the screen area.
153func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
154	DrawCenterCursor(scr, area, view, nil)
155}
156
157// Draw renders the overlay and its dialogs.
158func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
159	var cur *tea.Cursor
160	for _, dialog := range d.dialogs {
161		cur = dialog.Draw(scr, area)
162	}
163	return cur
164}
165
166// removeDialog removes a dialog from the stack.
167func (d *Overlay) removeDialog(idx int) {
168	if idx < 0 || idx >= len(d.dialogs) {
169		return
170	}
171	d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
172}