1package layout
2
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/opencode-ai/opencode/internal/tui/styles"
8 "github.com/opencode-ai/opencode/internal/tui/util"
9)
10
11type SplitPaneLayout interface {
12 util.Model
13 Sizeable
14 Bindings
15 SetLeftPanel(panel Container) tea.Cmd
16 SetRightPanel(panel Container) tea.Cmd
17 SetBottomPanel(panel Container) tea.Cmd
18
19 ClearLeftPanel() tea.Cmd
20 ClearRightPanel() tea.Cmd
21 ClearBottomPanel() tea.Cmd
22}
23
24type splitPaneLayout struct {
25 width int
26 height int
27
28 ratio float64
29 verticalRatio float64
30
31 rightPanel Container
32 leftPanel Container
33 bottomPanel Container
34
35 fixedBottomHeight int // Fixed height for the bottom panel, if any
36 fixedRightWidth int // Fixed width for the right panel, if any
37}
38
39type SplitPaneOption func(*splitPaneLayout)
40
41func (s *splitPaneLayout) Init() tea.Cmd {
42 var cmds []tea.Cmd
43
44 if s.leftPanel != nil {
45 cmds = append(cmds, s.leftPanel.Init())
46 }
47
48 if s.rightPanel != nil {
49 cmds = append(cmds, s.rightPanel.Init())
50 }
51
52 if s.bottomPanel != nil {
53 cmds = append(cmds, s.bottomPanel.Init())
54 }
55
56 return tea.Batch(cmds...)
57}
58
59func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60 var cmds []tea.Cmd
61 switch msg := msg.(type) {
62 case tea.WindowSizeMsg:
63 return s, s.SetSize(msg.Width, msg.Height)
64 }
65
66 if s.rightPanel != nil {
67 u, cmd := s.rightPanel.Update(msg)
68 s.rightPanel = u.(Container)
69 if cmd != nil {
70 cmds = append(cmds, cmd)
71 }
72 }
73
74 if s.leftPanel != nil {
75 u, cmd := s.leftPanel.Update(msg)
76 s.leftPanel = u.(Container)
77 if cmd != nil {
78 cmds = append(cmds, cmd)
79 }
80 }
81
82 if s.bottomPanel != nil {
83 u, cmd := s.bottomPanel.Update(msg)
84 s.bottomPanel = u.(Container)
85 if cmd != nil {
86 cmds = append(cmds, cmd)
87 }
88 }
89
90 return s, tea.Batch(cmds...)
91}
92
93func (s *splitPaneLayout) View() tea.View {
94 var topSection string
95
96 if s.leftPanel != nil && s.rightPanel != nil {
97 leftView := s.leftPanel.View()
98 rightView := s.rightPanel.View()
99 topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
100 } else if s.leftPanel != nil {
101 topSection = s.leftPanel.View().String()
102 } else if s.rightPanel != nil {
103 topSection = s.rightPanel.View().String()
104 } else {
105 topSection = ""
106 }
107
108 var finalView string
109
110 if s.bottomPanel != nil && topSection != "" {
111 bottomView := s.bottomPanel.View()
112 finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
113 } else if s.bottomPanel != nil {
114 finalView = s.bottomPanel.View().String()
115 } else {
116 finalView = topSection
117 }
118
119 // TODO: think of a better way to handle multiple cursors
120 var cursor *tea.Cursor
121 if s.bottomPanel != nil {
122 cursor = s.bottomPanel.View().Cursor()
123 } else if s.rightPanel != nil {
124 cursor = s.rightPanel.View().Cursor()
125 } else if s.leftPanel != nil {
126 cursor = s.leftPanel.View().Cursor()
127 }
128
129 t := styles.CurrentTheme()
130
131 style := t.S().Base.
132 Width(s.width).
133 Height(s.height)
134
135 view := tea.NewView(style.Render(finalView))
136 view.SetCursor(cursor)
137 return view
138}
139
140func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
141 s.width = width
142 s.height = height
143
144 var topHeight, bottomHeight int
145 var cmds []tea.Cmd
146 if s.bottomPanel != nil {
147 if s.fixedBottomHeight > 0 {
148 bottomHeight = s.fixedBottomHeight
149 topHeight = height - bottomHeight
150 } else {
151 topHeight = int(float64(height) * s.verticalRatio)
152 bottomHeight = height - topHeight
153 if bottomHeight <= 0 {
154 bottomHeight = 2
155 topHeight = height - bottomHeight
156 }
157 }
158 } else {
159 topHeight = height
160 bottomHeight = 0
161 }
162
163 var leftWidth, rightWidth int
164 if s.leftPanel != nil && s.rightPanel != nil {
165 if s.fixedRightWidth > 0 {
166 rightWidth = s.fixedRightWidth
167 leftWidth = width - rightWidth
168 } else {
169 leftWidth = int(float64(width) * s.ratio)
170 rightWidth = width - leftWidth
171 if rightWidth <= 0 {
172 rightWidth = 2
173 leftWidth = width - rightWidth
174 }
175 }
176 } else if s.leftPanel != nil {
177 leftWidth = width
178 rightWidth = 0
179 } else if s.rightPanel != nil {
180 leftWidth = 0
181 rightWidth = width
182 }
183
184 if s.leftPanel != nil {
185 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
186 cmds = append(cmds, cmd)
187 if positionable, ok := s.leftPanel.(Positionable); ok {
188 cmds = append(cmds, positionable.SetPosition(0, 0))
189 }
190 }
191
192 if s.rightPanel != nil {
193 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
194 cmds = append(cmds, cmd)
195 if positionable, ok := s.rightPanel.(Positionable); ok {
196 cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
197 }
198 }
199
200 if s.bottomPanel != nil {
201 cmd := s.bottomPanel.SetSize(width, bottomHeight)
202 cmds = append(cmds, cmd)
203 if positionable, ok := s.bottomPanel.(Positionable); ok {
204 cmds = append(cmds, positionable.SetPosition(0, topHeight))
205 }
206 }
207 return tea.Batch(cmds...)
208}
209
210func (s *splitPaneLayout) GetSize() (int, int) {
211 return s.width, s.height
212}
213
214func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
215 s.leftPanel = panel
216 if s.width > 0 && s.height > 0 {
217 return s.SetSize(s.width, s.height)
218 }
219 return nil
220}
221
222func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
223 s.rightPanel = panel
224 if s.width > 0 && s.height > 0 {
225 return s.SetSize(s.width, s.height)
226 }
227 return nil
228}
229
230func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
231 s.bottomPanel = panel
232 if s.width > 0 && s.height > 0 {
233 return s.SetSize(s.width, s.height)
234 }
235 return nil
236}
237
238func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
239 s.leftPanel = nil
240 if s.width > 0 && s.height > 0 {
241 return s.SetSize(s.width, s.height)
242 }
243 return nil
244}
245
246func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
247 s.rightPanel = nil
248 if s.width > 0 && s.height > 0 {
249 return s.SetSize(s.width, s.height)
250 }
251 return nil
252}
253
254func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
255 s.bottomPanel = nil
256 if s.width > 0 && s.height > 0 {
257 return s.SetSize(s.width, s.height)
258 }
259 return nil
260}
261
262func (s *splitPaneLayout) BindingKeys() []key.Binding {
263 keys := []key.Binding{}
264 if s.leftPanel != nil {
265 if b, ok := s.leftPanel.(Bindings); ok {
266 keys = append(keys, b.BindingKeys()...)
267 }
268 }
269 if s.rightPanel != nil {
270 if b, ok := s.rightPanel.(Bindings); ok {
271 keys = append(keys, b.BindingKeys()...)
272 }
273 }
274 if s.bottomPanel != nil {
275 if b, ok := s.bottomPanel.(Bindings); ok {
276 keys = append(keys, b.BindingKeys()...)
277 }
278 }
279 return keys
280}
281
282func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
283 layout := &splitPaneLayout{
284 ratio: 0.8,
285 verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
286 }
287 for _, option := range options {
288 option(layout)
289 }
290 return layout
291}
292
293func WithLeftPanel(panel Container) SplitPaneOption {
294 return func(s *splitPaneLayout) {
295 s.leftPanel = panel
296 }
297}
298
299func WithRightPanel(panel Container) SplitPaneOption {
300 return func(s *splitPaneLayout) {
301 s.rightPanel = panel
302 }
303}
304
305func WithRatio(ratio float64) SplitPaneOption {
306 return func(s *splitPaneLayout) {
307 s.ratio = ratio
308 }
309}
310
311func WithBottomPanel(panel Container) SplitPaneOption {
312 return func(s *splitPaneLayout) {
313 s.bottomPanel = panel
314 }
315}
316
317func WithVerticalRatio(ratio float64) SplitPaneOption {
318 return func(s *splitPaneLayout) {
319 s.verticalRatio = ratio
320 }
321}
322
323func WithFixedBottomHeight(height int) SplitPaneOption {
324 return func(s *splitPaneLayout) {
325 s.fixedBottomHeight = height
326 }
327}
328
329func WithFixedRightWidth(width int) SplitPaneOption {
330 return func(s *splitPaneLayout) {
331 s.fixedRightWidth = width
332 }
333}