split.go

  1package layout
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/key"
  5	tea "github.com/charmbracelet/bubbletea/v2"
  6	"github.com/charmbracelet/crush/internal/tui/styles"
  7	"github.com/charmbracelet/crush/internal/tui/util"
  8	"github.com/charmbracelet/lipgloss/v2"
  9)
 10
 11type LayoutPanel string
 12
 13const (
 14	LeftPanel   LayoutPanel = "left"
 15	RightPanel  LayoutPanel = "right"
 16	BottomPanel LayoutPanel = "bottom"
 17)
 18
 19type SplitPaneLayout interface {
 20	util.Model
 21	Sizeable
 22	Bindings
 23	SetLeftPanel(panel Container) tea.Cmd
 24	SetRightPanel(panel Container) tea.Cmd
 25	SetBottomPanel(panel Container) tea.Cmd
 26
 27	ClearLeftPanel() tea.Cmd
 28	ClearRightPanel() tea.Cmd
 29	ClearBottomPanel() tea.Cmd
 30
 31	FocusPanel(panel LayoutPanel) tea.Cmd
 32}
 33
 34type splitPaneLayout struct {
 35	width  int
 36	height int
 37
 38	ratio         float64
 39	verticalRatio float64
 40
 41	rightPanel  Container
 42	leftPanel   Container
 43	bottomPanel Container
 44
 45	fixedBottomHeight int // Fixed height for the bottom panel, if any
 46	fixedRightWidth   int // Fixed width for the right panel, if any
 47}
 48
 49type SplitPaneOption func(*splitPaneLayout)
 50
 51func (s *splitPaneLayout) Init() tea.Cmd {
 52	var cmds []tea.Cmd
 53
 54	if s.leftPanel != nil {
 55		cmds = append(cmds, s.leftPanel.Init())
 56	}
 57
 58	if s.rightPanel != nil {
 59		cmds = append(cmds, s.rightPanel.Init())
 60	}
 61
 62	if s.bottomPanel != nil {
 63		cmds = append(cmds, s.bottomPanel.Init())
 64	}
 65
 66	return tea.Batch(cmds...)
 67}
 68
 69func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 70	var cmds []tea.Cmd
 71	switch msg := msg.(type) {
 72	case tea.WindowSizeMsg:
 73		return s, s.SetSize(msg.Width, msg.Height)
 74	}
 75
 76	if s.rightPanel != nil {
 77		u, cmd := s.rightPanel.Update(msg)
 78		s.rightPanel = u.(Container)
 79		if cmd != nil {
 80			cmds = append(cmds, cmd)
 81		}
 82	}
 83
 84	if s.leftPanel != nil {
 85		u, cmd := s.leftPanel.Update(msg)
 86		s.leftPanel = u.(Container)
 87		if cmd != nil {
 88			cmds = append(cmds, cmd)
 89		}
 90	}
 91
 92	if s.bottomPanel != nil {
 93		u, cmd := s.bottomPanel.Update(msg)
 94		s.bottomPanel = u.(Container)
 95		if cmd != nil {
 96			cmds = append(cmds, cmd)
 97		}
 98	}
 99
