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	leftExists := false
191	rightTopExists := false
192	rightBottomExists := false
193
194	if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
195		leftExists = true
196	}
197	if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
198		rightTopExists = true
199	}
200	if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
201		rightBottomExists = true
202	}
203
204	leftWidth := 0
205	rightWidth := 0
206	rightTopHeight := 0
207	rightBottomHeight := 0
208
209	if leftExists && (rightTopExists || rightBottomExists) {
210		leftWidth = int(float64(width) * b.leftWidthRatio)
211		if leftWidth < minLeftWidth && width >= minLeftWidth {
212			leftWidth = minLeftWidth
213		}
214		rightWidth = width - leftWidth
215
216		if rightTopExists && rightBottomExists {
217			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
218			rightBottomHeight = height - rightTopHeight
219
220			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
221				rightBottomHeight = minRightBottomHeight
222				rightTopHeight = height - rightBottomHeight
223			}
224		} else if rightTopExists {
225			rightTopHeight = height
226		} else if rightBottomExists {
227			rightBottomHeight = height
228		}
229	} else if leftExists {
230		leftWidth = width
231	} else if rightTopExists || rightBottomExists {
232		rightWidth = width
233
234		if rightTopExists && rightBottomExists {
235			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
236			rightBottomHeight = height - rightTopHeight
237
238			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
239				rightBottomHeight = minRightBottomHeight
240				rightTopHeight = height - rightBottomHeight
241			}
242		} else if rightTopExists {
243			rightTopHeight = height
244		} else if rightBottomExists {
245			rightBottomHeight = height
246		}
247	}
248
249	if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
250		pane.SetSize(leftWidth, height)
251	}
252	if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
253		pane.SetSize(rightWidth, rightTopHeight)
254	}
255	if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
256		pane.SetSize(rightWidth, rightBottomHeight)
257	}
258}
259
260func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
261	if len(b.panes)-len(b.hiddenPanes) == 1 {
262		return nil
263	}
264	if _, ok := b.panes[pane]; ok {
265		b.hiddenPanes[pane] = true
266	}
267	b.SetSize(b.width, b.height)
268	return b.SwitchPane(false)
269}
270
271func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
272	orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
273	orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
274
275	order := orderForward
276	if back {
277		order = orderBackward
278	}
279
280	currentIdx := -1
281	for i, id := range order {
282		if id == b.currentPane {
283			currentIdx = i
284			break
285		}
286	}
287
288	if currentIdx == -1 {
289		for _, id := range order {
290			if _, exists := b.panes[id]; exists {
291				if _, hidden := b.hiddenPanes[id]; !hidden {
292					b.currentPane = id
293					break
294				}
295			}
296		}
297	} else {
298		startIdx := currentIdx
299		for {
300			currentIdx = (currentIdx + 1) % len(order)
301
302			nextID := order[currentIdx]
303			if _, exists := b.panes[nextID]; exists {
304				if _, hidden := b.hiddenPanes[nextID]; !hidden {
305					b.currentPane = nextID
306					break
307				}
308			}
309
310			if currentIdx == startIdx {
311				break
312			}
313		}
314	}
315
316	var cmds []tea.Cmd
317	for id, pane := range b.panes {
318		if _, ok := b.hiddenPanes[id]; ok {
319			continue
320		}
321		if id == b.currentPane {
322			cmds = append(cmds, pane.Focus())
323		} else {
324			cmds = append(cmds, pane.Blur())
325		}
326	}
327
328	return tea.Batch(cmds...)
329}
330
331func (s *bentoLayout) BindingKeys() []key.Binding {
332	bindings := KeyMapToSlice(defaultBentoKeyBindings)
333	if b, ok := s.panes[s.currentPane].(Bindings); ok {
334		bindings = append(bindings, b.BindingKeys()...)
335	}
336	return bindings
337}
338
339type BentoLayoutOption func(*bentoLayout)
340
341func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
342	p := make(map[paneID]SinglePaneLayout, len(panes))
343	for id, pane := range panes {
344		if sp, ok := pane.(SinglePaneLayout); !ok {
345			p[id] = NewSinglePane(
346				pane,
347				WithSinglePaneFocusable(true),
348				WithSinglePaneBordered(true),
349			)
350		} else {
351			p[id] = sp
352		}
353	}
354	if len(p) == 0 {
355		panic("no panes provided for BentoLayout")
356	}
357	layout := &bentoLayout{
358		panes:               p,
359		hiddenPanes:         make(map[paneID]bool),
360		currentPane:         BentoLeftPane,
361		leftWidthRatio:      defaultLeftWidthRatio,
362		rightTopHeightRatio: defaultRightTopHeightRatio,
363	}
364
365	for _, opt := range opts {
366		opt(layout)
367	}
368
369	return layout
370}
371
372func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
373	return func(b *bentoLayout) {
374		if ratio > 0 && ratio < 1 {
375			b.leftWidthRatio = ratio
376		}
377	}
378}
379
380func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
381	return func(b *bentoLayout) {
382		if ratio > 0 && ratio < 1 {
383			b.rightTopHeightRatio = ratio
384		}
385	}
386}
387
388func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
389	return func(b *bentoLayout) {
390		b.currentPane = pane
391	}
392}