summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/layout
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-30 20:47:56 -0400
committerDax Raad <[email protected]>2025-05-30 20:48:36 -0400
commitf3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch)
treea24317a19e1ab2a89da50db669dc6894f15d00d1 /internal/tui/layout
parent9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff)
downloadopencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz
opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip
sync
Diffstat (limited to 'internal/tui/layout')
-rw-r--r--internal/tui/layout/container.go230
-rw-r--r--internal/tui/layout/layout.go35
-rw-r--r--internal/tui/layout/overlay.go169
-rw-r--r--internal/tui/layout/split.go283
4 files changed, 0 insertions, 717 deletions
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
deleted file mode 100644
index b5bdca20a..000000000
--- a/internal/tui/layout/container.go
+++ /dev/null
@@ -1,230 +0,0 @@
-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/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
deleted file mode 100644
index 495a3fbc5..000000000
--- a/internal/tui/layout/layout.go
+++ /dev/null
@@ -1,35 +0,0 @@
-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/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go
deleted file mode 100644
index 64836463d..000000000
--- a/internal/tui/layout/overlay.go
+++ /dev/null
@@ -1,169 +0,0 @@
-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/internal/tui/layout/split.go b/internal/tui/layout/split.go
deleted file mode 100644
index 81e159517..000000000
--- a/internal/tui/layout/split.go
+++ /dev/null
@@ -1,283 +0,0 @@
-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
- }
-}