100	return s, tea.Batch(cmds...)
101}
102
103func (s *splitPaneLayout) View() tea.View {
104	var topSection string
105
106	if s.leftPanel != nil && s.rightPanel != nil {
107		leftView := s.leftPanel.View()
108		rightView := s.rightPanel.View()
109		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
110	} else if s.leftPanel != nil {
111		topSection = s.leftPanel.View().String()
112	} else if s.rightPanel != nil {
113		topSection = s.rightPanel.View().String()
114	} else {
115		topSection = ""
116	}
117
118	var finalView string
119
120	if s.bottomPanel != nil && topSection != "" {
121		bottomView := s.bottomPanel.View()
122		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
123	} else if s.bottomPanel != nil {
124		finalView = s.bottomPanel.View().String()
125	} else {
126		finalView = topSection
127	}
128
129	// TODO: think of a better way to handle multiple cursors
130	var cursor *tea.Cursor
131	if s.bottomPanel != nil {
132		cursor = s.bottomPanel.View().Cursor()
133	} else if s.rightPanel != nil {
134		cursor = s.rightPanel.View().Cursor()
135	} else if s.leftPanel != nil {
136		cursor = s.leftPanel.View().Cursor()
137	}
138
139	t := styles.CurrentTheme()
140
141	style := t.S().Base.
142		Width(s.width).
143		Height(s.height)
144
145	view := tea.NewView(style.Render(finalView))
146	view.SetCursor(cursor)
147	return view
148}
149
150func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
151	s.width = width
152	s.height = height
153
154	var topHeight, bottomHeight int
155	var cmds []tea.Cmd
156	if s.bottomPanel != nil {
157		if s.fixedBottomHeight > 0 {
158			bottomHeight = s.fixedBottomHeight
159			topHeight = height - bottomHeight
160		} else {
161			topHeight = int(float64(height) * s.verticalRatio)
162			bottomHeight = height - topHeight
163			if bottomHeight <= 0 {
164				bottomHeight = 2
165				topHeight = height - bottomHeight
166			}
167		}
168	} else {
169		topHeight = height
170		bottomHeight = 0
171	}
172
173	var leftWidth, rightWidth int
174	if s.leftPanel != nil && s.rightPanel != nil {
175		if s.fixedRightWidth > 0 {
176			rightWidth = s.fixedRightWidth
177			leftWidth = width - rightWidth
178		} else {
179			leftWidth = int(float64(width) * s.ratio)
180			rightWidth = width - leftWidth
181			if rightWidth <= 0 {
182				rightWidth = 2
183				leftWidth = width - rightWidth
184			}
185		}
186	} else if s.leftPanel != nil {
187		leftWidth = width
188		rightWidth = 0
189	} else if s.rightPanel != nil {
190		leftWidth = 0
191		rightWidth = width
192	}
193
194	if s.leftPanel != nil {
195		cmd := s.leftPanel.SetSize(leftWidth, topHeight)
196		cmds = append(cmds, cmd)
197		if positionable, ok := s.leftPanel.(Positionable); ok {
198			cmds = append(cmds, positionable.SetPosition(0, 0))
199		}
200	}
201
202	if s.rightPanel != nil {
203		cmd := s.rightPanel.SetSize(rightWidth, topHeight)
204		cmds = append(cmds, cmd)
205		if positionable, ok := s.rightPanel.(Positionable); ok {
206			cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
207		}
208	}
209
210	if s.bottomPanel != nil {
211		cmd := s.bottomPanel.SetSize(width, bottomHeight)
212		cmds = append(cmds, cmd)
213		if positionable, ok := s.bottomPanel.(Positionable); ok {
214			cmds = append(cmds, positionable.SetPosition(0, topHeight))
215		}
216	}
217	return tea.Batch(cmds...)
218}
219
220func (s *splitPaneLayout) GetSize() (int, int) {
221	return s.width, s.height
222}
223
224func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
225	s.leftPanel = panel
226	if s.width > 0 && s.height > 0 {
227		return s.SetSize(s.width, s.height)
228	}
229	return nil
230}
231
232func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
233	s.rightPanel = panel
234	if s.width > 0 && s.height > 0 {
235		return s.SetSize(s.width, s.height)
236	}
237	return nil
238}
239
240func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
241	s.bottomPanel = panel
242	if s.width > 0 && s.height > 0 {
243		return s.SetSize(s.width, s.height)
244	}
245	return nil
246}
247
248func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
249	s.leftPanel = nil
250	if s.width > 0 && s.height > 0 {
251		return s.SetSize(s.width, s.height)
252	}
253	return nil
254}
255
256func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
257	s.rightPanel = nil
258	if s.width > 0 && s.height > 0 {
259		return s.SetSize(s.width, s.height)
260	}
261	return nil
262}
263
264func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
265	s.bottomPanel = nil
266	if s.width > 0 && s.height > 0 {
267		return s.SetSize(s.width, s.height)
268	}
269	return nil
270}
271
272func (s *splitPaneLayout) BindingKeys() []key.Binding {
273	keys := []key.Binding{}
274	if s.leftPanel != nil {
275		if b, ok := s.leftPanel.(Bindings); ok {
276			keys = append(keys, b.BindingKeys()...)
277		}
278	}
279	if s.rightPanel != nil {
280		if b, ok := s.rightPanel.(Bindings); ok {
281			keys = append(keys, b.BindingKeys()...)
282		}
283	}
284	if s.bottomPanel != nil {
285		if b, ok := s.bottomPanel.(Bindings); ok {
286			keys = append(keys, b.BindingKeys()...)
287		}
288	}
289	return keys
290}
291
292func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
293	panels := map[LayoutPanel]Container{
294		LeftPanel:   s.leftPanel,
295		RightPanel:  s.rightPanel,
296		BottomPanel: s.bottomPanel,
297	}
298	var cmds []tea.Cmd
299	for p, container := range panels {
300		if container == nil {
301			continue
302		}
303		if p == panel {
304			cmds = append(cmds, container.Focus())
305		} else {
306			cmds = append(cmds, container.Blur())
307		}
308	}
309	return tea.Batch(cmds...)
310}
311
312func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
313	layout := &splitPaneLayout{
314		ratio:         0.8,
315		verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
316	}
317	for _, option := range options {
318		option(layout)
319	}
320	return layout
321}
322
323func WithLeftPanel(panel Container) SplitPaneOption {
324	return func(s *splitPaneLayout) {
325		s.leftPanel = panel
326	}
327}
328
329func WithRightPanel(panel Container) SplitPaneOption {
330	return func(s *splitPaneLayout) {
331		s.rightPanel = panel
332	}
333}
334
335func WithRatio(ratio float64) SplitPaneOption {
336	return func(s *splitPaneLayout) {
337		s.ratio = ratio
338	}
339}
340
341func WithBottomPanel(panel Container) SplitPaneOption {
342	return func(s *splitPaneLayout) {
343		s.bottomPanel = panel
344	}
345}
346
347func WithVerticalRatio(ratio float64) SplitPaneOption {
348	return func(s *splitPaneLayout) {
349		s.verticalRatio = ratio
350	}
351}
352
353func WithFixedBottomHeight(height int) SplitPaneOption {
354	return func(s *splitPaneLayout) {
355		s.fixedBottomHeight = height
356	}
357}
358
359func WithFixedRightWidth(width int) SplitPaneOption {
360	return func(s *splitPaneLayout) {
361		s.fixedRightWidth = width
362	}
363}