mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
chore: rework layout primitives
This commit is contained in:
parent
d090c08ef0
commit
9f3ba03965
7 changed files with 298 additions and 589 deletions
|
@ -22,7 +22,7 @@ import (
|
||||||
type EditorComponent interface {
|
type EditorComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
tea.ViewModel
|
||||||
layout.Sizeable
|
SetSize(width, height int) tea.Cmd
|
||||||
Content() string
|
Content() string
|
||||||
Lines() int
|
Lines() int
|
||||||
Value() string
|
Value() string
|
||||||
|
@ -158,7 +158,15 @@ func (m *editorComponent) Content() string {
|
||||||
|
|
||||||
func (m *editorComponent) View() string {
|
func (m *editorComponent) View() string {
|
||||||
if m.Lines() > 1 {
|
if m.Lines() > 1 {
|
||||||
return ""
|
t := theme.CurrentTheme()
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
"",
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return m.Content()
|
return m.Content()
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
type MessagesComponent interface {
|
type MessagesComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
tea.ViewModel
|
||||||
|
SetSize(width, height int) tea.Cmd
|
||||||
PageUp() (tea.Model, tea.Cmd)
|
PageUp() (tea.Model, tea.Cmd)
|
||||||
PageDown() (tea.Model, tea.Cmd)
|
PageDown() (tea.Model, tea.Cmd)
|
||||||
HalfPageUp() (tea.Model, tea.Cmd)
|
HalfPageUp() (tea.Model, tea.Cmd)
|
||||||
|
@ -311,6 +312,7 @@ func (m *messagesComponent) View() string {
|
||||||
if len(m.app.Messages) == 0 {
|
if len(m.app.Messages) == 0 {
|
||||||
return m.home()
|
return m.home()
|
||||||
}
|
}
|
||||||
|
t := theme.CurrentTheme()
|
||||||
if m.rendering {
|
if m.rendering {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.width,
|
m.width,
|
||||||
|
@ -318,19 +320,18 @@ func (m *messagesComponent) View() string {
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
"Loading session...",
|
"Loading session...",
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
t := theme.CurrentTheme()
|
header := lipgloss.PlaceHorizontal(
|
||||||
return lipgloss.JoinVertical(
|
m.width,
|
||||||
lipgloss.Left,
|
lipgloss.Center,
|
||||||
lipgloss.PlaceHorizontal(
|
m.header(),
|
||||||
m.width,
|
styles.WhitespaceStyle(t.Background()),
|
||||||
lipgloss.Center,
|
|
||||||
m.header(),
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
),
|
|
||||||
m.viewport.View(),
|
|
||||||
)
|
)
|
||||||
|
return styles.NewStyle().
|
||||||
|
Background(t.Background()).
|
||||||
|
Render(header + "\n" + m.viewport.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) home() string {
|
func (m *messagesComponent) home() string {
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
@ -17,7 +16,7 @@ import (
|
||||||
type CommandsComponent interface {
|
type CommandsComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
tea.ViewModel
|
||||||
layout.Sizeable
|
SetSize(width, height int) tea.Cmd
|
||||||
SetBackgroundColor(color compat.AdaptiveColor)
|
SetBackgroundColor(color compat.AdaptiveColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -1,255 +1,260 @@
|
||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FlexDirection int
|
type Direction int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FlexDirectionHorizontal FlexDirection = iota
|
Row Direction = iota
|
||||||
FlexDirectionVertical
|
Column
|
||||||
)
|
)
|
||||||
|
|
||||||
type FlexChildSize struct {
|
type Justify int
|
||||||
Fixed bool
|
|
||||||
Size 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}
|
type FlexItem struct {
|
||||||
|
View string
|
||||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
|
||||||
return FlexChildSize{Fixed: true, Size: size}
|
Grow bool // If true, the item will grow to fill available space
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlexLayout interface {
|
// Render lays out a series of view strings based on flexbox-like rules.
|
||||||
tea.ViewModel
|
func Render(opts FlexOptions, items ...FlexItem) string {
|
||||||
Sizeable
|
if len(items) == 0 {
|
||||||
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 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
// Calculate dimensions for each item
|
||||||
views := make([]string, 0, len(f.children))
|
mainAxisSize := opts.Width
|
||||||
for i, child := range f.children {
|
crossAxisSize := opts.Height
|
||||||
if child == nil {
|
if opts.Direction == Column {
|
||||||
continue
|
mainAxisSize = opts.Height
|
||||||
}
|
crossAxisSize = opts.Width
|
||||||
|
}
|
||||||
|
|
||||||
alignment := lipgloss.Center
|
// Calculate total fixed size and count grow items
|
||||||
if alignable, ok := child.(Alignable); ok {
|
totalFixedSize := 0
|
||||||
alignment = alignable.Alignment()
|
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)
|
// Calculate available space for grow items
|
||||||
view := lipgloss.PlaceHorizontal(
|
availableSpace := mainAxisSize - totalFixedSize
|
||||||
childWidth,
|
if availableSpace < 0 {
|
||||||
alignment,
|
availableSpace = 0
|
||||||
child.View(),
|
}
|
||||||
// TODO: make configurable WithBackgroundStyle
|
|
||||||
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
|
// Calculate size for each grow item
|
||||||
)
|
growItemSize := 0
|
||||||
views = append(views, view)
|
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 {
|
} else {
|
||||||
childWidth, childHeight = f.calculateChildSize(i)
|
// No fixed size and not growing - use natural size
|
||||||
view := lipgloss.Place(
|
if opts.Direction == Row {
|
||||||
f.width,
|
itemSize = lipgloss.Width(view)
|
||||||
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
|
|
||||||
} else {
|
} 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 {
|
} 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 {
|
// Calculate total actual size
|
||||||
height = f.height
|
totalActualSize := 0
|
||||||
if index < len(f.sizes) && f.sizes[index].Fixed {
|
for _, size := range actualSizes {
|
||||||
width = f.sizes[index].Size
|
totalActualSize += size
|
||||||
} else if flexCount > 0 {
|
}
|
||||||
remainingSpace := f.width - totalFixed
|
|
||||||
width = remainingSpace / flexCount
|
// 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 {
|
} else {
|
||||||
width = f.width
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
|
// Helper function to create a simple vertical layout
|
||||||
return func(f *flexLayout) {
|
func Vertical(width, height int, items ...FlexItem) string {
|
||||||
f.children = children
|
return Render(FlexOptions{
|
||||||
}
|
Direction: Column,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Justify: JustifyStart,
|
||||||
|
Align: AlignStretch,
|
||||||
|
}, items...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
// Helper function to create a simple horizontal layout
|
||||||
return func(f *flexLayout) {
|
func Horizontal(width, height int, items ...FlexItem) string {
|
||||||
f.sizes = sizes
|
return Render(FlexOptions{
|
||||||
}
|
Direction: Row,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Justify: JustifyStart,
|
||||||
|
Align: AlignStretch,
|
||||||
|
}, items...)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var Current *LayoutInfo
|
var Current *LayoutInfo
|
||||||
|
@ -34,33 +30,3 @@ type Modal interface {
|
||||||
Render(background string) string
|
Render(background string) string
|
||||||
Close() tea.Cmd
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -47,8 +47,6 @@ type appModel struct {
|
||||||
status status.StatusComponent
|
status status.StatusComponent
|
||||||
editor chat.EditorComponent
|
editor chat.EditorComponent
|
||||||
messages chat.MessagesComponent
|
messages chat.MessagesComponent
|
||||||
editorContainer layout.Container
|
|
||||||
layout layout.FlexLayout
|
|
||||||
completions dialog.CompletionDialog
|
completions dialog.CompletionDialog
|
||||||
completionManager *completions.CompletionManager
|
completionManager *completions.CompletionManager
|
||||||
showCompletionDialog bool
|
showCompletionDialog bool
|
||||||
|
@ -360,7 +358,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
Width: min(a.width, 80),
|
Width: min(a.width, 80),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
a.layout.SetSize(a.width, a.height)
|
// Update child component sizes
|
||||||
|
messagesHeight := a.height - 6 // Leave room for editor and status bar
|
||||||
|
a.messages.SetSize(a.width, messagesHeight)
|
||||||
|
a.editor.SetSize(min(a.width, 80), 5)
|
||||||
case app.SessionSelectedMsg:
|
case app.SessionSelectedMsg:
|
||||||
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -424,33 +425,69 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) View() string {
|
func (a appModel) View() string {
|
||||||
layoutView := a.layout.View()
|
messagesView := a.messages.View()
|
||||||
editorWidth, _ := a.editorContainer.GetSize()
|
editorView := a.editor.View()
|
||||||
editorX, editorY := a.editorContainer.GetPosition()
|
|
||||||
|
editorHeight := lipgloss.Height(editorView)
|
||||||
|
if editorHeight < 5 {
|
||||||
|
editorHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
centeredEditorView := lipgloss.PlaceHorizontal(
|
||||||
|
a.width,
|
||||||
|
lipgloss.Center,
|
||||||
|
editorView,
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
|
)
|
||||||
|
|
||||||
|
mainLayout := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Direction: layout.Column,
|
||||||
|
Width: a.width,
|
||||||
|
Height: a.height - 1, // Leave room for status bar
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: messagesView,
|
||||||
|
Grow: true,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: centeredEditorView,
|
||||||
|
FixedSize: editorHeight,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if a.editor.Lines() > 1 {
|
if a.editor.Lines() > 1 {
|
||||||
editorY = editorY - a.editor.Lines() + 1
|
editorWidth := min(a.width, 80)
|
||||||
layoutView = layout.PlaceOverlay(
|
editorX := (a.width - editorWidth) / 2
|
||||||
|
editorY := a.height - editorHeight - 1 // Position from bottom, accounting for status bar
|
||||||
|
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
editorX,
|
editorX,
|
||||||
editorY,
|
editorY,
|
||||||
a.editor.Content(),
|
a.editor.Content(),
|
||||||
layoutView,
|
mainLayout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.showCompletionDialog {
|
if a.showCompletionDialog {
|
||||||
|
editorWidth := min(a.width, 80)
|
||||||
|
editorX := (a.width - editorWidth) / 2
|
||||||
a.completions.SetWidth(editorWidth)
|
a.completions.SetWidth(editorWidth)
|
||||||
overlay := a.completions.View()
|
overlay := a.completions.View()
|
||||||
layoutView = layout.PlaceOverlay(
|
overlayHeight := lipgloss.Height(overlay)
|
||||||
|
editorY := a.height - editorHeight - 1
|
||||||
|
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
editorX,
|
editorX,
|
||||||
editorY-lipgloss.Height(overlay)+2,
|
editorY-overlayHeight,
|
||||||
overlay,
|
overlay,
|
||||||
layoutView,
|
mainLayout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
components := []string{
|
components := []string{
|
||||||
layoutView,
|
mainLayout,
|
||||||
a.status.View(),
|
a.status.View(),
|
||||||
}
|
}
|
||||||
appView := strings.Join(components, "\n")
|
appView := strings.Join(components, "\n")
|
||||||
|
@ -464,6 +501,7 @@ func (a appModel) View() string {
|
||||||
if theme.CurrentThemeUsesAnsiColors() {
|
if theme.CurrentThemeUsesAnsiColors() {
|
||||||
appView = util.ConvertRGBToAnsi16Colors(appView)
|
appView = util.ConvertRGBToAnsi16Colors(appView)
|
||||||
}
|
}
|
||||||
|
|
||||||
return appView
|
return appView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -653,13 +691,6 @@ func NewModel(app *app.App) tea.Model {
|
||||||
editor := chat.NewEditorComponent(app)
|
editor := chat.NewEditorComponent(app)
|
||||||
completions := dialog.NewCompletionDialogComponent(initialProvider)
|
completions := dialog.NewCompletionDialogComponent(initialProvider)
|
||||||
|
|
||||||
editorContainer := layout.NewContainer(
|
|
||||||
editor,
|
|
||||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
|
||||||
layout.WithAlignCenter(),
|
|
||||||
)
|
|
||||||
messagesContainer := layout.NewContainer(messages)
|
|
||||||
|
|
||||||
var leaderBinding *key.Binding
|
var leaderBinding *key.Binding
|
||||||
if app.Config.Keybinds.Leader != "" {
|
if app.Config.Keybinds.Leader != "" {
|
||||||
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
|
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
|
||||||
|
@ -676,17 +707,8 @@ func NewModel(app *app.App) tea.Model {
|
||||||
leaderBinding: leaderBinding,
|
leaderBinding: leaderBinding,
|
||||||
isLeaderSequence: false,
|
isLeaderSequence: false,
|
||||||
showCompletionDialog: false,
|
showCompletionDialog: false,
|
||||||
editorContainer: editorContainer,
|
|
||||||
toastManager: toast.NewToastManager(),
|
toastManager: toast.NewToastManager(),
|
||||||
interruptKeyState: InterruptKeyIdle,
|
interruptKeyState: InterruptKeyIdle,
|
||||||
layout: layout.NewFlexLayout(
|
|
||||||
[]tea.ViewModel{messagesContainer, editorContainer},
|
|
||||||
layout.WithDirection(layout.FlexDirectionVertical),
|
|
||||||
layout.WithSizes(
|
|
||||||
layout.FlexChildSizeGrow,
|
|
||||||
layout.FlexChildSizeFixed(5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue