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	return nil
 77}
 78
 79func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 80	switch msg := msg.(type) {
 81	case tea.WindowSizeMsg:
 82		b.SetSize(msg.Width, msg.Height)
 83		return b, nil
 84	case tea.KeyMsg:
 85		switch {
 86		case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
 87			return b, b.SwitchPane(false)
 88		case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
 89			return b, b.SwitchPane(true)
 90		case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
 91			return b, b.HidePane(b.currentPane)
 92		case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
 93			for id := range b.hiddenPanes {
 94				delete(b.hiddenPanes, id)
 95			}
 96			b.SetSize(b.width, b.height)
 97			return b, nil
 98		}
 99	}
100
101	if pane, ok := b.panes[b.currentPane]; ok {
102		u, cmd := pane.Update(msg)
103		b.panes[b.currentPane] = u.(SinglePaneLayout)
104		return b, cmd
105	}
106	return b, nil
107}
108
109func (b bentoLayout) View() string {
110	if b.width <= 0 || b.height <= 0 {
111		return ""
112	}
113
114	for id, pane := range b.panes {
115		if b.currentPane == id {
116			pane.Focus()
117		} else {
118			pane.Blur()
119		}
120	}
121
122	leftVisible := false
123	rightTopVisible := false
124	rightBottomVisible := false
125
126	var leftPane, rightTopPane, rightBottomPane string
127
128	if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
129		leftPane = pane.View()
130		leftVisible = true
131	}
132
133	if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
134		rightTopPane = pane.View()
135		rightTopVisible = true
136	}
137
138	if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
139		rightBottomPane = pane.View()
140		rightBottomVisible = true
141	}
142
143	if leftVisible {
144		if rightTopVisible || rightBottomVisible {
145			rightSection := ""
146			if rightTopVisible && rightBottomVisible {
147				rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
148			} else if rightTopVisible {
149				rightSection = rightTopPane
150			} else {
151				rightSection = rightBottomPane
152			}
153			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
154				lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
155			)
156		} else {
157			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
158		}
159	} else if rightTopVisible || rightBottomVisible {
160		if rightTopVisible && rightBottomVisible {
161			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
162				lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
163			)
164		} else if rightTopVisible {
165			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
166		} else {
167			return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
168		}
169	}
170	return ""
171}
172
173func (b *bentoLayout) SetSize(width int, height int) {
174	if width < 0 || height < 0 {
175		return
176	}
177	b.width = width
178	b.height = height
179
180	// Check which panes are available
181	leftExists := false
182	rightTopExists := false
183	rightBottomExists := false
184
185	if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
186		leftExists = true
187	}
188	if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
189		rightTopExists = true
190	}
191	if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
192		rightBottomExists = true
193	}
194
195	leftWidth := 0
196	rightWidth := 0
197	rightTopHeight := 0
198	rightBottomHeight := 0
199
200	if leftExists && (rightTopExists || rightBottomExists) {
201		leftWidth = int(float64(width) * b.leftWidthRatio)
202		if leftWidth < minLeftWidth && width >= minLeftWidth {
203			leftWidth = minLeftWidth
204		}
205		rightWidth = width - leftWidth
206
207		if rightTopExists && rightBottomExists {
208			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
209			rightBottomHeight = height - rightTopHeight
210
211			// Ensure minimum height for bottom pane
212			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
213				rightBottomHeight = minRightBottomHeight
214				rightTopHeight = height - rightBottomHeight
215			}
216		} else if rightTopExists {
217			rightTopHeight = height
218		} else if rightBottomExists {
219			rightBottomHeight = height
220		}
221	} else if leftExists {
222		leftWidth = width
223	} else if rightTopExists || rightBottomExists {
224		rightWidth = width
225
226		if rightTopExists && rightBottomExists {
227			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
228			rightBottomHeight = height - rightTopHeight
229
230			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
231				rightBottomHeight = minRightBottomHeight
232				rightTopHeight = height - rightBottomHeight
233			}
234		} else if rightTopExists {
235			rightTopHeight = height
236		} else if rightBottomExists {
237			rightBottomHeight = height
238		}
239	}
240
241	if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
242		pane.SetSize(leftWidth, height)
243	}
244	if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
245		pane.SetSize(rightWidth, rightTopHeight)
246	}
247	if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
248		pane.SetSize(rightWidth, rightBottomHeight)
249	}
250}
251
252func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
253	if len(b.panes)-len(b.hiddenPanes) == 1 {
254		return nil
255	}
256	if _, ok := b.panes[pane]; ok {
257		b.hiddenPanes[pane] = true
258	}
259	b.SetSize(b.width, b.height)
260	return b.SwitchPane(false)
261}
262
263func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
264	if back {
265		switch b.currentPane {
266		case BentoLeftPane:
267			b.currentPane = BentoRightBottomPane
268		case BentoRightTopPane:
269			b.currentPane = BentoLeftPane
270		case BentoRightBottomPane:
271			b.currentPane = BentoRightTopPane
272		}
273	} else {
274		switch b.currentPane {
275		case BentoLeftPane:
276			b.currentPane = BentoRightTopPane
277		case BentoRightTopPane:
278			b.currentPane = BentoRightBottomPane
279		case BentoRightBottomPane:
280			b.currentPane = BentoLeftPane
281		}
282	}
283
284	var cmds []tea.Cmd
285	for id, pane := range b.panes {
286		if _, ok := b.hiddenPanes[id]; ok {
287			continue
288		}
289		if id == b.currentPane {
290			cmds = append(cmds, pane.Focus())
291		} else {
292			cmds = append(cmds, pane.Blur())
293		}
294	}
295
296	return tea.Batch(cmds...)
297}
298
299func (s *bentoLayout) BindingKeys() []key.Binding {
300	bindings := KeyMapToSlice(defaultBentoKeyBindings)
301	if b, ok := s.panes[s.currentPane].(Bindings); ok {
302		bindings = append(bindings, b.BindingKeys()...)
303	}
304	return bindings
305}
306
307type BentoLayoutOption func(*bentoLayout)
308
309func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
310	p := make(map[paneID]SinglePaneLayout, len(panes))
311	for id, pane := range panes {
312		// Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
313		if _, ok := pane.(SinglePaneLayout); !ok {
314			p[id] = NewSinglePane(
315				pane,
316				WithSinglePaneFocusable(true),
317				WithSinglePaneBordered(true),
318			)
319		} else {
320			p[id] = pane.(SinglePaneLayout)
321		}
322	}
323	if len(p) == 0 {
324		panic("no panes provided for BentoLayout")
325	}
326	layout := &bentoLayout{
327		panes:               p,
328		hiddenPanes:         make(map[paneID]bool),
329		currentPane:         BentoLeftPane,
330		leftWidthRatio:      defaultLeftWidthRatio,
331		rightTopHeightRatio: defaultRightTopHeightRatio,
332	}
333
334	for _, opt := range opts {
335		opt(layout)
336	}
337
338	return layout
339}
340
341func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
342	return func(b *bentoLayout) {
343		if ratio > 0 && ratio < 1 {
344			b.leftWidthRatio = ratio
345		}
346	}
347}
348
349func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
350	return func(b *bentoLayout) {
351		if ratio > 0 && ratio < 1 {
352			b.rightTopHeightRatio = ratio
353		}
354	}
355}
356
357func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
358	return func(b *bentoLayout) {
359		b.currentPane = pane
360	}
361}