1package dialogs
2
3import (
4 "slices"
5
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/tui/util"
8 "github.com/charmbracelet/lipgloss/v2"
9)
10
11type DialogID string
12
13// DialogModel represents a dialog component that can be displayed.
14type DialogModel interface {
15 util.Model
16 Position() (int, int)
17 ID() DialogID
18}
19
20// CloseCallback allows dialogs to perform cleanup when closed.
21type CloseCallback interface {
22 Close() tea.Cmd
23}
24
25// OpenDialogMsg is sent to open a new dialog with specified dimensions.
26type OpenDialogMsg struct {
27 Model DialogModel
28}
29
30// CloseDialogMsg is sent to close the topmost dialog.
31type CloseDialogMsg struct{}
32
33// DialogCmp manages a stack of dialogs with keyboard navigation.
34type DialogCmp interface {
35 tea.Model
36
37 Dialogs() []DialogModel
38 HasDialogs() bool
39 GetLayers() []*lipgloss.Layer
40 ActiveView() *tea.View
41 ActiveDialogId() DialogID
42}
43
44type dialogCmp struct {
45 width, height int
46 dialogs []DialogModel
47 idMap map[DialogID]int
48 keyMap KeyMap
49}
50
51// NewDialogCmp creates a new dialog manager.
52func NewDialogCmp() DialogCmp {
53 return dialogCmp{
54 dialogs: []DialogModel{},
55 keyMap: DefaultKeyMap(),
56 idMap: make(map[DialogID]int),
57 }
58}
59
60func (d dialogCmp) Init() tea.Cmd {
61 return nil
62}
63
64// Update handles dialog lifecycle and forwards messages to the active dialog.
65func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66 switch msg := msg.(type) {
67 case tea.WindowSizeMsg:
68 var cmds []tea.Cmd
69 d.width = msg.Width
70 d.height = msg.Height
71 for i := range d.dialogs {
72 u, cmd := d.dialogs[i].Update(msg)
73 d.dialogs[i] = u.(DialogModel)
74 cmds = append(cmds, cmd)
75 }
76 return d, tea.Batch(cmds...)
77 case OpenDialogMsg:
78 return d.handleOpen(msg)
79 case CloseDialogMsg:
80 if len(d.dialogs) == 0 {
81 return d, nil
82 }
83 inx := len(d.dialogs) - 1
84 dialog := d.dialogs[inx]
85 delete(d.idMap, dialog.ID())
86 d.dialogs = d.dialogs[:len(d.dialogs)-1]
87 if closeable, ok := dialog.(CloseCallback); ok {
88 return d, closeable.Close()
89 }
90 return d, nil
91 }
92 if d.HasDialogs() {
93 lastIndex := len(d.dialogs) - 1
94 u, cmd := d.dialogs[lastIndex].Update(msg)
95 d.dialogs[lastIndex] = u.(DialogModel)
96 return d, cmd
97 }
98 return d, nil
99}
100
101func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
102 if d.HasDialogs() {
103 dialog := d.dialogs[len(d.dialogs)-1]
104 if dialog.ID() == msg.Model.ID() {
105 return d, nil // Do not open a dialog if it's already the topmost one
106 }
107 if dialog.ID() == "quit" {
108 return d, nil // Do not open dialogs on top of quit
109 }
110 }
111 // if the dialog is already in the stack make it the last item
112 if _, ok := d.idMap[msg.Model.ID()]; ok {
113 existing := d.dialogs[d.idMap[msg.Model.ID()]]
114 // Reuse the model so we keep the state
115 msg.Model = existing
116 d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
117 }
118 d.idMap[msg.Model.ID()] = len(d.dialogs)
119 d.dialogs = append(d.dialogs, msg.Model)
120 var cmds []tea.Cmd
121 cmd := msg.Model.Init()
122 cmds = append(cmds, cmd)
123 _, cmd = msg.Model.Update(tea.WindowSizeMsg{
124 Width: d.width,
125 Height: d.height,
126 })
127 cmds = append(cmds, cmd)
128 return d, tea.Batch(cmds...)
129}
130
131func (d dialogCmp) Dialogs() []DialogModel {
132 return d.dialogs
133}
134
135func (d dialogCmp) ActiveView() *tea.View {
136 if len(d.dialogs) == 0 {
137 return nil
138 }
139 view := d.dialogs[len(d.dialogs)-1].View()
140 return &view
141}
142
143func (d dialogCmp) ActiveDialogId() DialogID {
144 if len(d.dialogs) == 0 {
145 return ""
146 }
147 return d.dialogs[len(d.dialogs)-1].ID()
148}
149
150func (d dialogCmp) GetLayers() []*lipgloss.Layer {
151 layers := []*lipgloss.Layer{}
152 for _, dialog := range d.Dialogs() {
153 dialogView := dialog.View().String()
154 row, col := dialog.Position()
155 layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
156 }
157 return layers
158}
159
160func (d dialogCmp) HasDialogs() bool {
161 return len(d.dialogs) > 0
162}