bento.go

  1package layout
  2
  3import (
  4	"github.com/charmbracelet/bubbles/key"
  5	tea "github.com/charmbracelet/bubbletea"
  6	"github.com/charmbracelet/lipgloss"
  7)
  8
  9type paneID string
 10
 11const (
 12	BentoLeftPane        paneID = "left"
 13	BentoRightTopPane    paneID = "right-top"
 14	BentoRightBottomPane paneID = "right-bottom"
 15)
 16
 17type BentoPanes map[paneID]tea.Model
 18
 19const (
 20	defaultLeftWidthRatio      = 0.2
 21	defaultRightTopHeightRatio = 0.85
 22
 23	minLeftWidth         = 10
 24	minRightBottomHeight = 10
 25)
 26
 27type BentoLayout interface {
 28	tea.Model
 29	Sizeable
 30	Bindings
 31}
 32
 33type BentoKeyBindings struct {
 34	SwitchPane      key.Binding
 35	SwitchPaneBack  key.Binding
 36	HideCurrentPane key.Binding
 37	ShowAllPanes    key.Binding
 38}
 39
 40var defaultBentoKeyBindings = BentoKeyBindings{
 41	SwitchPane: key.NewBinding(
 42		key.WithKeys("tab"),
 43		key.WithHelp("tab", "switch pane"),
 44	),
 45	SwitchPaneBack: key.NewBinding(
 46		key.WithKeys("shift+tab"),
 47		key.WithHelp("shift+tab", "switch pane back"),
 48	),
 49	HideCurrentPane: key.NewBinding(
 50		key.WithKeys("X"),
 51		key.WithHelp("X", "hide current pane"),
 52	),
 53	ShowAllPanes: key.NewBinding(
 54		key.WithKeys("R"),
 55		key.WithHelp("R", "show all panes"),
 56	),
 57}
 58
 59type bentoLayout struct {
 60	width  int
 61	height int
 62
 63	leftWidthRatio      float64
 64	rightTopHeightRatio float64
 65
 66	currentPane paneID
 67	panes       map[paneID]SinglePaneLayout
 68	hiddenPanes map[paneID]bool
 69}
 70
 71func (b *bentoLayout) GetSize() (int, int) {
 72	return b.width, b.height
 73}
 74
 75func (b *bentoLayout) Init() tea.Cmd {
 76	var cmds []tea.Cmd
 77	for _, pane := range b.panes {
 78		cmd := pane.Init()
 79		if cmd != nil {
 80			cmds = append(cmds, cmd)
 81		}
 82	}
 83	return tea.Batch(cmds...)
 84}
 85
 86func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 87	switch msg := msg.(type) {
 88	case tea.WindowSizeMsg:
 89		b.SetSize(msg.Width, msg.Height)
 90		return b, nil
 91	case tea.KeyMsg:
 92		switch {
 93		case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
 94			return b, b.SwitchPane(false)
 95		case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
 96			return b, b.SwitchPane(true)
 97		case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
 98			return b, b.HidePane(b.currentPane)
 99		case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
100			for id := range b.hiddenPanes {
101				delete(b.hiddenPanes, id)
102			}
103			b.SetSize(b.width, b.height)
104			return b, nil
105		}
106	}
107
108	var cmds []tea.Cmd
109	for id, pane := range b.panes {
110		u, cmd := pane.Update(msg)
111		b.panes[id] = u.(SinglePaneLayout)
112		if cmd != nil {
113			cmds = append(cmds, cmd)
114		}
115	}
116	return b, tea.Batch(cmds...)
117}
118
119func (b *bentoLayout) View() string {
120	if b.width <= 0 || b.height <= 0 {
121		return ""
122	}
123
124	for id, pane := range b.panes {
125		if b.currentPane == id {
126			pane.Focus()
127		} else {
128			pane.Blur()
129		}
130	}
131
132	leftVisible := false
133	rightTopVisible := false
134	rightBottomVisible := false
135
136	var leftPane, rightTopPane, rightBottomPane string
137
138	if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
139		leftPane = pane.View()
140		leftVisible = true
141	}
142
143	if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
144		rightTopPane = pane.View()
145		rightTopVisible = true
146	}
147
148	if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
149		rightBottomPane = pane.View()
150		rightBottomVisible = true
151	}
152
153	if leftVisible {
154		if rightTopVisible || rightBottomVisible {
155			rightSection := ""
156			if rightTopVisible && rightBottomVisible {
157				rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
158			} else if rightTopVisible {
159				rightSection = rightTopPane
160			} else {
161				rightSection = rightBottomPane
162			}
163			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
164				lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
165			)
166		} else {
167			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
168		}
169	} else if rightTopVisible || rightBottomVisible {
170		if rightTopVisible && rightBottomVisible {
171			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
172				lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
173			)
174		} else if rightTopVisible {
175			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
176		} else {
177			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
178		}
179	}
180	return ""
181}
182
183func (b *bentoLayout) SetSize(width int, height int) {
184	if width < 0 || height < 0 {
185		return
186	}
187	b.width = width
188	b.height = height
189
190	// Check which panes are available
191	leftExists := false
192	rightTopExists := false
193	rightBottomExists := false
194
195	if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
196		leftExists = true
197	}
198	if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
199		rightTopExists = true
200	}
201	if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
202		rightBottomExists = true
203	}
204
205	leftWidth := 0
206	rightWidth := 0
207	rightTopHeight := 0
208	rightBottomHeight := 0
209
210	if leftExists && (rightTopExists || rightBottomExists) {
211		leftWidth = int(float64(width) * b.leftWidthRatio)
212		if leftWidth < minLeftWidth && width >= minLeftWidth {
213			leftWidth = minLeftWidth
214		}
215		rightWidth = width - leftWidth
216
217		if rightTopExists && rightBottomExists {
218			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
219			rightBottomHeight = height - rightTopHeight
220
221			// Ensure minimum height for bottom pane
222			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
223				rightBottomHeight = minRightBottomHeight
224				rightTopHeight = height - rightBottomHeight
225			}
226		} else if rightTopExists {
227			rightTopHeight = height
228		} else if rightBottomExists {
229			rightBottomHeight = height
230		}
231	} else if leftExists {
232		leftWidth = width
233	} else if rightTopExists || rightBottomExists {
234		rightWidth = width
235
236		if rightTopExists && rightBottomExists {
237			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
238			rightBottomHeight = height - rightTopHeight
239
240			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
241				rightBottomHeight = minRightBottomHeight
242				rightTopHeight = height - rightBottomHeight
243			}
244		} else if rightTopExists {
245			rightTopHeight = height
246		} else if rightBottomExists {
247			rightBottomHeight = height
248		}
249	}
250
251	if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
252		pane.SetSize(leftWidth, height)
253	}
254	if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
255		pane.SetSize(rightWidth, rightTopHeight)
256	}
257	if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
258		pane.SetSize(rightWidth, rightBottomHeight)
259	}
260}
261
262func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
263	if len(b.panes)-len(b.hiddenPanes) == 1 {
264		return nil
265	}
266	if _, ok := b.panes[pane]; ok {
267		b.hiddenPanes[pane] = true
268	}
269	b.SetSize(b.width, b.height)
270	return b.SwitchPane(false)
271}
272
273func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
274	if back {
275		switch b.currentPane {
276		case BentoLeftPane:
277			b.currentPane = BentoRightBottomPane
278		case BentoRightTopPane:
279			b.currentPane = BentoLeftPane
280		case BentoRightBottomPane:
281			b.currentPane = BentoRightTopPane
282		}
283	} else {
284		switch b.currentPane {
285		case BentoLeftPane:
286			b.currentPane = BentoRightTopPane
287		case BentoRightTopPane:
288			b.currentPane = BentoRightBottomPane
289		case BentoRightBottomPane:
290			b.currentPane = BentoLeftPane
291		}
292	}
293
294	var cmds []tea.Cmd
295	for id, pane := range b.panes {
296		if _, ok := b.hiddenPanes[id]; ok {
297			continue
298		}
299		if id == b.currentPane {
300			cmds = append(cmds, pane.Focus())
301		} else {
302			cmds = append(cmds, pane.Blur())
303		}
304	}
305
306	return tea.Batch(cmds...)
307}
308
309func (s *bentoLayout) BindingKeys() []key.Binding {
310	bindings := KeyMapToSlice(defaultBentoKeyBindings)
311	if b, ok := s.panes[s.currentPane].(Bindings); ok {
312		bindings = append(bindings, b.BindingKeys()...)
313	}
314	return bindings
315}
316
317type BentoLayoutOption func(*bentoLayout)
318
319func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
320	p := make(map[paneID]SinglePaneLayout, len(panes))
321	for id, pane := range panes {
322		// Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
323		if sp, ok := pane.(SinglePaneLayout); !ok {
324			p[id] = NewSinglePane(
325				pane,
326				WithSinglePaneFocusable(true),
327				WithSinglePaneBordered(true),
328			)
329		} else {
330			p[id] = sp
331		}
332	}
333	if len(p) == 0 {
334		panic("no panes provided for BentoLayout")
335	}
336	layout := &bentoLayout{
337		panes:               p,
338		hiddenPanes:         make(map[paneID]bool),
339		currentPane:         BentoLeftPane,
340		leftWidthRatio:      defaultLeftWidthRatio,
341		rightTopHeightRatio: defaultRightTopHeightRatio,
342	}
343
344	for _, opt := range opts {
345		opt(layout)
346	}
347
348	return layout
349}
350
351func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
352	return func(b *bentoLayout) {
353		if ratio > 0 && ratio < 1 {
354			b.leftWidthRatio = ratio
355		}
356	}
357}
358
359func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
360	return func(b *bentoLayout) {
361		if ratio > 0 && ratio < 1 {
362			b.rightTopHeightRatio = ratio
363		}
364	}
365}
366
367func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
368	return func(b *bentoLayout) {
369		b.currentPane = pane
370	}
371}