From 87ccf351b1dbdca211aff37a2406921d5e03dcc6 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:26:35 +0530 Subject: [PATCH 1/4] fix(overlay): preserve padding and unicode rendering From 658cb460da726f4d268c6f8c7589c5ad44168f06 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:28:13 +0530 Subject: [PATCH 2/4] fix(overlay): apply padding and unicode rendering fixes From 8c019341b651e0ceaf40ac82f17a5d31d175c990 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:29:44 +0530 Subject: [PATCH 3/4] fix(overlay): apply padding and unicode rendering fixes From 0d58fbd39fdd794b8f378632b4d1f4e0b5c9e559 Mon Sep 17 00:00:00 2001 From: frittlechasm <91785542+frittlechasm@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:30:36 +0530 Subject: [PATCH 4/4] fix(overlay): preserve padding and unicode rendering --- internal/render/tui/overlay/overlay.go | 50 +++++-- .../tui/overlay/overlay_regression_test.go | 128 ++++++++++++++++++ 2 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 internal/render/tui/overlay/overlay_regression_test.go diff --git a/internal/render/tui/overlay/overlay.go b/internal/render/tui/overlay/overlay.go index e07915b..f083eec 100644 --- a/internal/render/tui/overlay/overlay.go +++ b/internal/render/tui/overlay/overlay.go @@ -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") } @@ -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) { @@ -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() } @@ -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)) } diff --git a/internal/render/tui/overlay/overlay_regression_test.go b/internal/render/tui/overlay/overlay_regression_test.go new file mode 100644 index 0000000..b8bee56 --- /dev/null +++ b/internal/render/tui/overlay/overlay_regression_test.go @@ -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) + } + }) + } +}