summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/layout
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-28 06:04:01 -0500
committeradamdottv <[email protected]>2025-06-30 12:29:29 -0500
commit9f3ba0396596c750aa6b080e571382e383eed73e (patch)
treec2d92e3d7dd2c9ed02d7a28a597ffceaea4fd13f /packages/tui/internal/layout
parentd090c08ef0940d974305adc29ea931e046626786 (diff)
downloadopencode-9f3ba0396596c750aa6b080e571382e383eed73e.tar.gz
opencode-9f3ba0396596c750aa6b080e571382e383eed73e.zip
chore: rework layout primitives
Diffstat (limited to 'packages/tui/internal/layout')
-rw-r--r--packages/tui/internal/layout/container.go292
-rw-r--r--packages/tui/internal/layout/flex.go397
-rw-r--r--packages/tui/internal/layout/layout.go34
3 files changed, 201 insertions, 522 deletions
diff --git a/packages/tui/internal/layout/container.go b/packages/tui/internal/layout/container.go
deleted file mode 100644
index 250034ebc..000000000
--- a/packages/tui/internal/layout/container.go
+++ /dev/null
@@ -1,292 +0,0 @@
-package layout
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type Container interface {
- tea.Model
- tea.ViewModel
- Sizeable
- Focusable
- Alignable
-}
-
-type container struct {
- width int
- height int
- x int
- y int
-
- content tea.ViewModel
-
- paddingTop int
- paddingRight int
- paddingBottom int
- paddingLeft int
-
- borderTop bool
- borderRight bool
- borderBottom bool
- borderLeft bool
- borderStyle lipgloss.Border
-
- maxWidth int
- align lipgloss.Position
-
- focused bool
-}
-
-func (c *container) Init() tea.Cmd {
- if model, ok := c.content.(tea.Model); ok {
- return model.Init()
- }
- return nil
-}
-
-func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- if model, ok := c.content.(tea.Model); ok {
- u, cmd := model.Update(msg)
- c.content = u.(tea.ViewModel)
- return c, cmd
- }
- return c, nil
-}
-
-func (c *container) View() string {
- t := theme.CurrentTheme()
- style := styles.NewStyle().Background(t.Background())
- width := c.width
- height := c.height
-
- // Apply max width constraint if set
- if c.maxWidth > 0 && width > c.maxWidth {
- width = c.maxWidth
- }
-
- // Apply border if any side is enabled
- if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
- // Adjust width and height for borders
- if c.borderTop {
- height--
- }
- if c.borderBottom {
- height--
- }
- if c.borderLeft {
- width--
- }
- if c.borderRight {
- width--
- }
- style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
-
- // Use primary color for border if focused
- if c.focused {
- style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
- } else {
- style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
- }
- }
- style = style.
- Width(width).
- Height(height).
- PaddingTop(c.paddingTop).
- PaddingRight(c.paddingRight).
- PaddingBottom(c.paddingBottom).
- PaddingLeft(c.paddingLeft)
-
- return style.Render(c.content.View())
-}
-
-func (c *container) SetSize(width, height int) tea.Cmd {
- c.width = width
- c.height = height
-
- // Apply max width constraint if set
- effectiveWidth := width
- if c.maxWidth > 0 && width > c.maxWidth {
- effectiveWidth = c.maxWidth
- }
-
- // If the content implements Sizeable, adjust its size to account for padding and borders
- if sizeable, ok := c.content.(Sizeable); ok {
- // Calculate horizontal space taken by padding and borders
- horizontalSpace := c.paddingLeft + c.paddingRight
- if c.borderLeft {
- horizontalSpace++
- }
- if c.borderRight {
- horizontalSpace++
- }
-
- // Calculate vertical space taken by padding and borders
- verticalSpace := c.paddingTop + c.paddingBottom
- if c.borderTop {
- verticalSpace++
- }
- if c.borderBottom {
- verticalSpace++
- }
-
- // Set content size with adjusted dimensions
- contentWidth := max(0, effectiveWidth-horizontalSpace)
- contentHeight := max(0, height-verticalSpace)
- return sizeable.SetSize(contentWidth, contentHeight)
- }
- return nil
-}
-
-func (c *container) GetSize() (int, int) {
- return min(c.width, c.maxWidth), c.height
-}
-
-func (c *container) MaxWidth() int {
- return c.maxWidth
-}
-
-func (c *container) Alignment() lipgloss.Position {
- return c.align
-}
-
-// Focus sets the container as focused
-func (c *container) Focus() tea.Cmd {
- c.focused = true
- if focusable, ok := c.content.(Focusable); ok {
- return focusable.Focus()
- }
- return nil
-}
-
-// Blur removes focus from the container
-func (c *container) Blur() tea.Cmd {
- c.focused = false
- if blurable, ok := c.content.(Focusable); ok {
- return blurable.Blur()
- }
- return nil
-}
-
-func (c *container) IsFocused() bool {
- if blurable, ok := c.content.(Focusable); ok {
- return blurable.IsFocused()
- }
- return c.focused
-}
-
-// GetPosition returns the x, y coordinates of the container
-func (c *container) GetPosition() (x, y int) {
- return c.x, c.y
-}
-
-func (c *container) SetPosition(x, y int) {
- c.x = x
- c.y = y
-}
-
-type ContainerOption func(*container)
-
-func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
- c := &container{
- content: content,
- borderStyle: lipgloss.NormalBorder(),
- }
- for _, option := range options {
- option(c)
- }
- return c
-}
-
-// Padding options
-func WithPadding(top, right, bottom, left int) ContainerOption {
- return func(c *container) {
- c.paddingTop = top
- c.paddingRight = right
- c.paddingBottom = bottom
- c.paddingLeft = left
- }
-}
-
-func WithPaddingAll(padding int) ContainerOption {
- return WithPadding(padding, padding, padding, padding)
-}
-
-func WithPaddingHorizontal(padding int) ContainerOption {
- return func(c *container) {
- c.paddingLeft = padding
- c.paddingRight = padding
- }
-}
-
-func WithPaddingVertical(padding int) ContainerOption {
- return func(c *container) {
- c.paddingTop = padding
- c.paddingBottom = padding
- }
-}
-
-func WithBorder(top, right, bottom, left bool) ContainerOption {
- return func(c *container) {
- c.borderTop = top
- c.borderRight = right
- c.borderBottom = bottom
- c.borderLeft = left
- }
-}
-
-func WithBorderAll() ContainerOption {
- return WithBorder(true, true, true, true)
-}
-
-func WithBorderHorizontal() ContainerOption {
- return WithBorder(true, false, true, false)
-}
-
-func WithBorderVertical() ContainerOption {
- return WithBorder(false, true, false, true)
-}
-
-func WithBorderStyle(style lipgloss.Border) ContainerOption {
- return func(c *container) {
- c.borderStyle = style
- }
-}
-
-func WithRoundedBorder() ContainerOption {
- return WithBorderStyle(lipgloss.RoundedBorder())
-}
-
-func WithThickBorder() ContainerOption {
- return WithBorderStyle(lipgloss.ThickBorder())
-}
-
-func WithDoubleBorder() ContainerOption {
- return WithBorderStyle(lipgloss.DoubleBorder())
-}
-
-func WithMaxWidth(maxWidth int) ContainerOption {
- return func(c *container) {
- c.maxWidth = maxWidth
- }
-}
-
-func WithAlign(align lipgloss.Position) ContainerOption {
- return func(c *container) {
- c.align = align
- }
-}
-
-func WithAlignLeft() ContainerOption {
- return WithAlign(lipgloss.Left)
-}
-
-func WithAlignCenter() ContainerOption {
- return WithAlign(lipgloss.Center)
-}
-
-func WithAlignRight() ContainerOption {
- return WithAlign(lipgloss.Right)
-}
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
index 320a95203..f164a03dc 100644
--- a/packages/tui/internal/layout/flex.go
+++ b/packages/tui/internal/layout/flex.go
@@ -1,255 +1,260 @@
package layout
import (
- tea "github.com/charmbracelet/bubbletea/v2"
+ "strings"
+
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
)
-type FlexDirection int
+type Direction int
const (
- FlexDirectionHorizontal FlexDirection = iota
- FlexDirectionVertical
+ Row Direction = iota
+ Column
)
-type FlexChildSize struct {
- Fixed bool
- Size int
-}
+type Justify int
-var FlexChildSizeGrow = FlexChildSize{Fixed: false}
+const (
+ JustifyStart Justify = iota
+ JustifyEnd
+ JustifyCenter
+ JustifySpaceBetween
+ JustifySpaceAround
+)
-func FlexChildSizeFixed(size int) FlexChildSize {
- return FlexChildSize{Fixed: true, Size: size}
-}
+type Align int
-type FlexLayout interface {
- tea.ViewModel
- Sizeable
- SetChildren(panes []tea.ViewModel) tea.Cmd
- SetSizes(sizes []FlexChildSize) tea.Cmd
- SetDirection(direction FlexDirection) tea.Cmd
-}
+const (
+ AlignStart Align = iota
+ AlignEnd
+ AlignCenter
+ AlignStretch // Only applicable in the cross-axis
+)
-type flexLayout struct {
- width int
- height int
- direction FlexDirection
- children []tea.ViewModel
- sizes []FlexChildSize
+type FlexOptions struct {
+ Direction Direction
+ Justify Justify
+ Align Align
+ Width int
+ Height int
}
-type FlexLayoutOption func(*flexLayout)
+type FlexItem struct {
+ View string
+ FixedSize int // Fixed size in the main axis (width for Row, height for Column)
+ Grow bool // If true, the item will grow to fill available space
+}
-func (f *flexLayout) View() string {
- if len(f.children) == 0 {
+// Render lays out a series of view strings based on flexbox-like rules.
+func Render(opts FlexOptions, items ...FlexItem) string {
+ if len(items) == 0 {
return ""
}
- t := theme.CurrentTheme()
- views := make([]string, 0, len(f.children))
- for i, child := range f.children {
- if child == nil {
- continue
- }
+ // Calculate dimensions for each item
+ mainAxisSize := opts.Width
+ crossAxisSize := opts.Height
+ if opts.Direction == Column {
+ mainAxisSize = opts.Height
+ crossAxisSize = opts.Width
+ }
- alignment := lipgloss.Center
- if alignable, ok := child.(Alignable); ok {
- alignment = alignable.Alignment()
- }
- var childWidth, childHeight int
- if f.direction == FlexDirectionHorizontal {
- childWidth, childHeight = f.calculateChildSize(i)
- view := lipgloss.PlaceHorizontal(
- childWidth,
- alignment,
- child.View(),
- // TODO: make configurable WithBackgroundStyle
- lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
- )
- views = append(views, view)
- } else {
- childWidth, childHeight = f.calculateChildSize(i)
- view := lipgloss.Place(
- f.width,
- childHeight,
- lipgloss.Center,
- alignment,
- child.View(),
- // TODO: make configurable WithBackgroundStyle
- lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
- )
- views = append(views, view)
+ // Calculate total fixed size and count grow items
+ totalFixedSize := 0
+ growCount := 0
+ for _, item := range items {
+ if item.FixedSize > 0 {
+ totalFixedSize += item.FixedSize
+ } else if item.Grow {
+ growCount++
}
}
- if f.direction == FlexDirectionHorizontal {
- return lipgloss.JoinHorizontal(lipgloss.Center, views...)
+
+ // Calculate available space for grow items
+ availableSpace := mainAxisSize - totalFixedSize
+ if availableSpace < 0 {
+ availableSpace = 0
}
- return lipgloss.JoinVertical(lipgloss.Center, views...)
-}
-func (f *flexLayout) calculateChildSize(index int) (width, height int) {
- if index >= len(f.children) {
- return 0, 0
+ // Calculate size for each grow item
+ growItemSize := 0
+ if growCount > 0 && availableSpace > 0 {
+ growItemSize = availableSpace / growCount
}
- totalFixed := 0
- flexCount := 0
+ // Prepare sized views
+ sizedViews := make([]string, len(items))
+ actualSizes := make([]int, len(items))
- for i, child := range f.children {
- if child == nil {
- continue
- }
- if i < len(f.sizes) && f.sizes[i].Fixed {
- if f.direction == FlexDirectionHorizontal {
- totalFixed += f.sizes[i].Size
+ for i, item := range items {
+ view := item.View
+
+ // Determine the size for this item
+ itemSize := 0
+ if item.FixedSize > 0 {
+ itemSize = item.FixedSize
+ } else if item.Grow && growItemSize > 0 {
+ itemSize = growItemSize
+ } else {
+ // No fixed size and not growing - use natural size
+ if opts.Direction == Row {
+ itemSize = lipgloss.Width(view)
} else {
- totalFixed += f.sizes[i].Size
+ itemSize = lipgloss.Height(view)
}
- } else {
- flexCount++
}
- }
- if f.direction == FlexDirectionHorizontal {
- height = f.height
- if index < len(f.sizes) && f.sizes[index].Fixed {
- width = f.sizes[index].Size
- } else if flexCount > 0 {
- remainingSpace := f.width - totalFixed
- width = remainingSpace / flexCount
- }
- } else {
- width = f.width
- if index < len(f.sizes) && f.sizes[index].Fixed {
- height = f.sizes[index].Size
- } else if flexCount > 0 {
- remainingSpace := f.height - totalFixed
- height = remainingSpace / flexCount
- }
- }
-
- return width, height
-}
-
-func (f *flexLayout) SetSize(width, height int) tea.Cmd {
- f.width = width
- f.height = height
-
- var cmds []tea.Cmd
- currentX, currentY := 0, 0
-
- for i, child := range f.children {
- if child != nil {
- paneWidth, paneHeight := f.calculateChildSize(i)
- alignment := lipgloss.Center
- if alignable, ok := child.(Alignable); ok {
- alignment = alignable.Alignment()
+ // Apply size constraints
+ if opts.Direction == Row {
+ // For row direction, constrain width and handle height alignment
+ if itemSize > 0 {
+ view = styles.NewStyle().
+ Width(itemSize).
+ Height(crossAxisSize).
+ Render(view)
}
- // Calculate actual position based on alignment
- actualX, actualY := currentX, currentY
-
- if f.direction == FlexDirectionHorizontal {
- // In horizontal layout, vertical alignment affects Y position
- // (lipgloss.Center is used for vertical alignment in JoinHorizontal)
- actualY = (f.height - paneHeight) / 2
- } else {
- // In vertical layout, horizontal alignment affects X position
- contentWidth := paneWidth
- if alignable, ok := child.(Alignable); ok {
- if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
- contentWidth = alignable.MaxWidth()
- }
- }
-
- switch alignment {
- case lipgloss.Center:
- actualX = (f.width - contentWidth) / 2
- case lipgloss.Right:
- actualX = f.width - contentWidth
- case lipgloss.Left:
- actualX = 0
- }
+ // Apply cross-axis alignment
+ switch opts.Align {
+ case AlignCenter:
+ view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
+ case AlignEnd:
+ view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
+ case AlignStart:
+ view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
+ case AlignStretch:
+ // Already stretched by Height setting above
}
-
- // Set position if the pane is Alignable
- if c, ok := child.(Alignable); ok {
- c.SetPosition(actualX, actualY)
+ } else {
+ // For column direction, constrain height and handle width alignment
+ if itemSize > 0 {
+ view = styles.NewStyle().
+ Height(itemSize).
+ Width(crossAxisSize).
+ Render(view)
}
- if sizeable, ok := child.(Sizeable); ok {
- cmd := sizeable.SetSize(paneWidth, paneHeight)
- cmds = append(cmds, cmd)
+ // Apply cross-axis alignment
+ switch opts.Align {
+ case AlignCenter:
+ view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
+ case AlignEnd:
+ view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
+ case AlignStart:
+ view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
+ case AlignStretch:
+ // Already stretched by Width setting above
}
+ }
- // Update position for next pane
- if f.direction == FlexDirectionHorizontal {
- currentX += paneWidth
- } else {
- currentY += paneHeight
- }
+ sizedViews[i] = view
+ if opts.Direction == Row {
+ actualSizes[i] = lipgloss.Width(view)
+ } else {
+ actualSizes[i] = lipgloss.Height(view)
}
}
- return tea.Batch(cmds...)
-}
-func (f *flexLayout) GetSize() (int, int) {
- return f.width, f.height
-}
+ // Calculate total actual size
+ totalActualSize := 0
+ for _, size := range actualSizes {
+ totalActualSize += size
+ }
-func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
- f.children = children
- if f.width > 0 && f.height > 0 {
- return f.SetSize(f.width, f.height)
+ // Apply justification
+ remainingSpace := mainAxisSize - totalActualSize
+ if remainingSpace < 0 {
+ remainingSpace = 0
}
- return nil
-}
-func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
- f.sizes = sizes
- if f.width > 0 && f.height > 0 {
- return f.SetSize(f.width, f.height)
+ // Calculate spacing based on justification
+ var spaceBefore, spaceBetween, spaceAfter int
+ switch opts.Justify {
+ case JustifyStart:
+ spaceAfter = remainingSpace
+ case JustifyEnd:
+ spaceBefore = remainingSpace
+ case JustifyCenter:
+ spaceBefore = remainingSpace / 2
+ spaceAfter = remainingSpace - spaceBefore
+ case JustifySpaceBetween:
+ if len(items) > 1 {
+ spaceBetween = remainingSpace / (len(items) - 1)
+ } else {
+ spaceAfter = remainingSpace
+ }
+ case JustifySpaceAround:
+ if len(items) > 0 {
+ spaceAround := remainingSpace / (len(items) * 2)
+ spaceBefore = spaceAround
+ spaceAfter = spaceAround
+ spaceBetween = spaceAround * 2
+ }
}
- return nil
-}
-func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
- f.direction = direction
- if f.width > 0 && f.height > 0 {
- return f.SetSize(f.width, f.height)
+ // Build the final layout
+ var parts []string
+
+ // Add space before if needed
+ if spaceBefore > 0 {
+ if opts.Direction == Row {
+ parts = append(parts, strings.Repeat(" ", spaceBefore))
+ } else {
+ parts = append(parts, strings.Repeat("\n", spaceBefore))
+ }
}
- return nil
-}
-func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
- layout := &flexLayout{
- children: children,
- direction: FlexDirectionHorizontal,
- sizes: []FlexChildSize{},
+ // Add items with spacing
+ for i, view := range sizedViews {
+ parts = append(parts, view)
+
+ // Add space between items (not after the last one)
+ if i < len(sizedViews)-1 && spaceBetween > 0 {
+ if opts.Direction == Row {
+ parts = append(parts, strings.Repeat(" ", spaceBetween))
+ } else {
+ parts = append(parts, strings.Repeat("\n", spaceBetween))
+ }
+ }
}
- for _, option := range options {
- option(layout)
+
+ // Add space after if needed
+ if spaceAfter > 0 {
+ if opts.Direction == Row {
+ parts = append(parts, strings.Repeat(" ", spaceAfter))
+ } else {
+ parts = append(parts, strings.Repeat("\n", spaceAfter))
+ }
}
- return layout
-}
-func WithDirection(direction FlexDirection) FlexLayoutOption {
- return func(f *flexLayout) {
- f.direction = direction
+ // Join the parts
+ if opts.Direction == Row {
+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+ } else {
+ return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
}
-func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
- return func(f *flexLayout) {
- f.children = children
- }
+// Helper function to create a simple vertical layout
+func Vertical(width, height int, items ...FlexItem) string {
+ return Render(FlexOptions{
+ Direction: Column,
+ Width: width,
+ Height: height,
+ Justify: JustifyStart,
+ Align: AlignStretch,
+ }, items...)
}
-func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
- return func(f *flexLayout) {
- f.sizes = sizes
- }
+// Helper function to create a simple horizontal layout
+func Horizontal(width, height int, items ...FlexItem) string {
+ return Render(FlexOptions{
+ Direction: Row,
+ Width: width,
+ Height: height,
+ Justify: JustifyStart,
+ Align: AlignStretch,
+ }, items...)
}
diff --git a/packages/tui/internal/layout/layout.go b/packages/tui/internal/layout/layout.go
index 208faaa2f..dce27ac68 100644
--- a/packages/tui/internal/layout/layout.go
+++ b/packages/tui/internal/layout/layout.go
@@ -1,11 +1,7 @@
package layout
import (
- "reflect"
-
- "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
)
var Current *LayoutInfo
@@ -34,33 +30,3 @@ type Modal interface {
Render(background string) string
Close() tea.Cmd
}
-
-type Focusable interface {
- Focus() tea.Cmd
- Blur() tea.Cmd
- IsFocused() bool
-}
-
-type Sizeable interface {
- SetSize(width, height int) tea.Cmd
- GetSize() (int, int)
-}
-
-type Alignable interface {
- MaxWidth() int
- Alignment() lipgloss.Position
- SetPosition(x, y int)
- GetPosition() (x, y int)
-}
-
-func KeyMapToSlice(t any) (bindings []key.Binding) {
- typ := reflect.TypeOf(t)
- if typ.Kind() != reflect.Struct {
- return nil
- }
- for i := range typ.NumField() {
- v := reflect.ValueOf(t).Field(i)
- bindings = append(bindings, v.Interface().(key.Binding))
- }
- return
-}