Skip to content

Instantly share code, notes, and snippets.

@Broderick-Westrope
Last active November 3, 2024 22:29
Show Gist options
  • Save Broderick-Westrope/b89b14770c09dda928c4a108f437b927 to your computer and use it in GitHub Desktop.
Save Broderick-Westrope/b89b14770c09dda928c4a108f437b927 to your computer and use it in GitHub Desktop.
Golang Lipgloss Window Overlay
module test
go 1.23.0
require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/charmbracelet/x/ansi v0.4.2
github.com/stretchr/testify v1.9.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package main
import (
"fmt"
"regexp"
"strings"
"unicode"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
// At its core this allows overlaying a string on top of another.
// My use case is for building TUI applications using "github.com/charmbracelet/bubbletea" wherein I like to have modal windows presented on top of the main window.
// This code was derived from the following source, but has the following changes:
// - Wrapping is not done for you. Instead, wrapping of the background and overlay strings must be done beforehand.
// - A helper function is included for overlaying at the center of the background string.
// - A boolean `ignoreMarginWhitespace`. When false, margin whitespace in the overlay string will overwrite the background string.
// When true, margin whitespace in the overlay string will be ignored such that these cells of the background string are preserved. See the tests for examples.
//
// CREDIT: https://gist.github.com/ras0q/9bf5d81544b22302393f61206892e2cd
// OverlayCenter writes the overlay string onto the background string such that the middle of the
// overlay string will be at the middle of the overlay will be at the middle of the background.
func OverlayCenter(bg string, overlay string, ignoreMarginWhitespace bool) (string, error) {
row := lipgloss.Height(bg) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(bg) / 2
col -= lipgloss.Width(overlay) / 2
return Overlay(bg, overlay, row, col, ignoreMarginWhitespace)
}
// Overlay writes the overlay string onto the background string at the specified row and column.
// In this case, the row and column are zero indexed.
func Overlay(bg, overlay string, row, col int, ignoreMarginWhitespace bool) (string, error) {
bgLines := strings.Split(bg, "\n")
overlayLines := strings.Split(overlay, "\n")
for i, overlayLine := range overlayLines {
targetRow := i + row
// Ensure the target row exists in the background lines
for len(bgLines) <= targetRow {
bgLines = append(bgLines, "")
}
bgLine := bgLines[targetRow]
bgLineWidth := ansi.StringWidth(bgLine)
if bgLineWidth < col {
bgLine += strings.Repeat(" ", col-bgLineWidth) // Add padding
}
// Handle ignoreMarginWhitespace
if ignoreMarginWhitespace {
// Process the overlay line to preserve leading and trailing whitespace
overlayLine = removeMarginWhitespace(bgLine, overlayLine, col)
}
bgLeft := ansi.Truncate(bgLine, col, "")
bgRight, err := truncateLeft(bgLine, col+ansi.StringWidth(overlayLine))
if err != nil {
return "", fmt.Errorf("failed to truncate line: %w", err)
}
bgLines[targetRow] = bgLeft + overlayLine + bgRight
}
return strings.Join(bgLines, "\n"), nil
}
// removeMarginWhitespace preserves the background where the overlay line has leading or trailing whitespace.
// This is done by detecting those empty cells in the overlay string and replacing them with the corresponding background cells.
func removeMarginWhitespace(bgLine, overlayLine string, col int) string {
var result strings.Builder
// Variables to track ANSI escape sequences
inAnsi := false
ansiSeq := strings.Builder{}
// Strip ANSI codes to analyze whitespace
overlayStripped := ansi.Strip(overlayLine)
overlayRunes := []rune(overlayStripped)
// Find first and last non-whitespace positions
firstNonWhitespacePos := -1
lastNonWhitespacePos := -1
visualPos := 0
overlayVisualWidths := make([]int, len(overlayRunes))
for i, r := range overlayRunes {
runeWidth := runewidth.RuneWidth(r)
overlayVisualWidths[i] = runeWidth
if !unicode.IsSpace(r) {
if firstNonWhitespacePos == -1 {
firstNonWhitespacePos = visualPos
}
lastNonWhitespacePos = visualPos + runeWidth - 1 // inclusive
}
visualPos += runeWidth
}
// If all characters are whitespace
if firstNonWhitespacePos == -1 {
firstNonWhitespacePos = 0
lastNonWhitespacePos = -1
}
// Now, process the overlayLine, keeping track of visual positions
visualPos = 0
runeReader := strings.NewReader(overlayLine)
for {
r, _, err := runeReader.ReadRune()
if err != nil {
break
}
if r == '\x1b' {
// Start of ANSI escape sequence
inAnsi = true
ansiSeq.WriteRune(r)
continue
}
if inAnsi {
ansiSeq.WriteRune(r)
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
// End of ANSI escape sequence
inAnsi = false
result.WriteString(ansiSeq.String())
ansiSeq.Reset()
}
continue
}
runeWidth := runewidth.RuneWidth(r)
// Determine if current position is leading whitespace or trailing whitespace
var isLeadingWhitespace, isTrailingWhitespace bool
if visualPos < firstNonWhitespacePos {
isLeadingWhitespace = true
} else if visualPos > lastNonWhitespacePos {
isTrailingWhitespace = true
}
if unicode.IsSpace(r) && (isLeadingWhitespace || isTrailingWhitespace) {
// Preserve background character
for k := 0; k < runeWidth; k++ {
bgChar := getBgCharAt(bgLine, col+visualPos+k)
result.WriteString(bgChar)
}
} else {
// Include character from overlay (could be a non-whitespace or whitespace character in between)
result.WriteRune(r)
}
visualPos += runeWidth
}
return result.String()
}
// getBgCharAt returns the character from the background line at the specified visual index.
func getBgCharAt(bgLine string, visualIndex int) string {
var result strings.Builder
displayWidth := 0
inAnsi := false
ansiSeq := strings.Builder{}
runeReader := strings.NewReader(bgLine)
for {
r, _, err := runeReader.ReadRune()
if err != nil {
break
}
if r == '\x1b' {
// Start of ANSI escape sequence
inAnsi = true
ansiSeq.WriteRune(r)
continue
}
if inAnsi {
ansiSeq.WriteRune(r)
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
// End of ANSI escape sequence
inAnsi = false
result.WriteString(ansiSeq.String())
ansiSeq.Reset()
}
continue
}
charWidth := runewidth.RuneWidth(r)
if displayWidth+charWidth > visualIndex {
// We have reached the desired index
result.WriteRune(r)
break
}
displayWidth += charWidth
}
// If no character found at the position, return a space
if result.Len() == 0 {
return " "
}
return result.String()
}
// truncateLeft removes characters from the beginning of a line, considering ANSI escape codes.
func truncateLeft(line string, padding int) (string, error) {
if strings.Contains(line, "\n") {
return "", fmt.Errorf("line must not contain newline")
}
wrapped := strings.Split(ansi.Hardwrap(line, padding, true), "\n")
if len(wrapped) == 1 {
return "", nil
}
var ansiStyle string
// Regular expression to match ANSI escape codes.
ansiStyles := regexp.MustCompile(`\x1b[[\d;]*m`).FindAllString(wrapped[0], -1)
if len(ansiStyles) > 0 {
ansiStyle = ansiStyles[len(ansiStyles)-1]
}
return ansiStyle + strings.Join(wrapped[1:], ""), nil
}
package main
import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOverlayCenter(t *testing.T) {
tt := map[string]struct {
bg string
overlay string
ignoreMarginWhitespace bool
want string
}{
"simple": {
bg: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sapiente neque. Laborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impedit. Quas nam molestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Sit ipsam aperiam aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Reprehenderit veritatis nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
overlay: "*********\n*****",
ignoreMarginWhitespace: false,
want: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sapiente neque. Laborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impedit*********m molestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Sit i*****aperiam aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Reprehenderit veritatis nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
},
"padded; enforce margins": {
bg: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sapiente neque. Laborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impedit. Quas nam molestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Sit ipsam aperiam aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Reprehenderit veritatis nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
overlay: lipgloss.NewStyle().Padding(1, 3).Render("*********\n*****"),
ignoreMarginWhitespace: false,
want: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sa aborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impe ********* olestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Si ***** aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Repreh is nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
},
"padded; ignore margins": {
bg: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sapiente neque. Laborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impedit. Quas nam molestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Sit ipsam aperiam aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Reprehenderit veritatis nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
overlay: lipgloss.NewStyle().Padding(1, 3).Render("*********\n*****"),
ignoreMarginWhitespace: true,
want: heredoc.Doc(`
Facere enim neque consectetur soluta tenetur ducimus omnis. Voluptatibus accusantium maiores quia eaque velit nesciunt hic saepe tenetur.
Amet quidem reprehenderit ex. Error illum sit est expedita sapiente neque. Laborum vero necessitatibus similique suscipit nam.
Tempore occaecati eligendi accusamus eos similique harum impedit*********m molestiae architecto quam.
Accusamus pariatur facilis ea nostrum exercitationem quam. Sit i*****aperiam aspernatur hic fugit officia inventore.
Reiciendis doloribus ut eius id. Repellendus eum enim. Reprehenderit veritatis nulla molestiae nulla veniam.
Nemo animi nisi blanditiis. Eligendi tempora laudantium assumenda nam.
`),
},
}
for name, tc := range tt {
t.Run(name, func(t *testing.T) {
result, err := OverlayCenter(tc.bg, tc.overlay, tc.ignoreMarginWhitespace)
require.NoError(t, err)
assert.Equal(t, tc.want, result)
})
}
}
func TestOverlay(t *testing.T) {
tt := map[string]struct {
bg string
overlay string
row int
col int
ignoreMarginWhitespace bool
want string
}{
"single line; start": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: "*********",
row: 0,
col: 0,
ignoreMarginWhitespace: false,
want: "*********ibero modi velit neque dolores.",
},
"single line; middle": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: "*********",
row: 0,
col: 10,
ignoreMarginWhitespace: false,
want: "Nostrum li********* velit neque dolores.",
},
"single line; beyond final column": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: "*********",
row: 0,
col: 35,
ignoreMarginWhitespace: false,
want: "Nostrum libero modi velit neque dol*********",
},
"single line; beyond final row": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: "*********",
row: 3,
col: 0,
ignoreMarginWhitespace: false,
want: "Nostrum libero modi velit neque dolores.\n\n\n*********",
},
"single line; lipgloss styled": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("1")).Render("*****"),
row: 0,
col: 5,
ignoreMarginWhitespace: false,
want: "Nostr*****bero modi velit neque dolores.",
},
"single line; manual escape code": {
bg: "Nostrum libero modi velit neque dolores.",
overlay: "\x1b[31m*****\x1b[0m",
row: 0,
col: 5,
ignoreMarginWhitespace: false,
want: "Nostr\u001B[31m*****\u001B[0mbero modi velit neque dolores.",
},
"multi-line background; overlay middle line": {
bg: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
overlay: "*****",
row: 2,
col: 0,
ignoreMarginWhitespace: false,
want: "Line 1\nLine 2\n*****3\nLine 4\nLine 5",
},
"multi-line overlay; beyond background": {
bg: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
overlay: "*******\n*******",
row: 1,
col: 5,
ignoreMarginWhitespace: false,
want: "Line 1\nLine *******\nLine *******\nLine 4\nLine 5",
},
"multi-line overlay; enforce margins": {
bg: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
overlay: lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1).Render("***\n***"),
row: 0,
col: 0,
ignoreMarginWhitespace: false,
want: " 1\n ***2\n ***3\nLine 4\nLine 5",
},
"multi-line overlay; ignore margins": {
bg: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
overlay: lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1).Render("***\n***"),
row: 0,
col: 0,
ignoreMarginWhitespace: true,
want: "Line 1\nLi***2\nLi***3\nLine 4\nLine 5",
},
}
for name, tc := range tt {
t.Run(name, func(t *testing.T) {
result, err := Overlay(tc.bg, tc.overlay, tc.row, tc.col, tc.ignoreMarginWhitespace)
require.NoError(t, err)
assert.Equal(t, tc.want, result)
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment