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}