grid.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 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}