|
|
@@ -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
|
|
|
+ }
|
|
|
+}
|