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