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