summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/layout
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-03-25 13:04:36 +0100
committerKujtim Hoxha <[email protected]>2025-03-26 01:12:30 +0100
commit904061c243f70696bfe781e97bf4e392e6954d07 (patch)
tree4428f96d09968ee0cde44e6ebbaee4757f80050e /internal/tui/layout
parent005b8ac16776512b2d4b1f22bd989da162ca1bad (diff)
downloadopencode-904061c243f70696bfe781e97bf4e392e6954d07.tar.gz
opencode-904061c243f70696bfe781e97bf4e392e6954d07.zip
additional tools
Diffstat (limited to 'internal/tui/layout')
-rw-r--r--internal/tui/layout/border.go55
-rw-r--r--internal/tui/layout/grid.go254
-rw-r--r--internal/tui/layout/single.go5
3 files changed, 295 insertions, 19 deletions
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
index a3c80396f..8fe5c430c 100644
--- a/internal/tui/layout/border.go
+++ b/internal/tui/layout/border.go
@@ -24,24 +24,43 @@ var (
InactivePreviewBorder = styles.Grey
)
-func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
- if embeddedText == nil {
- embeddedText = make(map[BorderPosition]string)
+type BorderOptions struct {
+ Active bool
+ EmbeddedText map[BorderPosition]string
+ ActiveColor lipgloss.TerminalColor
+ InactiveColor lipgloss.TerminalColor
+ ActiveBorder lipgloss.Border
+ InactiveBorder lipgloss.Border
+}
+
+func Borderize(content string, opts BorderOptions) string {
+ if opts.EmbeddedText == nil {
+ opts.EmbeddedText = make(map[BorderPosition]string)
+ }
+ if opts.ActiveColor == nil {
+ opts.ActiveColor = ActiveBorder
}
- if activeColor == nil {
- activeColor = ActiveBorder
+ if opts.InactiveColor == nil {
+ opts.InactiveColor = InactivePreviewBorder
}
+ if opts.ActiveBorder == (lipgloss.Border{}) {
+ opts.ActiveBorder = lipgloss.ThickBorder()
+ }
+ if opts.InactiveBorder == (lipgloss.Border{}) {
+ opts.InactiveBorder = lipgloss.NormalBorder()
+ }
+
var (
thickness = map[bool]lipgloss.Border{
- true: lipgloss.Border(lipgloss.ThickBorder()),
- false: lipgloss.Border(lipgloss.NormalBorder()),
+ true: opts.ActiveBorder,
+ false: opts.InactiveBorder,
}
color = map[bool]lipgloss.TerminalColor{
- true: activeColor,
- false: InactivePreviewBorder,
+ true: opts.ActiveColor,
+ false: opts.InactiveColor,
}
- border = thickness[active]
- style = lipgloss.NewStyle().Foreground(color[active])
+ border = thickness[opts.Active]
+ style = lipgloss.NewStyle().Foreground(color[opts.Active])
width = lipgloss.Width(content)
)
@@ -80,20 +99,20 @@ func Borderize(content string, active bool, embeddedText map[BorderPosition]stri
// Stack top border, content and horizontal borders, and bottom border.
return strings.Join([]string{
buildHorizontalBorder(
- embeddedText[TopLeftBorder],
- embeddedText[TopMiddleBorder],
- embeddedText[TopRightBorder],
+ opts.EmbeddedText[TopLeftBorder],
+ opts.EmbeddedText[TopMiddleBorder],
+ opts.EmbeddedText[TopRightBorder],
border.TopLeft,
border.Top,
border.TopRight,
),
lipgloss.NewStyle().
- BorderForeground(color[active]).
+ BorderForeground(color[opts.Active]).
Border(border, false, true, false, true).Render(content),
buildHorizontalBorder(
- embeddedText[BottomLeftBorder],
- embeddedText[BottomMiddleBorder],
- embeddedText[BottomRightBorder],
+ opts.EmbeddedText[BottomLeftBorder],
+ opts.EmbeddedText[BottomMiddleBorder],
+ opts.EmbeddedText[BottomRightBorder],
border.BottomLeft,
border.Bottom,
border.BottomRight,
diff --git a/internal/tui/layout/grid.go b/internal/tui/layout/grid.go
new file mode 100644
index 000000000..d6f0b4ab9
--- /dev/null
+++ b/internal/tui/layout/grid.go
@@ -0,0 +1,254 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type GridLayout interface {
+ tea.Model
+ Sizeable
+ Bindings
+ Panes() [][]tea.Model
+}
+
+type gridLayout struct {
+ width int
+ height int
+
+ rows int
+ columns int
+
+ panes [][]tea.Model
+
+ gap int
+ bordered bool
+ focusable bool
+
+ currentRow int
+ currentColumn int
+
+ activeColor lipgloss.TerminalColor
+}
+
+type GridOption func(*gridLayout)
+
+func (g *gridLayout) Init() tea.Cmd {
+ var cmds []tea.Cmd
+ for i := range g.panes {
+ for j := range g.panes[i] {
+ if g.panes[i][j] != nil {
+ cmds = append(cmds, g.panes[i][j].Init())
+ }
+ }
+ }
+ return tea.Batch(cmds...)
+}
+
+func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ g.SetSize(msg.Width, msg.Height)
+ return g, nil
+ case tea.KeyMsg:
+ if key.Matches(msg, g.nextPaneBinding()) {
+ return g.focusNextPane()
+ }
+ }
+
+ // Update all panes
+ for i := range g.panes {
+ for j := range g.panes[i] {
+ if g.panes[i][j] != nil {
+ var cmd tea.Cmd
+ g.panes[i][j], cmd = g.panes[i][j].Update(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ }
+ }
+
+ return g, tea.Batch(cmds...)
+}
+
+func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
+ if !g.focusable {
+ return g, nil
+ }
+
+ var cmds []tea.Cmd
+
+ // Blur current pane
+ if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
+ if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
+ cmds = append(cmds, currentPane.Blur())
+ }
+ }
+
+ // Find next valid pane
+ g.currentColumn++
+ if g.currentColumn >= len(g.panes[g.currentRow]) {
+ g.currentColumn = 0
+ g.currentRow++
+ if g.currentRow >= len(g.panes) {
+ g.currentRow = 0
+ }
+ }
+
+ // Focus next pane
+ if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
+ if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
+ cmds = append(cmds, nextPane.Focus())
+ }
+ }
+
+ return g, tea.Batch(cmds...)
+}
+
+func (g *gridLayout) nextPaneBinding() key.Binding {
+ return key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "next pane"),
+ )
+}
+
+func (g *gridLayout) View() string {
+ if len(g.panes) == 0 {
+ return ""
+ }
+
+ // Calculate dimensions for each cell
+ cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
+ cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
+
+ // Render each row
+ rows := make([]string, g.rows)
+ for i := 0; i < g.rows; i++ {
+ // Render each column in this row
+ cols := make([]string, len(g.panes[i]))
+ for j := 0; j < len(g.panes[i]); j++ {
+ if g.panes[i][j] == nil {
+ cols[j] = ""
+ continue
+ }
+
+ // Set size for each pane
+ if sizable, ok := g.panes[i][j].(Sizeable); ok {
+ effectiveWidth, effectiveHeight := cellWidth, cellHeight
+ if g.bordered {
+ effectiveWidth -= 2
+ effectiveHeight -= 2
+ }
+ sizable.SetSize(effectiveWidth, effectiveHeight)
+ }
+
+ // Render the pane
+ content := g.panes[i][j].View()
+
+ // Apply border if needed
+ if g.bordered {
+ isFocused := false
+ if focusable, ok := g.panes[i][j].(Focusable); ok {
+ isFocused = focusable.IsFocused()
+ }
+
+ borderText := map[BorderPosition]string{}
+ if bordered, ok := g.panes[i][j].(Bordered); ok {
+ borderText = bordered.BorderText()
+ }
+
+ content = Borderize(content, BorderOptions{
+ Active: isFocused,
+ EmbeddedText: borderText,
+ })
+ }
+
+ cols[j] = content
+ }
+
+ // Join columns with gap
+ rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
+ }
+
+ // Join rows with gap
+ return lipgloss.JoinVertical(lipgloss.Left, rows...)
+}
+
+func (g *gridLayout) SetSize(width, height int) {
+ g.width = width
+ g.height = height
+}
+
+func (g *gridLayout) GetSize() (int, int) {
+ return g.width, g.height
+}
+
+func (g *gridLayout) BindingKeys() []key.Binding {
+ var bindings []key.Binding
+ bindings = append(bindings, g.nextPaneBinding())
+
+ // Collect bindings from all panes
+ for i := range g.panes {
+ for j := range g.panes[i] {
+ if g.panes[i][j] != nil {
+ if bindable, ok := g.panes[i][j].(Bindings); ok {
+ bindings = append(bindings, bindable.BindingKeys()...)
+ }
+ }
+ }
+ }
+
+ return bindings
+}
+
+func (g *gridLayout) Panes() [][]tea.Model {
+ return g.panes
+}
+
+// NewGridLayout creates a new grid layout with the given number of rows and columns
+func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
+ grid := &gridLayout{
+ rows: rows,
+ columns: cols,
+ panes: panes,
+ gap: 1,
+ }
+
+ for _, opt := range opts {
+ opt(grid)
+ }
+
+ return grid
+}
+
+// WithGridGap sets the gap between cells
+func WithGridGap(gap int) GridOption {
+ return func(g *gridLayout) {
+ g.gap = gap
+ }
+}
+
+// WithGridBordered sets whether cells should have borders
+func WithGridBordered(bordered bool) GridOption {
+ return func(g *gridLayout) {
+ g.bordered = bordered
+ }
+}
+
+// WithGridFocusable sets whether the grid supports focus navigation
+func WithGridFocusable(focusable bool) GridOption {
+ return func(g *gridLayout) {
+ g.focusable = focusable
+ }
+}
+
+// WithGridActiveColor sets the active border color
+func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
+ return func(g *gridLayout) {
+ g.activeColor = color
+ }
+}
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
index 1e4d0881c..e5c9a61c4 100644
--- a/internal/tui/layout/single.go
+++ b/internal/tui/layout/single.go
@@ -64,7 +64,10 @@ func (s *singlePaneLayout) View() string {
if bordered, ok := s.content.(Bordered); ok {
s.borderText = bordered.BorderText()
}
- return Borderize(content, s.focused, s.borderText, s.activeColor)
+ return Borderize(content, BorderOptions{
+ Active: s.focused,
+ EmbeddedText: s.borderText,
+ })
}
return content
}