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}