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	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}