(this issue has been raised by me, and redacted and filled with more details using Claude Code ; the 'reproduction' snipped has been verified by hand)
Since #128 (d9e819d, fix(render): flush trailing empty cells in renderLine), Render() pads every line out to its full width instead of dropping the trailing blanks. String() still trims. I'd like to ask what the intended contract is, because right now the two sibling methods disagree and only one of them says anything about it.
I'm not asking for a revert — I've adapted downstream either way. I'm asking for the guarantee to be written down, because as things stand a consumer can't tell which behaviour is a promise and which is an accident.
Reproduction
package main
import (
"fmt"
"image/color"
uv "github.com/charmbracelet/ultraviolet"
)
func main() {
plain := uv.NewLine(10)
plain[0] = uv.Cell{Content: "H", Width: 1}
plain[1] = uv.Cell{Content: "i", Width: 1}
red := uv.Style{Bg: color.RGBA{R: 255, A: 255}}
styled := uv.NewLine(10)
styled[0] = uv.Cell{Content: "H", Width: 1}
styled[1] = uv.Cell{Content: "i", Width: 1}
for i := 2; i < 10; i++ {
styled[i] = uv.Cell{Content: " ", Width: 1, Style: red}
}
fmt.Printf("plain Render() = %q\n", plain.Render())
fmt.Printf("plain String() = %q\n", plain.String())
fmt.Printf("styled Render() = %q\n", styled.Render())
}
Before the change (0b88c25):
plain Render() = "Hi"
plain String() = "Hi"
styled Render() = "Hi\x1b[48;2;255;0;0m \x1b[m"
After (68fa937, current tip):
plain Render() = "Hi "
plain String() = "Hi"
styled Render() = "Hi\x1b[48;2;255;0;0m \x1b[m"
The contract, as documented
// String returns the string representation of the line. Any trailing spaces
// are removed.
func (l Line) String() string
// Render renders the line to a string with all the required attributes and
// styles.
func (l Line) Render() string
String promises to trim. Render says nothing about width or trailing cells. Before #128 both trimmed; now they differ, and the doc comment on the one that changed is unchanged. The asymmetry also reaches x/vt, where Emulator.String() applies uv.TrimSpace and Emulator.Render() is a bare passthrough of buf.Render().
Worth noting for context: the PR is 72 insertions and 0 deletions. No existing test encoded the previous behaviour, so nothing in the suite could flag the change — which is consistent with there being no contract to break, only a usage.
The case the PR was written for already worked
The PR describes the motivation as "background colors on otherwise empty cells were missing." As far as I can measure, that case was never affected. A styled blank is not EmptyCell — EmptyCell is Cell{Content: " ", Width: 1} with a zero style, and c.Equal(&EmptyCell) compares Style — so a coloured blank never entered the pending buffer and was already emitted. The third line of the reproduction above is identical either side of the change.
What the diff changes is only the unstyled trailing run.
Why it matters downstream
Two plausible uses of Render() want opposite things:
- Width fidelity — layout, copy/paste, width arithmetic, screen-change detection. This is what the PR argues for, and it's a reasonable thing to want.
- Printing to a terminal.
Lines.Render() joins rows with a bare \n and emits no absolute positioning, so it is text meant for the current cursor position, not a self-contained repaint. Padding to the exact width means the last row now ends in the final column with the pending-wrap flag armed — the one cursor state a TUI has to avoid, because the next character written wraps and scrolls the window.
One method can't serve both silently. The package already has TrimSpace for the second case, so "call uv.TrimSpace" is a perfectly good answer — it just needs to be in the doc comment.
For what it's worth, the PR itself raises exactly this and it doesn't seem to have been answered before merge:
Line.String() intentionally trims trailing spaces (documented: "Any trailing
spaces are removed"), but Render() has no such contract. Does that apply
here too? Doesn't seem like it. LMK
The question
Either resolution works for me; I'd just like it decided and documented:
Render() is width-faithful. Then say so in the doc comment, and name uv.TrimSpace as what output destined for a terminal goes through.
Render() trims, like String(). Then the padding moves behind an explicit option for the layout use case.
What this is not
This isn't a conformance problem. Wrapping after the last column is correct, and DECAWM/pending-wrap behaviour is right on both sides — I checked that separately before filing. The breakage I hit was in my own code, which printed a rendering and left the cursor wherever the text ended. This is only about the API contract.
Environment
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be, compared against v0.0.0-20260303162955-0b88c25f3fff
- go1.25, linux/amd64
- Found via
github.com/charmbracelet/x/vt, in a terminal multiplexer that prints Emulator.Render() to prime a client
(this issue has been raised by me, and redacted and filled with more details using Claude Code ; the 'reproduction' snipped has been verified by hand)
Since #128 (
d9e819d, fix(render): flush trailing empty cells in renderLine),Render()pads every line out to its full width instead of dropping the trailing blanks.String()still trims. I'd like to ask what the intended contract is, because right now the two sibling methods disagree and only one of them says anything about it.I'm not asking for a revert — I've adapted downstream either way. I'm asking for the guarantee to be written down, because as things stand a consumer can't tell which behaviour is a promise and which is an accident.
Reproduction
Before the change (
0b88c25):After (
68fa937, current tip):The contract, as documented
Stringpromises to trim.Rendersays nothing about width or trailing cells. Before #128 both trimmed; now they differ, and the doc comment on the one that changed is unchanged. The asymmetry also reachesx/vt, whereEmulator.String()appliesuv.TrimSpaceandEmulator.Render()is a bare passthrough ofbuf.Render().Worth noting for context: the PR is 72 insertions and 0 deletions. No existing test encoded the previous behaviour, so nothing in the suite could flag the change — which is consistent with there being no contract to break, only a usage.
The case the PR was written for already worked
The PR describes the motivation as "background colors on otherwise empty cells were missing." As far as I can measure, that case was never affected. A styled blank is not
EmptyCell—EmptyCellisCell{Content: " ", Width: 1}with a zero style, andc.Equal(&EmptyCell)comparesStyle— so a coloured blank never entered thependingbuffer and was already emitted. The third line of the reproduction above is identical either side of the change.What the diff changes is only the unstyled trailing run.
Why it matters downstream
Two plausible uses of
Render()want opposite things:Lines.Render()joins rows with a bare\nand emits no absolute positioning, so it is text meant for the current cursor position, not a self-contained repaint. Padding to the exact width means the last row now ends in the final column with the pending-wrap flag armed — the one cursor state a TUI has to avoid, because the next character written wraps and scrolls the window.One method can't serve both silently. The package already has
TrimSpacefor the second case, so "calluv.TrimSpace" is a perfectly good answer — it just needs to be in the doc comment.For what it's worth, the PR itself raises exactly this and it doesn't seem to have been answered before merge:
The question
Either resolution works for me; I'd just like it decided and documented:
Render()is width-faithful. Then say so in the doc comment, and nameuv.TrimSpaceas what output destined for a terminal goes through.Render()trims, likeString(). Then the padding moves behind an explicit option for the layout use case.What this is not
This isn't a conformance problem. Wrapping after the last column is correct, and
DECAWM/pending-wrap behaviour is right on both sides — I checked that separately before filing. The breakage I hit was in my own code, which printed a rendering and left the cursor wherever the text ended. This is only about the API contract.Environment
github.com/charmbracelet/ultravioletv0.0.0-20260812204455-68fa937c71be, compared againstv0.0.0-20260303162955-0b88c25f3fffgithub.com/charmbracelet/x/vt, in a terminal multiplexer that printsEmulator.Render()to prime a client