summaryrefslogtreecommitdiffhomepage
path: root/pkg/tui/layout
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-18 22:30:41 -0400
committerDax Raad <[email protected]>2025-05-26 12:40:17 -0400
commit99af6146d5def31c59993636d60eb75a483a283b (patch)
treeeb57ed227c15cf9be54bc9f231c83895d812f881 /pkg/tui/layout
parent020e0ca039287b73fa33041fbd1bb214e6ccb396 (diff)
downloadopencode-99af6146d5def31c59993636d60eb75a483a283b.tar.gz
opencode-99af6146d5def31c59993636d60eb75a483a283b.zip
openapi
Diffstat (limited to 'pkg/tui/layout')
-rw-r--r--pkg/tui/layout/container.go230
-rw-r--r--pkg/tui/layout/layout.go35
-rw-r--r--pkg/tui/layout/overlay.go169
-rw-r--r--pkg/tui/layout/split.go283
4 files changed, 717 insertions, 0 deletions
diff --git a/pkg/tui/layout/container.go b/pkg/tui/layout/container.go
new file mode 100644
index 000000000..b5bdca20a
--- /dev/null
+++ b/pkg/tui/layout/container.go
@@ -0,0 +1,230 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/tui/theme"
+)
+
+type Container interface {
+ tea.Model
+ Sizeable
+ Bindings
+ Focus()
+ Blur()
+}
+
+type container struct {
+ width int
+ height int
+
+ content tea.Model
+
+ paddingTop int
+ paddingRight int
+ paddingBottom int
+ paddingLeft int
+
+ borderTop bool
+ borderRight bool
+ borderBottom bool
+ borderLeft bool
+ borderStyle lipgloss.Border
+
+ focused bool
+}
+
+func (c *container) Init() tea.Cmd {
+ return c.content.Init()
+}
+
+func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ u, cmd := c.content.Update(msg)
+ c.content = u
+ return c, cmd
+}
+
+func (c *container) View() string {
+ t := theme.CurrentTheme()
+ style := lipgloss.NewStyle()
+ width := c.width
+ height := c.height
+
+ style = style.Background(t.Background())
+
+ // 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.BorderNormal())
+ }
+ }
+ 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
+
+ // 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, width-horizontalSpace)
+ contentHeight := max(0, height-verticalSpace)
+ return sizeable.SetSize(contentWidth, contentHeight)
+ }
+ return nil
+}
+
+func (c *container) GetSize() (int, int) {
+ return c.width, c.height
+}
+
+func (c *container) BindingKeys() []key.Binding {
+ if b, ok := c.content.(Bindings); ok {
+ return b.BindingKeys()
+ }
+ return []key.Binding{}
+}
+
+// Focus sets the container as focused
+func (c *container) Focus() {
+ c.focused = true
+ // Pass focus to content if it supports it
+ if focusable, ok := c.content.(interface{ Focus() }); ok {
+ focusable.Focus()
+ }
+}
+
+// Blur removes focus from the container
+func (c *container) Blur() {
+ c.focused = false
+ // Remove focus from content if it supports it
+ if blurable, ok := c.content.(interface{ Blur() }); ok {
+ blurable.Blur()
+ }
+}
+
+type ContainerOption func(*container)
+
+func NewContainer(content tea.Model, 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())
+}
diff --git a/pkg/tui/layout/layout.go b/pkg/tui/layout/layout.go
new file mode 100644
index 000000000..495a3fbc5
--- /dev/null
+++ b/pkg/tui/layout/layout.go
@@ -0,0 +1,35 @@
+package layout
+
+import (
+ "reflect"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Focusable interface {
+ Focus() tea.Cmd
+ Blur() tea.Cmd
+ IsFocused() bool
+}
+
+type Sizeable interface {
+ SetSize(width, height int) tea.Cmd
+ GetSize() (int, int)
+}
+
+type Bindings interface {
+ BindingKeys() []key.Binding
+}
+
+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
+}
diff --git a/pkg/tui/layout/overlay.go b/pkg/tui/layout/overlay.go
new file mode 100644
index 000000000..64836463d
--- /dev/null
+++ b/pkg/tui/layout/overlay.go
@@ -0,0 +1,169 @@
+package layout
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ chAnsi "github.com/charmbracelet/x/ansi"
+ "github.com/muesli/ansi"
+ "github.com/muesli/reflow/truncate"
+ "github.com/muesli/termenv"
+ "github.com/sst/opencode/internal/tui/styles"
+ "github.com/sst/opencode/internal/tui/theme"
+ "github.com/sst/opencode/internal/tui/util"
+)
+
+// Most of this code is borrowed from
+// https://github.com/charmbracelet/lipgloss/pull/102
+// as well as the lipgloss library, with some modification for what I needed.
+
+// Split a string into lines, additionally returning the size of the widest
+// line.
+func getLines(s string) (lines []string, widest int) {
+ lines = strings.Split(s, "\n")
+
+ for _, l := range lines {
+ w := ansi.PrintableRuneWidth(l)
+ if widest < w {
+ widest = w
+ }
+ }
+
+ return lines, widest
+}
+
+// PlaceOverlay places fg on top of bg.
+func PlaceOverlay(
+ x, y int,
+ fg, bg string,
+ shadow bool, opts ...WhitespaceOption,
+) string {
+ fgLines, fgWidth := getLines(fg)
+ bgLines, bgWidth := getLines(bg)
+ bgHeight := len(bgLines)
+ fgHeight := len(fgLines)
+
+ if shadow {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ var shadowbg string = ""
+ shadowchar := lipgloss.NewStyle().
+ Background(t.BackgroundDarker()).
+ Foreground(t.Background()).
+ Render("░")
+ bgchar := baseStyle.Render(" ")
+ for i := 0; i <= fgHeight; i++ {
+ if i == 0 {
+ shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
+ } else {
+ shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
+ }
+ }
+
+ fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
+ fgLines, fgWidth = getLines(fg)
+ fgHeight = len(fgLines)
+ }
+
+ if fgWidth >= bgWidth && fgHeight >= bgHeight {
+ // FIXME: return fg or bg?
+ return fg
+ }
+ // TODO: allow placement outside of the bg box?
+ x = util.Clamp(x, 0, bgWidth-fgWidth)
+ y = util.Clamp(y, 0, bgHeight-fgHeight)
+
+ ws := &whitespace{}
+ for _, opt := range opts {
+ opt(ws)
+ }
+
+ var b strings.Builder
+ for i, bgLine := range bgLines {
+ if i > 0 {
+ b.WriteByte('\n')
+ }
+ if i < y || i >= y+fgHeight {
+ b.WriteString(bgLine)
+ continue
+ }
+
+ pos := 0
+ if x > 0 {
+ left := truncate.String(bgLine, uint(x))
+ pos = ansi.PrintableRuneWidth(left)
+ b.WriteString(left)
+ if pos < x {
+ b.WriteString(ws.render(x - pos))
+ pos = x
+ }
+ }
+
+ fgLine := fgLines[i-y]
+ b.WriteString(fgLine)
+ pos += ansi.PrintableRuneWidth(fgLine)
+
+ right := cutLeft(bgLine, pos)
+ bgWidth := ansi.PrintableRuneWidth(bgLine)
+ rightWidth := ansi.PrintableRuneWidth(right)
+ if rightWidth <= bgWidth-pos {
+ b.WriteString(ws.render(bgWidth - rightWidth - pos))
+ }
+
+ b.WriteString(right)
+ }
+
+ return b.String()
+}
+
+// cutLeft cuts printable characters from the left.
+// This function is heavily based on muesli's ansi and truncate packages.
+func cutLeft(s string, cutWidth int) string {
+ return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+type whitespace struct {
+ style termenv.Style
+ chars string
+}
+
+// Render whitespaces.
+func (w whitespace) render(width int) string {
+ if w.chars == "" {
+ w.chars = " "
+ }
+
+ r := []rune(w.chars)
+ j := 0
+ b := strings.Builder{}
+
+ // Cycle through runes and print them into the whitespace.
+ for i := 0; i < width; {
+ b.WriteRune(r[j])
+ j++
+ if j >= len(r) {
+ j = 0
+ }
+ i += ansi.PrintableRuneWidth(string(r[j]))
+ }
+
+ // Fill any extra gaps white spaces. This might be necessary if any runes
+ // are more than one cell wide, which could leave a one-rune gap.
+ short := width - ansi.PrintableRuneWidth(b.String())
+ if short > 0 {
+ b.WriteString(strings.Repeat(" ", short))
+ }
+
+ return w.style.Styled(b.String())
+}
+
+// WhitespaceOption sets a styling rule for rendering whitespace.
+type WhitespaceOption func(*whitespace)
diff --git a/pkg/tui/layout/split.go b/pkg/tui/layout/split.go
new file mode 100644
index 000000000..81e159517
--- /dev/null
+++ b/pkg/tui/layout/split.go
@@ -0,0 +1,283 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/tui/theme"
+)
+
+type SplitPaneLayout interface {
+ tea.Model
+ Sizeable
+ Bindings
+ SetLeftPanel(panel Container) tea.Cmd
+ SetRightPanel(panel Container) tea.Cmd
+ SetBottomPanel(panel Container) tea.Cmd
+
+ ClearLeftPanel() tea.Cmd
+ ClearRightPanel() tea.Cmd
+ ClearBottomPanel() tea.Cmd
+}
+
+type splitPaneLayout struct {
+ width int
+ height int
+ ratio float64
+ verticalRatio float64
+
+ rightPanel Container
+ leftPanel Container
+ bottomPanel Container
+}
+
+type SplitPaneOption func(*splitPaneLayout)
+
+func (s *splitPaneLayout) Init() tea.Cmd {
+ var cmds []tea.Cmd
+
+ if s.leftPanel != nil {
+ cmds = append(cmds, s.leftPanel.Init())
+ }
+
+ if s.rightPanel != nil {
+ cmds = append(cmds, s.rightPanel.Init())
+ }
+
+ if s.bottomPanel != nil {
+ cmds = append(cmds, s.bottomPanel.Init())
+ }
+
+ return tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ return s, s.SetSize(msg.Width, msg.Height)
+ }
+
+ if s.rightPanel != nil {
+ u, cmd := s.rightPanel.Update(msg)
+ s.rightPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ if s.leftPanel != nil {
+ u, cmd := s.leftPanel.Update(msg)
+ s.leftPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ if s.bottomPanel != nil {
+ u, cmd := s.bottomPanel.Update(msg)
+ s.bottomPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ return s, tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) View() string {
+ var topSection string
+
+ if s.leftPanel != nil && s.rightPanel != nil {
+ leftView := s.leftPanel.View()
+ rightView := s.rightPanel.View()
+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
+ } else if s.leftPanel != nil {
+ topSection = s.leftPanel.View()
+ } else if s.rightPanel != nil {
+ topSection = s.rightPanel.View()
+ } else {
+ topSection = ""
+ }
+
+ var finalView string
+
+ if s.bottomPanel != nil && topSection != "" {
+ bottomView := s.bottomPanel.View()
+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
+ } else if s.bottomPanel != nil {
+ finalView = s.bottomPanel.View()
+ } else {
+ finalView = topSection
+ }
+
+ if finalView != "" {
+ t := theme.CurrentTheme()
+
+ style := lipgloss.NewStyle().
+ Width(s.width).
+ Height(s.height).
+ Background(t.Background())
+
+ return style.Render(finalView)
+ }
+
+ return finalView
+}
+
+func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
+ s.width = width
+ s.height = height
+
+ var topHeight, bottomHeight int
+ if s.bottomPanel != nil {
+ topHeight = int(float64(height) * s.verticalRatio)
+ bottomHeight = height - topHeight
+ } else {
+ topHeight = height
+ bottomHeight = 0
+ }
+
+ var leftWidth, rightWidth int
+ if s.leftPanel != nil && s.rightPanel != nil {
+ leftWidth = int(float64(width) * s.ratio)
+ rightWidth = width - leftWidth
+ } else if s.leftPanel != nil {
+ leftWidth = width
+ rightWidth = 0
+ } else if s.rightPanel != nil {
+ leftWidth = 0
+ rightWidth = width
+ }
+
+ var cmds []tea.Cmd
+ if s.leftPanel != nil {
+ cmd := s.leftPanel.SetSize(leftWidth, topHeight)
+ cmds = append(cmds, cmd)
+ }
+
+ if s.rightPanel != nil {
+ cmd := s.rightPanel.SetSize(rightWidth, topHeight)
+ cmds = append(cmds, cmd)
+ }
+
+ if s.bottomPanel != nil {
+ cmd := s.bottomPanel.SetSize(width, bottomHeight)
+ cmds = append(cmds, cmd)
+ }
+ return tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) GetSize() (int, int) {
+ return s.width, s.height
+}
+
+func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
+ s.leftPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
+ s.rightPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
+ s.bottomPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
+ s.leftPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
+ s.rightPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
+ s.bottomPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) BindingKeys() []key.Binding {
+ keys := []key.Binding{}
+ if s.leftPanel != nil {
+ if b, ok := s.leftPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ if s.rightPanel != nil {
+ if b, ok := s.rightPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ if s.bottomPanel != nil {
+ if b, ok := s.bottomPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ return keys
+}
+
+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
+
+ layout := &splitPaneLayout{
+ ratio: 0.7,
+ verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
+ }
+ for _, option := range options {
+ option(layout)
+ }
+ return layout
+}
+
+func WithLeftPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.leftPanel = panel
+ }
+}
+
+func WithRightPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.rightPanel = panel
+ }
+}
+
+func WithRatio(ratio float64) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.ratio = ratio
+ }
+}
+
+func WithBottomPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.bottomPanel = panel
+ }
+}
+
+func WithVerticalRatio(ratio float64) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.verticalRatio = ratio
+ }
+}