mirror of
https://github.com/sst/opencode.git
synced 2025-07-10 01:24:59 +00:00
380 lines
9.9 KiB
Go
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
|
|
}
|
|
}
|