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