|
|
@@ -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...)
|
|
|
}
|