diff options
| author | adamdottv <[email protected]> | 2025-06-19 11:41:23 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-06-19 11:41:30 -0500 |
| commit | 4cdc86612cc100afa8775432108c6a48a374d991 (patch) | |
| tree | ffd93afbefc1d6511e83caf7879bbb5f1fda91f9 /packages/tui/internal/layout | |
| parent | f1f3f8d12c2cbf2e8f96e7b9d99cdc196e5a78a9 (diff) | |
| download | opencode-4cdc86612cc100afa8775432108c6a48a374d991.tar.gz opencode-4cdc86612cc100afa8775432108c6a48a374d991.zip | |
fix(tui): overlay border backgrounds
Diffstat (limited to 'packages/tui/internal/layout')
| -rw-r--r-- | packages/tui/internal/layout/overlay.go | 263 |
1 files changed, 248 insertions, 15 deletions
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 + } +} |
