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