summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-19 11:41:23 -0500
committeradamdottv <[email protected]>2025-06-19 11:41:30 -0500
commit4cdc86612cc100afa8775432108c6a48a374d991 (patch)
treeffd93afbefc1d6511e83caf7879bbb5f1fda91f9
parentf1f3f8d12c2cbf2e8f96e7b9d99cdc196e5a78a9 (diff)
downloadopencode-4cdc86612cc100afa8775432108c6a48a374d991.tar.gz
opencode-4cdc86612cc100afa8775432108c6a48a374d991.zip
fix(tui): overlay border backgrounds
-rw-r--r--packages/tui/internal/components/chat/editor.go3
-rw-r--r--packages/tui/internal/components/dialog/complete.go8
-rw-r--r--packages/tui/internal/components/modal/modal.go15
-rw-r--r--packages/tui/internal/components/toast/toast.go43
-rw-r--r--packages/tui/internal/layout/overlay.go263
5 files changed, 272 insertions, 60 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 7cd16085d..7fb034b19 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -110,9 +110,6 @@ func (m *editorComponent) Content() string {
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
- Border(lipgloss.ThickBorder(), false, true).
- BorderForeground(t.BackgroundElement()).
- BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ")
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index ba99e7f04..e73afed01 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
@@ -203,13 +202,6 @@ func (c *completionDialogComponent) View() string {
return baseStyle.Padding(0, 0).
Background(t.BackgroundElement()).
- Border(lipgloss.ThickBorder()).
- BorderTop(false).
- BorderBottom(false).
- BorderRight(true).
- BorderLeft(true).
- BorderBackground(t.Background()).
- BorderForeground(t.BackgroundElement()).
Width(c.width).
Render(c.list.View())
}
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
index b2b59a4b2..afb67a959 100644
--- a/packages/tui/internal/components/modal/modal.go
+++ b/packages/tui/internal/components/modal/modal.go
@@ -100,9 +100,9 @@ func (m *Modal) Render(contentView string, background string) string {
if m.title != "" {
titleStyle := baseStyle.
Foreground(t.Primary()).
- Bold(true)
+ Bold(true).
+ Padding(0, 1)
- // titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escText := escStyle.Render("esc")
@@ -123,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
- PaddingRight(2).
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderLeftForeground(t.BackgroundSubtle()).
- BorderLeftBackground(t.Background()).
- BorderRightForeground(t.BackgroundSubtle()).
- BorderRightBackground(t.Background())
+ PaddingRight(2)
modalView := modalStyle.
Width(outerWidth).
@@ -150,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
row,
modalView,
background,
+ layout.WithOverlayBorder(),
+ layout.WithOverlayBorderColor(t.Primary()),
)
}
diff --git a/packages/tui/internal/components/toast/toast.go b/packages/tui/internal/components/toast/toast.go
index 6612b8e52..09b3c6288 100644
--- a/packages/tui/internal/components/toast/toast.go
+++ b/packages/tui/internal/components/toast/toast.go
@@ -93,12 +93,7 @@ func (tm *ToastManager) renderSingleToast(toast Toast) string {
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
- Padding(1, 2).
- BorderStyle(lipgloss.ThickBorder()).
- BorderBackground(t.Background()).
- BorderForeground(toast.Color).
- BorderLeft(true).
- BorderRight(true)
+ Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
contentMaxWidth := max(maxWidth-6, 20)
@@ -137,9 +132,7 @@ func (tm *ToastManager) View() string {
toastViews = append(toastViews, toastView+"\n")
}
- t := theme.CurrentTheme()
- content := lipgloss.JoinVertical(lipgloss.Right, toastViews...)
- return lipgloss.NewStyle().Background(t.Background()).Render(content)
+ return strings.Join(toastViews, "\n")
}
// RenderOverlay renders the toasts as an overlay on the given background
@@ -151,38 +144,40 @@ func (tm *ToastManager) RenderOverlay(background string) string {
bgWidth := lipgloss.Width(background)
bgHeight := lipgloss.Height(background)
result := background
-
+
// Start from top with 2 character padding
currentY := 2
-
+
// Render each toast individually
for _, toast := range tm.toasts {
// Render individual toast
toastView := tm.renderSingleToast(toast)
toastWidth := lipgloss.Width(toastView)
toastHeight := lipgloss.Height(toastView)
-
+
// Position at top-right with 2 character padding from right edge
- x := bgWidth - toastWidth - 2
-
- // Ensure we don't go negative
- if x < 0 {
- x = 0
- }
-
+ x := max(bgWidth-toastWidth-4, 0)
+
// Check if toast fits vertically
- if currentY + toastHeight > bgHeight - 2 {
+ if currentY+toastHeight > bgHeight-2 {
// No more room for toasts
break
}
-
+
// Place this toast
- result = layout.PlaceOverlay(x, currentY, toastView, result)
-
+ result = layout.PlaceOverlay(
+ x,
+ currentY,
+ toastView,
+ result,
+ layout.WithOverlayBorder(),
+ layout.WithOverlayBorderColor(toast.Color),
+ )
+
// Move down for next toast (add 1 for spacing between toasts)
currentY += toastHeight + 1
}
-
+
return result
}
diff --git a/packages/tui/internal/layout/overlay.go b/packages/tui/internal/layout/overlay.go
index f3d302a99..c617da4cb 100644
--- a/packages/tui/internal/layout/overlay.go
+++ b/packages/tui/internal/layout/overlay.go
@@ -1,9 +1,13 @@
package layout
import (
+ "fmt"
+ "regexp"
"strings"
+ "unicode/utf8"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/compat"
chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
return lines, widest
}
+// overlayOptions holds configuration for overlay rendering
+type overlayOptions struct {
+ whitespace *whitespace
+ border bool
+ borderColor *compat.AdaptiveColor
+}
+
+// OverlayOption sets options for overlay rendering
+type OverlayOption func(*overlayOptions)
+
// PlaceOverlay places fg on top of bg.
func PlaceOverlay(
x, y int,
fg, bg string,
- opts ...WhitespaceOption,
+ opts ...OverlayOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
- if fgWidth >= bgWidth && fgHeight >= bgHeight {
- // FIXME: return fg or bg?
- return fg
+ // Parse options
+ options := &overlayOptions{
+ whitespace: &whitespace{},
+ }
+ for _, opt := range opts {
+ opt(options)
}
- // TODO: allow placement outside of the bg box?
- x = util.Clamp(x, 0, bgWidth-fgWidth)
- y = util.Clamp(y, 0, bgHeight-fgHeight)
+ // Adjust for borders if enabled
+ if options.border {
+ // Add space for left and right borders
+ adjustedFgWidth := fgWidth + 2
+ // Adjust placement to account for borders
+ x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
+ y = util.Clamp(y, 0, bgHeight-fgHeight)
- ws := &whitespace{}
- for _, opt := range opts {
- opt(ws)
+ // Pad all foreground lines to the same width for consistent borders
+ for i := range fgLines {
+ lineWidth := ansi.PrintableRuneWidth(fgLines[i])
+ if lineWidth < fgWidth {
+ fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
+ }
+ }
+ } else {
+ 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)
}
var b strings.Builder
@@ -59,25 +92,62 @@ func PlaceOverlay(
}
pos := 0
+
+ // Handle left side of the line up to the overlay
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))
+ b.WriteString(options.whitespace.render(x - pos))
pos = x
}
}
- fgLine := fgLines[i-y]
- b.WriteString(fgLine)
- pos += ansi.PrintableRuneWidth(fgLine)
+ // Render the overlay content with optional borders
+ if options.border {
+ // Get the foreground line
+ fgLine := fgLines[i-y]
+ fgLineWidth := ansi.PrintableRuneWidth(fgLine)
+
+ // Extract the styles at the border positions
+ leftStyle := getStyleAtPosition(bgLine, pos)
+ rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
+
+ // Left border - combine background from original with border foreground
+ leftSeq := combineStyles(leftStyle, options.borderColor)
+ if leftSeq != "" {
+ b.WriteString(leftSeq)
+ }
+ b.WriteString("┃")
+ b.WriteString("\x1b[0m") // Reset all styles
+ pos++
+
+ // Content
+ b.WriteString(fgLine)
+ pos += fgLineWidth
+ // Right border - combine background from original with border foreground
+ rightSeq := combineStyles(rightStyle, options.borderColor)
+ if rightSeq != "" {
+ b.WriteString(rightSeq)
+ }
+ b.WriteString("┃")
+ b.WriteString("\x1b[0m") // Reset all styles
+ pos++
+ } else {
+ // No border, just render the content
+ fgLine := fgLines[i-y]
+ b.WriteString(fgLine)
+ pos += ansi.PrintableRuneWidth(fgLine)
+ }
+
+ // Handle right side of the line after the overlay
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(options.whitespace.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
}
+// ansiStyle represents parsed ANSI style attributes
+type ansiStyle struct {
+ fgColor string
+ bgColor string
+ attrs []string
+}
+
+// parseANSISequence parses an ANSI escape sequence into its components
+func parseANSISequence(seq string) ansiStyle {
+ style := ansiStyle{}
+
+ // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
+ if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
+ return style
+ }
+
+ params := seq[2 : len(seq)-1]
+ if params == "" {
+ return style
+ }
+
+ parts := strings.Split(params, ";")
+ i := 0
+ for i < len(parts) {
+ switch parts[i] {
+ case "0": // Reset
+ style = ansiStyle{}
+ case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
+ style.attrs = append(style.attrs, parts[i])
+ case "38": // Foreground color
+ if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
+ // 256 color mode
+ style.fgColor = strings.Join(parts[i:i+3], ";")
+ i += 2
+ } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
+ // RGB color mode
+ style.fgColor = strings.Join(parts[i:i+5], ";")
+ i += 4
+ }
+ case "48": // Background color
+ if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
+ // 256 color mode
+ style.bgColor = strings.Join(parts[i:i+3], ";")
+ i += 2
+ } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
+ // RGB color mode
+ style.bgColor = strings.Join(parts[i:i+5], ";")
+ i += 4
+ }
+ case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
+ style.fgColor = parts[i]
+ case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
+ style.bgColor = parts[i]
+ case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
+ style.fgColor = parts[i]
+ case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
+ style.bgColor = parts[i]
+ }
+ i++
+ }
+
+ return style
+}
+
+// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
+func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
+ if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
+ return ""
+ }
+
+ var parts []string
+
+ // Add attributes
+ parts = append(parts, bgStyle.attrs...)
+
+ // Add background color from the original style
+ if bgStyle.bgColor != "" {
+ parts = append(parts, bgStyle.bgColor)
+ }
+
+ // Add foreground color if specified
+ if fgColor != nil {
+ // Use the light color (could be improved to detect terminal background)
+ color := (*fgColor).Light
+
+ // Use RGBA to get color components
+ r, g, b, _ := color.RGBA()
+ // RGBA returns 16-bit values, we need 8-bit
+ parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
+ }
+
+ if len(parts) == 0 {
+ return ""
+ }
+
+ return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
+}
+
+// getStyleAtPosition extracts the active ANSI style at a given visual position
+func getStyleAtPosition(s string, targetPos int) ansiStyle {
+ // ANSI escape sequence regex
+ ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+ visualPos := 0
+ currentStyle := ansiStyle{}
+
+ i := 0
+ for i < len(s) && visualPos <= targetPos {
+ // Check if we're at an ANSI escape sequence
+ if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
+ // Found an ANSI sequence at current position
+ seq := s[i : i+match[1]]
+ parsedStyle := parseANSISequence(seq)
+
+ // Update current style (merge with existing)
+ if parsedStyle.fgColor != "" {
+ currentStyle.fgColor = parsedStyle.fgColor
+ }
+ if parsedStyle.bgColor != "" {
+ currentStyle.bgColor = parsedStyle.bgColor
+ }
+ if len(parsedStyle.attrs) > 0 {
+ currentStyle.attrs = parsedStyle.attrs
+ }
+
+ i += match[1]
+ } else if i < len(s) {
+ // Regular character
+ if visualPos == targetPos {
+ return currentStyle
+ }
+ _, size := utf8.DecodeRuneInString(s[i:])
+ i += size
+ visualPos++
+ }
+ }
+
+ return currentStyle
+}
+
type whitespace struct {
style termenv.Style
chars string
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)
+
+// WithWhitespace sets whitespace options for the overlay
+func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
+ return func(o *overlayOptions) {
+ for _, opt := range opts {
+ opt(o.whitespace)
+ }
+ }
+}
+
+// WithOverlayBorder enables border rendering for the overlay
+func WithOverlayBorder() OverlayOption {
+ return func(o *overlayOptions) {
+ o.border = true
+ }
+}
+
+// WithOverlayBorderColor sets the border color for the overlay
+func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
+ return func(o *overlayOptions) {
+ o.borderColor = &color
+ }
+}