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// CloseKey is the default key binding to close dialogs.
12var CloseKey = key.NewBinding(
13 key.WithKeys("esc", "alt+esc"),
14 key.WithHelp("esc", "exit"),
15)
16
17// Dialog is a component that can be displayed on top of the UI.
18type Dialog interface {
19 ID() string
20 Update(msg tea.Msg) tea.Msg
21 View() string
22}
23
24// Overlay manages multiple dialogs as an overlay.
25type Overlay struct {
26 dialogs []Dialog
27}
28
29// NewOverlay creates a new [Overlay] instance.
30func NewOverlay(dialogs ...Dialog) *Overlay {
31 return &Overlay{
32 dialogs: dialogs,
33 }
34}
35
36// HasDialogs checks if there are any active dialogs.
37func (d *Overlay) HasDialogs() bool {
38 return len(d.dialogs) > 0
39}
40
41// ContainsDialog checks if a dialog with the specified ID exists.
42func (d *Overlay) ContainsDialog(dialogID string) bool {
43 for _, dialog := range d.dialogs {
44 if dialog.ID() == dialogID {
45 return true
46 }
47 }
48 return false
49}
50
51// OpenDialog opens a new dialog to the stack.
52func (d *Overlay) OpenDialog(dialog Dialog) {
53 d.dialogs = append(d.dialogs, dialog)
54}
55
56// CloseDialog closes the dialog with the specified ID from the stack.
57func (d *Overlay) CloseDialog(dialogID string) {
58 for i, dialog := range d.dialogs {
59 if dialog.ID() == dialogID {
60 d.removeDialog(i)
61 return
62 }
63 }
64}
65
66// CloseFrontDialog closes the front dialog in the stack.
67func (d *Overlay) CloseFrontDialog() {
68 if len(d.dialogs) == 0 {
69 return
70 }
71 d.removeDialog(len(d.dialogs) - 1)
72}
73
74// Dialog returns the dialog with the specified ID, or nil if not found.
75func (d *Overlay) Dialog(dialogID string) Dialog {
76 for _, dialog := range d.dialogs {
77 if dialog.ID() == dialogID {
78 return dialog
79 }
80 }
81 return nil
82}
83
84// DialogLast returns the front dialog, or nil if there are no dialogs.
85func (d *Overlay) DialogLast() Dialog {
86 if len(d.dialogs) == 0 {
87 return nil
88 }
89 return d.dialogs[len(d.dialogs)-1]
90}
91
92// BringToFront brings the dialog with the specified ID to the front.
93func (d *Overlay) BringToFront(dialogID string) {
94 for i, dialog := range d.dialogs {
95 if dialog.ID() == dialogID {
96 // Move the dialog to the end of the slice
97 d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
98 d.dialogs = append(d.dialogs, dialog)
99 return
100 }
101 }
102}
103
104// Update handles dialog updates.
105func (d *Overlay) Update(msg tea.Msg) tea.Msg {
106 if len(d.dialogs) == 0 {
107 return nil
108 }
109
110 idx := len(d.dialogs) - 1 // active dialog is the last one
111 dialog := d.dialogs[idx]
112 if dialog == nil {
113 return nil
114 }
115
116 return dialog.Update(msg)
117}
118
119// CenterPosition calculates the centered position for the dialog.
120func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
121 dialog := d.Dialog(dialogID)
122 if dialog == nil {
123 return uv.Rectangle{}
124 }
125 return d.centerPositionView(area, dialog.View())
126}
127
128func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
129 viewWidth := lipgloss.Width(view)
130 viewHeight := lipgloss.Height(view)
131 return common.CenterRect(area, viewWidth, viewHeight)
132}
133
134// Draw renders the overlay and its dialogs.
135func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
136 for _, dialog := range d.dialogs {
137 view := dialog.View()
138 center := d.centerPositionView(area, view)
139 if area.Overlaps(center) {
140 uv.NewStyledString(view).Draw(scr, center)
141 }
142 }
143}
144
145// removeDialog removes a dialog from the stack.
146func (d *Overlay) removeDialog(idx int) {
147 if idx < 0 || idx >= len(d.dialogs) {
148 return
149 }
150 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
151}