1package layout
2
3import (
4 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
6 "github.com/charmbracelet/lipgloss"
7)
8
9type GridLayout interface {
10 tea.Model
11 Sizeable
12 Bindings
13 Panes() [][]tea.Model
14}
15
16type gridLayout struct {
17 width int
18 height int
19
20 rows int
21 columns int
22
23 panes [][]tea.Model
24
25 gap int
26 bordered bool
27 focusable bool
28
29 currentRow int
30 currentColumn int
31
32 activeColor lipgloss.TerminalColor
33}
34
35type GridOption func(*gridLayout)
36
37func (g *gridLayout) Init() tea.Cmd {
38 var cmds []tea.Cmd
39 for i := range g.panes {
40 for j := range g.panes[i] {
41 if g.panes[i][j] != nil {
42 cmds = append(cmds, g.panes[i][j].Init())
43 }
44 }
45 }
46 return tea.Batch(cmds...)
47}
48
49func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50 var cmds []tea.Cmd
51
52 switch msg := msg.(type) {
53 case tea.WindowSizeMsg:
54 g.SetSize(msg.Width, msg.Height)
55 return g, nil
56 case tea.KeyMsg:
57 if key.Matches(msg, g.nextPaneBinding()) {
58 return g.focusNextPane()
59 }
60 }
61
62 // Update all panes
63 for i := range g.panes {
64 for j := range g.panes[i] {
65 if g.panes[i][j] != nil {
66 var cmd tea.Cmd
67 g.panes[i][j], cmd = g.panes[i][j].Update(msg)
68 if cmd != nil {
69 cmds = append(cmds, cmd)
70 }
71 }
72 }
73 }
74
75 return g, tea.Batch(cmds...)
76}
77
78func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
79 if !g.focusable {
80 return g, nil
81 }
82
83 var cmds []tea.Cmd
84
85 // Blur current pane
86 if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
87 if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
88 cmds = append(cmds, currentPane.Blur())
89 }
90 }
91
92 // Find next valid pane
93 g.currentColumn++
94 if g.currentColumn >= len(g.panes[g.currentRow]) {
95 g.currentColumn = 0
96 g.currentRow++
97 if g.currentRow >= len(g.panes) {
98 g.currentRow = 0
99 }
100 }
101
102 // Focus next pane
103 if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
104 if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
105 cmds = append(cmds, nextPane.Focus())
106 }
107 }
108
109 return g, tea.Batch(cmds...)
110}
111
112func (g *gridLayout) nextPaneBinding() key.Binding {
113 return key.NewBinding(
114 key.WithKeys("tab"),
115 key.WithHelp("tab", "next pane"),
116 )
117}
118
119func (g *gridLayout) View() string {
120 if len(g.panes) == 0 {
121 return ""
122 }
123
124 // Calculate dimensions for each cell
125 cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
126 cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
127
128 // Render each row
129 rows := make([]string, g.rows)
130 for i := range g.rows {
131 // Render each column in this row
132 cols := make([]string, len(g.panes[i]))
133 for j := range g.panes[i] {
134 if g.panes[i][j] == nil {
135 cols[j] = ""
136 continue
137 }
138
139 // Set size for each pane
140 if sizable, ok := g.panes[i][j].(Sizeable); ok {
141 effectiveWidth, effectiveHeight := cellWidth, cellHeight
142 if g.bordered {
143 effectiveWidth -= 2
144 effectiveHeight -= 2
145 }
146 sizable.SetSize(effectiveWidth, effectiveHeight)
147 }
148
149 // Render the pane
150 content := g.panes[i][j].View()
151
152 // Apply border if needed
153 if g.bordered {
154 isFocused := false
155 if focusable, ok := g.panes[i][j].(Focusable); ok {
156 isFocused = focusable.IsFocused()
157 }
158
159 borderText := map[BorderPosition]string{}
160 if bordered, ok := g.panes[i][j].(Bordered); ok {
161 borderText = bordered.BorderText()
162 }
163
164 content = Borderize(content, BorderOptions{
165 Active: isFocused,
166 EmbeddedText: borderText,
167 })
168 }
169
170 cols[j] = content
171 }
172
173 // Join columns with gap
174 rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
175 }
176
177 // Join rows with gap
178 return lipgloss.JoinVertical(lipgloss.Left, rows...)
179}
180
181func (g *gridLayout) SetSize(width, height int) {
182 g.width = width
183 g.height = height
184}
185
186func (g *gridLayout) GetSize() (int, int) {
187 return g.width, g.height
188}
189
190func (g *gridLayout) BindingKeys() []key.Binding {
191 var bindings []key.Binding
192 bindings = append(bindings, g.nextPaneBinding())
193
194 // Collect bindings from all panes
195 for i := range g.panes {
196 for j := range g.panes[i] {
197 if g.panes[i][j] != nil {
198 if bindable, ok := g.panes[i][j].(Bindings); ok {
199 bindings = append(bindings, bindable.BindingKeys()...)
200 }
201 }
202 }
203 }
204
205 return bindings
206}
207
208func (g *gridLayout) Panes() [][]tea.Model {
209 return g.panes
210}
211
212// NewGridLayout creates a new grid layout with the given number of rows and columns
213func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
214 grid := &gridLayout{
215 rows: rows,
216 columns: cols,
217 panes: panes,
218 gap: 1,
219 }
220
221 for _, opt := range opts {
222 opt(grid)
223 }
224
225 return grid
226}
227
228// WithGridGap sets the gap between cells
229func WithGridGap(gap int) GridOption {
230 return func(g *gridLayout) {
231 g.gap = gap
232 }
233}
234
235// WithGridBordered sets whether cells should have borders
236func WithGridBordered(bordered bool) GridOption {
237 return func(g *gridLayout) {
238 g.bordered = bordered
239 }
240}
241
242// WithGridFocusable sets whether the grid supports focus navigation
243func WithGridFocusable(focusable bool) GridOption {
244 return func(g *gridLayout) {
245 g.focusable = focusable
246 }
247}
248
249// WithGridActiveColor sets the active border color
250func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
251 return func(g *gridLayout) {
252 g.activeColor = color
253 }
254}