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}