Style.Render measures the whole block to apply margins that are zero (applyMargins calls getLines unconditionally)
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:
// 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
// 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:
// 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.
Source: charmbracelet/lipgloss