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) Cursor() *tea.Cursor {
107	if s.bottomPanel != nil {
108		if c, ok := s.bottomPanel.(util.Cursor); ok {
109			return c.Cursor()
110		}
111	} else if s.rightPanel != nil {
112		if c, ok := s.rightPanel.(util.Cursor); ok {
113			return c.Cursor()
114		}
115	} else if s.leftPanel != nil {
116		if c, ok := s.leftPanel.(util.Cursor); ok {
117			return c.Cursor()
118		}
119	}
120	return nil
121}
122
123func (s *splitPaneLayout) View() string {
124	var topSection string
125
126	if s.leftPanel != nil && s.rightPanel != nil {
127		leftView := s.leftPanel.View()
128		rightView := s.rightPanel.View()
129		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
130	} else if s.leftPanel != nil {
131		topSection = s.leftPanel.View()
132	} else if s.rightPanel != nil {
133		topSection = s.rightPanel.View()
134	} else {
135		topSection = ""
136	}
137
138	var finalView string
139
140	if s.bottomPanel != nil && topSection != "" {
141		bottomView := s.bottomPanel.View()
142		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
143	} else if s.bottomPanel != nil {
144		finalView = s.bottomPanel.View()
145	} else {
146		finalView = topSection
147	}
148
149	t := styles.CurrentTheme()
150
151	style := t.S().Base.
152		Width(s.width).
153		Height(s.height)
154
155	return style.Render(finalView)
156}
157
158func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
159	s.width = width
160	s.height = height
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}