| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- 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"
- )
- var (
- // ANSI escape sequence regex
- ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
- )
- // 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 {
- 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
- }
- }
|