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/theme"
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 := theme.CurrentTheme()
130
131 style := lipgloss.NewStyle().
132 Width(s.width).
133 Height(s.height).
134 Background(t.Background())
135
136 view := tea.NewView(style.Render(finalView))
137 view.SetCursor(cursor)
138 return view
139}
140
141func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
142 s.width = width
143 s.height = height
144
145 var topHeight, bottomHeight int
146 var cmds []tea.Cmd
147 if s.bottomPanel != nil {
148 if s.fixedBottomHeight > 0 {
149 bottomHeight = s.fixedBottomHeight
150 topHeight = height - bottomHeight
151 } else {
152 topHeight = int(float64(height) * s.verticalRatio)
153 bottomHeight = height - topHeight
154 if bottomHeight <= 0 {
155 bottomHeight = 2
156 topHeight = height - bottomHeight
157 }
158 }
159 } else {
160 topHeight = height
161 bottomHeight = 0
162 }
163
164 var leftWidth, rightWidth int
165 if s.leftPanel != nil && s.rightPanel != nil {
166 if s.fixedRightWidth > 0 {
167 rightWidth = s.fixedRightWidth
168 leftWidth = width - rightWidth
169 } else {
170 leftWidth = int(float64(width) * s.ratio)
171 rightWidth = width - leftWidth
172 if rightWidth <= 0 {
173 rightWidth = 2
174 leftWidth = width - rightWidth
175 }
176 }
177 } else if s.leftPanel != nil {
178 leftWidth = width
179 rightWidth = 0
180 } else if s.rightPanel != nil {
181 leftWidth = 0
182 rightWidth = width
183 }
184
185 if s.leftPanel != nil {
186 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
187 cmds = append(cmds, cmd)
188 if positionable, ok := s.leftPanel.(Positionable); ok {
189 cmds = append(cmds, positionable.SetPosition(0, 0))
190 }
191 }
192
193 if s.rightPanel != nil {
194 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
195 cmds = append(cmds, cmd)
196 if positionable, ok := s.rightPanel.(Positionable); ok {
197 cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
198 }
199 }
200
201 if s.bottomPanel != nil {
202 cmd := s.bottomPanel.SetSize(width, bottomHeight)
203 cmds = append(cmds, cmd)
204 if positionable, ok := s.bottomPanel.(Positionable); ok {
205 cmds = append(cmds, positionable.SetPosition(0, topHeight))
206 }
207 }
208 return tea.Batch(cmds...)
209}
210
211func (s *splitPaneLayout) GetSize() (int, int) {
212 return s.width, s.height
213}
214
215func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
216 s.leftPanel = panel
217 if s.width > 0 && s.height > 0 {
218 return s.SetSize(s.width, s.height)
219 }
220 return nil
221}
222
223func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
224 s.rightPanel = panel
225 if s.width > 0 && s.height > 0 {
226 return s.SetSize(s.width, s.height)
227 }
228 return nil
229}
230
231func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
232 s.bottomPanel = panel
233 if s.width > 0 && s.height > 0 {
234 return s.SetSize(s.width, s.height)
235 }
236 return nil
237}
238
239func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
240 s.leftPanel = nil
241 if s.width > 0 && s.height > 0 {
242 return s.SetSize(s.width, s.height)
243 }
244 return nil
245}
246
247func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
248 s.rightPanel = nil
249 if s.width > 0 && s.height > 0 {
250 return s.SetSize(s.width, s.height)
251 }
252 return nil
253}
254
255func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
256 s.bottomPanel = nil
257 if s.width > 0 && s.height > 0 {
258 return s.SetSize(s.width, s.height)
259 }
260 return nil
261}
262
263func (s *splitPaneLayout) BindingKeys() []key.Binding {
264 keys := []key.Binding{}
265 if s.leftPanel != nil {
266 if b, ok := s.leftPanel.(Bindings); ok {
267 keys = append(keys, b.BindingKeys()...)
268 }
269 }
270 if s.rightPanel != nil {
271 if b, ok := s.rightPanel.(Bindings); ok {
272 keys = append(keys, b.BindingKeys()...)
273 }
274 }
275 if s.bottomPanel != nil {
276 if b, ok := s.bottomPanel.(Bindings); ok {
277 keys = append(keys, b.BindingKeys()...)
278 }
279 }
280 return keys
281}
282
283func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
284 layout := &splitPaneLayout{
285 ratio: 0.8,
286 verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
287 }
288 for _, option := range options {
289 option(layout)
290 }
291 return layout
292}
293
294func WithLeftPanel(panel Container) SplitPaneOption {
295 return func(s *splitPaneLayout) {
296 s.leftPanel = panel
297 }
298}
299
300func WithRightPanel(panel Container) SplitPaneOption {
301 return func(s *splitPaneLayout) {
302 s.rightPanel = panel
303 }
304}
305
306func WithRatio(ratio float64) SplitPaneOption {
307 return func(s *splitPaneLayout) {
308 s.ratio = ratio
309 }
310}
311
312func WithBottomPanel(panel Container) SplitPaneOption {
313 return func(s *splitPaneLayout) {
314 s.bottomPanel = panel
315 }
316}
317
318func WithVerticalRatio(ratio float64) SplitPaneOption {
319 return func(s *splitPaneLayout) {
320 s.verticalRatio = ratio
321 }
322}
323
324func WithFixedBottomHeight(height int) SplitPaneOption {
325 return func(s *splitPaneLayout) {
326 s.fixedBottomHeight = height
327 }
328}
329
330func WithFixedRightWidth(width int) SplitPaneOption {
331 return func(s *splitPaneLayout) {
332 s.fixedRightWidth = width
333 }
334}