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