#721·lipgloss

Style.Render measures the whole block to apply margins that are zero (applyMargins calls getLines unconditionally)

Author: ZviBaratzCreated Aug 1, 2026Updated Aug 2, 2026

Summary

Style.applyMargins calls getLines(str) outside the topMargin > 0 / bottomMargin > 0 guards, so every non-inline Render pays a full grapheme-cluster width pass over the entire block even when no margins are set — and the result is discarded.

https://github.com/charmbracelet/lipgloss/blob/master/style.go — in v2.0.5 this is style.go:566-577:

go
	// Top/bottom margin
	if !inline {
		_, width := getLines(str)          // <- always runs
		spaces := strings.Repeat(" ", width)

		if topMargin > 0 {
			str = style.Styled(strings.Repeat(spaces+"\n", topMargin)) + str
		}
		if bottomMargin > 0 {
			str += style.Styled(strings.Repeat("\n"+spaces, bottomMargin))
		}
	}

spaces is used only inside those two branches, so when both margins are zero the getLines call is pure waste. getLines runs ansi.StringWidth on every line, which is grapheme-cluster segmentation via uax29 — the most expensive thing in the render path.

Most styles in a TUI set no margins at all, so most Render calls pay this.

Impact

Found while profiling a Bubble Tea app whose frame build turned out to be ~70% ansi.StringWidth. In a CPU profile of one full frame, Style.applyMargins was 11.2% of samples — 13.5% of the frame build — and 94% of that was this one getLines call. The application sets exactly one margin in its entire tree.

Reproducer

go
// A block the size of a TUI pane. Every line carries box-drawing runes, so no
// per-line ASCII fast path in StringWidth can help.
func block() string {
	var b strings.Builder
	for i := range 40 {
		fmt.Fprintf(&b, "│ %02d ╭──────────────╮ some pane content on this line\n", i)
	}
	return strings.TrimRight(b.String(), "\n")
}

var sink string

func BenchmarkRenderNoMargins(b *testing.B) {
	s, blk := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")), block()
	b.ReportAllocs()
	for b.Loop() {
		sink = s.Render(blk)
	}
}

go test -bench . -benchmem -benchtime 2s, lipgloss v2.0.5, Go 1.26, Intel Core Ultra 7 258V — three runs each side:

ns/op B/op allocs/op
v2.0.5 as-is 372,899 / 340,365 / 301,007 39,233 63
with the guard 173,528 / 209,387 / 202,521 38,529 62

Median ~340µs → ~202µs, a 41% cut on this benchmark, with no overlap between the two sets, plus one fewer allocation.

Suggested fix

Hoist the call under the condition that uses it:

go
	// Top/bottom margin
	if !inline && (topMargin > 0 || bottomMargin > 0) {
		_, width := getLines(str)
		spaces := strings.Repeat(" ", width)
		...
	}

getLines is pure, and width has no other consumer in the function, so this is behaviour-preserving. go test ./... in the lipgloss repo passes with the change applied (lipgloss, list, table, tree).

Happy to open a PR if that is useful.

Related, for context

While measuring: alignTextHorizontal calls getLines(str) and then ansi.StringWidth(l) again for each line — so each line is measured twice per alignment pass. That one is not obviously removable without changing getLines' signature to return per-line widths, and I have not benchmarked it, so I am only noting it rather than proposing anything.

Versions: charm.land/lipgloss/v2 v2.0.5, github.com/charmbracelet/x/ansi v0.11.7, github.com/clipperhouse/uax29/v2 v2.7.0, Go 1.26.5, linux/amd64.