grid.go 5.2 KB

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