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