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}
 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) Bindings() []key.Binding {
273	if s.leftPanel != nil {
274		if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
275			return b.Bindings()
276		}
277	}
278	if s.rightPanel != nil {
279		if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
280			return b.Bindings()
281		}
282	}
283	if s.bottomPanel != nil {
284		if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
285			return b.Bindings()
286		}
287	}
288	return nil
289}
290
291func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
292	panels := map[LayoutPanel]Container{
293		LeftPanel:   s.leftPanel,
294		RightPanel:  s.rightPanel,
295		BottomPanel: s.bottomPanel,
296	}
297	var cmds []tea.Cmd
298	for p, container := range panels {
299		if container == nil {
300			continue
301		}
302		if p == panel {
303			cmds = append(cmds, container.Focus())
304		} else {
305			cmds = append(cmds, container.Blur())
306		}
307	}
308	return tea.Batch(cmds...)
309}
310
311func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
312	layout := &splitPaneLayout{
313		ratio:         0.8,
314		verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
315	}
316	for _, option := range options {
317		option(layout)
318	}
319	return layout
320}
321
322func WithLeftPanel(panel Container) SplitPaneOption {
323	return func(s *splitPaneLayout) {
324		s.leftPanel = panel
325	}
326}
327
328func WithRightPanel(panel Container) SplitPaneOption {
329	return func(s *splitPaneLayout) {
330		s.rightPanel = panel
331	}
332}
333
334func WithRatio(ratio float64) SplitPaneOption {
335	return func(s *splitPaneLayout) {
336		s.ratio = ratio
337	}
338}
339
340func WithBottomPanel(panel Container) SplitPaneOption {
341	return func(s *splitPaneLayout) {
342		s.bottomPanel = panel
343	}
344}
345
346func WithVerticalRatio(ratio float64) SplitPaneOption {
347	return func(s *splitPaneLayout) {
348		s.verticalRatio = ratio
349	}
350}
351
352func WithFixedBottomHeight(height int) SplitPaneOption {
353	return func(s *splitPaneLayout) {
354		s.fixedBottomHeight = height
355	}
356}
357
358func WithFixedRightWidth(width int) SplitPaneOption {
359	return func(s *splitPaneLayout) {
360		s.fixedRightWidth = width
361	}
362}