split.go

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