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// OpenDialogMsg is sent to open a new dialog with specified dimensions.
27type OpenDialogMsg struct {
28 Model DialogModel
29}
30
31// CloseDialogMsg is sent to close the topmost dialog.
32type CloseDialogMsg struct{}
33
34// DialogCmp manages a stack of dialogs with keyboard navigation.
35type DialogCmp interface {
36 tea.Model
37
38 Dialogs() []DialogModel
39 HasDialogs() bool
40 GetLayers() []*lipgloss.Layer
41 ActiveView() *tea.View
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 case tea.KeyPressMsg:
92 if key.Matches(msg, d.keyMap.Close) {
93 return d, util.CmdHandler(CloseDialogMsg{})
94 }
95 }
96 if d.HasDialogs() {
97 lastIndex := len(d.dialogs) - 1
98 u, cmd := d.dialogs[lastIndex].Update(msg)
99 d.dialogs[lastIndex] = u.(DialogModel)
100 return d, cmd
101 }
102 return d, nil
103}
104
105func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
106 if d.HasDialogs() {
107 dialog := d.dialogs[len(d.dialogs)-1]
108 if dialog.ID() == msg.Model.ID() {
109 return d, nil // Do not open a dialog if it's already the topmost one
110 }
111 if dialog.ID() == "quit" {
112 return d, nil // Do not open dialogs on top of quit
113 }
114 }
115 // if the dialog is already in the stack make it the last item
116 if _, ok := d.idMap[msg.Model.ID()]; ok {
117 existing := d.dialogs[d.idMap[msg.Model.ID()]]
118 // Reuse the model so we keep the state
119 msg.Model = existing
120 d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
121 }
122 d.idMap[msg.Model.ID()] = len(d.dialogs)
123 d.dialogs = append(d.dialogs, msg.Model)
124 var cmds []tea.Cmd
125 cmd := msg.Model.Init()
126 cmds = append(cmds, cmd)
127 _, cmd = msg.Model.Update(tea.WindowSizeMsg{
128 Width: d.width,
129 Height: d.height,
130 })
131 cmds = append(cmds, cmd)
132 return d, tea.Batch(cmds...)
133}
134
135func (d dialogCmp) Dialogs() []DialogModel {
136 return d.dialogs
137}
138
139func (d dialogCmp) ActiveView() *tea.View {
140 if len(d.dialogs) == 0 {
141 return nil
142 }
143 view := d.dialogs[len(d.dialogs)-1].View()
144 return &view
145}
146
147func (d dialogCmp) GetLayers() []*lipgloss.Layer {
148 layers := []*lipgloss.Layer{}
149 for _, dialog := range d.Dialogs() {
150 dialogView := dialog.View().String()
151 row, col := dialog.Position()
152 layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
153 }
154 return layers
155}
156
157func (d dialogCmp) HasDialogs() bool {
158 return len(d.dialogs) > 0
159}