split.go

  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}