opencode/packages/tui/internal/layout/overlay.go
2025-06-23 11:21:49 -05:00

380 lines
9.9 KiB
Go

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"
"github.com/muesli/termenv"
"github.com/sst/opencode/internal/util"
)
// 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
}
// 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 ...OverlayOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
// Parse options
options := &overlayOptions{
whitespace: &whitespace{},
}
for _, opt := range opts {
opt(options)
}
// 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)
// 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
for i, bgLine := range bgLines {
if i > 0 {
b.WriteByte('\n')
}
if i < y || i >= y+fgHeight {
b.WriteString(bgLine)
continue
}
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(options.whitespace.render(x - pos))
pos = x
}
}
// 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
// We need to get the style just before the border position to preserve background
leftStyle := ansiStyle{}
if pos > 0 {
leftStyle = getStyleAtPosition(bgLine, pos-1)
} else {
leftStyle = getStyleAtPosition(bgLine, pos)
}
rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
if leftSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
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("┃")
if rightSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
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(options.whitespace.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))
}
// 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
// Mark this as a reset by adding it to attrs
style.attrs = append(style.attrs, "0")
// Don't clear the style here, let the caller handle it
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 adaptive color which automatically selects based on terminal background
// The RGBA method already handles light/dark selection
r, g, b, _ := fgColor.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)
// Check if this is a reset sequence
if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
// Reset all styles
currentStyle = ansiStyle{}
} else {
// 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
}
// 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)
// 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
}
}