Skip to content

Render() pads unstyled trailing cells since #128 — is width fidelity the intended contract? #160

Description

@emmanuel-deloget

(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 EmptyCellEmptyCell 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:

  1. Render() is width-faithful. Then say so in the doc comment, and name uv.TrimSpace as what output destined for a terminal goes through.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions