mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 21:38:01 +00:00
chore: rework layout primitives
This commit is contained in:
parent
d090c08ef0
commit
9f3ba03965
7 changed files with 298 additions and 589 deletions
|
@ -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
|
||||
|
||||
const (
|
||||
JustifyStart Justify = iota
|
||||
JustifyEnd
|
||||
JustifyCenter
|
||||
JustifySpaceBetween
|
||||
JustifySpaceAround
|
||||
)
|
||||
|
||||
type Align int
|
||||
|
||||
const (
|
||||
AlignStart Align = iota
|
||||
AlignEnd
|
||||
AlignCenter
|
||||
AlignStretch // Only applicable in the cross-axis
|
||||
)
|
||||
|
||||
type FlexOptions struct {
|
||||
Direction Direction
|
||||
Justify Justify
|
||||
Align Align
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
||||
|
||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
||||
return FlexChildSize{Fixed: true, Size: size}
|
||||
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
|
||||
}
|
||||
|
||||
type FlexLayout interface {
|
||||
tea.ViewModel
|
||||
Sizeable
|
||||
SetChildren(panes []tea.ViewModel) tea.Cmd
|
||||
SetSizes(sizes []FlexChildSize) tea.Cmd
|
||||
SetDirection(direction FlexDirection) tea.Cmd
|
||||
}
|
||||
|
||||
type flexLayout struct {
|
||||
width int
|
||||
height int
|
||||
direction FlexDirection
|
||||
children []tea.ViewModel
|
||||
sizes []FlexChildSize
|
||||
}
|
||||
|
||||
type FlexLayoutOption func(*flexLayout)
|
||||
|
||||
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()
|
||||
// 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++
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Calculate available space for grow items
|
||||
availableSpace := mainAxisSize - totalFixedSize
|
||||
if availableSpace < 0 {
|
||||
availableSpace = 0
|
||||
}
|
||||
|
||||
// Calculate size for each grow item
|
||||
growItemSize := 0
|
||||
if growCount > 0 && availableSpace > 0 {
|
||||
growItemSize = availableSpace / growCount
|
||||
}
|
||||
|
||||
// Prepare sized views
|
||||
sizedViews := make([]string, len(items))
|
||||
actualSizes := make([]int, len(items))
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
|
||||
if index >= len(f.children) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
totalFixed := 0
|
||||
flexCount := 0
|
||||
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
} else {
|
||||
flexCount++
|
||||
// For column direction, constrain height and handle width alignment
|
||||
if itemSize > 0 {
|
||||
view = styles.NewStyle().
|
||||
Height(itemSize).
|
||||
Width(crossAxisSize).
|
||||
Render(view)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
sizedViews[i] = view
|
||||
if opts.Direction == Row {
|
||||
actualSizes[i] = lipgloss.Width(view)
|
||||
} else {
|
||||
actualSizes[i] = lipgloss.Height(view)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Calculate total actual size
|
||||
totalActualSize := 0
|
||||
for _, size := range actualSizes {
|
||||
totalActualSize += size
|
||||
}
|
||||
|
||||
// Apply justification
|
||||
remainingSpace := mainAxisSize - totalActualSize
|
||||
if remainingSpace < 0 {
|
||||
remainingSpace = 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// Join the parts
|
||||
if opts.Direction == Row {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
} 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Set position if the pane is Alignable
|
||||
if c, ok := child.(Alignable); ok {
|
||||
c.SetPosition(actualX, actualY)
|
||||
}
|
||||
|
||||
if sizeable, ok := child.(Sizeable); ok {
|
||||
cmd := sizeable.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
// Update position for next pane
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
currentX += paneWidth
|
||||
} else {
|
||||
currentY += paneHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) GetSize() (int, int) {
|
||||
return f.width, f.height
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
|
||||
layout := &flexLayout{
|
||||
children: children,
|
||||
direction: FlexDirectionHorizontal,
|
||||
sizes: []FlexChildSize{},
|
||||
}
|
||||
for _, option := range options {
|
||||
option(layout)
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
func WithDirection(direction FlexDirection) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.direction = direction
|
||||
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...)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue