Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions internal/render/tui/overlay/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func PlaceOverlayWithPositionAndPadding(
y = 0
case OverlayPositionBottomRight:
x = fullWidth - fgWidth - horizontalPadding
y = (fullHeight)
y = fullHeight - fgHeight - verticalPadding
default:
panic("Unknown overlay position")
}
Expand Down Expand Up @@ -164,8 +164,9 @@ func cutLeft(s string, cutWidth int) string {
b bytes.Buffer
)
for _, c := range s {
var w int
if c == ansi.Marker || isAnsi {
inAnsiSequence := c == ansi.Marker || isAnsi
var runeWidth int
if inAnsiSequence {
isAnsi = true
ab.WriteRune(c)
if ansi.IsTerminator(c) {
Expand All @@ -175,22 +176,24 @@ func cutLeft(s string, cutWidth int) string {
}
}
} else {
w = runewidth.RuneWidth(c)
runeWidth = runewidth.RuneWidth(c)
}

if pos >= cutWidth {
if b.Len() == 0 {
initializingOutput := b.Len() == 0
if initializingOutput {
if pos-cutWidth > 0 {
b.WriteByte(' ')
}
if ab.Len() > 0 {
b.Write(ab.Bytes())
}
if pos-cutWidth > 1 {
b.WriteByte(' ')
continue
}
}
b.WriteRune(c)
if !initializingOutput || !inAnsiSequence {
b.WriteRune(c)
}
}
pos += w
pos += runeWidth
}
return b.String()
}
Expand All @@ -206,27 +209,44 @@ type whitespace struct {

// Render whitespaces.
func (w whitespace) render(width int) string {
if width <= 0 {
return ""
}

if w.chars == "" {
w.chars = " "
}

r := []rune(w.chars)
if ansi.PrintableRuneWidth(w.chars) == 0 {
return w.style.Styled(strings.Repeat(" ", width))
}

j := 0
b := strings.Builder{}
printedWidth := 0

// Keep combining marks after the last visible character.
for {
runeWidth := runewidth.RuneWidth(r[j])
if runeWidth > 0 && printedWidth+runeWidth > width {
break
}

// Cycle through runes and print them into the whitespace.
for i := 0; i < width; {
b.WriteRune(r[j])
printedWidth += runeWidth
j++
if j >= len(r) {
j = 0
if printedWidth >= width {
break
}
}
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())
short := width - printedWidth
if short > 0 {
b.WriteString(strings.Repeat(" ", short))
}
Expand Down
128 changes: 128 additions & 0 deletions internal/render/tui/overlay/overlay_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package overlay

import "testing"

func TestPlaceOverlayWithPositionAndPaddingHonorsBottomPadding(t *testing.T) {
got := PlaceOverlayWithPositionAndPadding(
OverlayPositionBottomRight,
5,
3,
1,
1,
"X",
".....\n.....\n.....",
false,
)
want := ".....\n...X.\n....."

if got != want {
t.Fatalf("PlaceOverlayWithPositionAndPadding() = %q, want %q", got, want)
}
}

func TestCutLeftDoesNotSplitWideRune(t *testing.T) {
if got, want := cutLeft("界ab", 1), " ab"; got != want {
t.Fatalf("cutLeft() = %q, want %q", got, want)
}
}

func TestCutLeftPreservesANSISequences(t *testing.T) {
tests := []struct {
name string
input string
cutWidth int
want string
}{
{
name: "sequence starts at cut",
input: "ab\x1b[31mcd\x1b[0m",
cutWidth: 2,
want: "\x1b[31mcd\x1b[0m",
},
{
name: "cut splits wide rune before sequence",
input: "界\x1b[31mab\x1b[0m",
cutWidth: 1,
want: " \x1b[31mab\x1b[0m",
},
{
name: "style active at cut",
input: "\x1b[31mabcdef\x1b[0m",
cutWidth: 2,
want: "\x1b[31mcdef\x1b[0m",
},
{
name: "reset before cut",
input: "\x1b[31mab\x1b[0mcd",
cutWidth: 3,
want: "d",
},
{
name: "zero cut",
input: "\x1b[31mab\x1b[0m",
cutWidth: 0,
want: "\x1b[31mab\x1b[0m",
},
{
name: "cut beyond end",
input: "\x1b[31mab\x1b[0m",
cutWidth: 3,
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cutLeft(tt.input, tt.cutWidth); got != tt.want {
t.Fatalf("cutLeft(%q, %d) = %q, want %q", tt.input, tt.cutWidth, got, tt.want)
}
})
}
}

func TestWhitespaceRenderHandlesRuneWidths(t *testing.T) {
tests := []struct {
name string
chars string
width int
want string
}{
{name: "combining pattern width one", chars: "e\u0301", width: 1, want: "e\u0301"},
{name: "combining pattern width two", chars: "e\u0301", width: 2, want: "e\u0301e\u0301"},
{name: "combining mark inside pattern", chars: "a\u0301b", width: 4, want: "a\u0301ba\u0301b"},
{name: "combining mark only", chars: "\u0301", width: 2, want: " "},
{name: "zero width space only", chars: "\u200b", width: 2, want: " "},
{name: "zero width", chars: "e\u0301", width: 0, want: ""},
{name: "negative width", chars: "e\u0301", width: -1, want: ""},
{name: "wide pattern narrower target", chars: "界", width: 1, want: " "},
{name: "wide pattern with remainder", chars: "界", width: 3, want: "界 "},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (whitespace{chars: tt.chars}).render(tt.width); got != tt.want {
t.Fatalf("render(%d) = %q, want %q", tt.width, got, tt.want)
}
})
}
}

func TestPlaceOverlayUsesWhitespacePatternCellWidths(t *testing.T) {
tests := []struct {
name string
chars string
want string
}{
{name: "combining pattern", chars: "e\u0301", want: "ae\u0301X\n...."},
{name: "wide pattern", chars: "界", want: "a X\n...."},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PlaceOverlay(2, 0, "X", "a\n....", false, WithWhitespaceChars(tt.chars))
if got != tt.want {
t.Fatalf("PlaceOverlay() = %q, want %q", got, tt.want)
}
})
}
}
Loading