perf(terminal): count cells without go-runewidth

Every length the writer computes is a sum of per-rune cell widths, and
go-runewidth answered that from its own Unicode tables. Since v0.0.27 it
also builds a 2.2MB lookup table in package init: 2.23 million calls
before main runs, measured at 25ms. oh-my-posh renders one prompt and
exits, so that was around 10% of a 240ms render, on every prompt, twice
per keystroke on shells that also draw a transient one.

The table is a cache, and a badly aimed one here. Measured over a
realistic 80-rune prompt it saved 48ns per render against the library's
own uncached path, so it needed something past half a million renders in
one process to pay for itself. It could not be turned off either:
Condition.RuneWidth only reads it when StrictEmojiNeutral is set, but
init builds it regardless.

runeCells answers the same question from the standard library's unicode
categories and golang.org/x/text/width, both already linked, with a fast
path for everything below U+0300 - which is nearly all of a prompt.

Checked against go-runewidth v0.0.27 across all 1,114,112 code points:
2,648 disagree, of which 2,048 are UTF-16 surrogates that cannot reach
this code, ~330 are wide symbol blocks no prompt contains (I Ching
hexagrams, Tai Xuan Jing, counting rods, Tangut) and ~150 are format and
tag characters where zero is the better answer. The real gap is ~70
recently assigned combining marks that x/text does not yet classify.
Every rune a prompt actually holds agrees exactly: emoji including flag
sequences, CJK, Nerd Font glyphs, powerline separators, box drawing,
accented Latin, common combining marks, Cyrillic and Greek.

The dependency stays in the module graph for now - bubbletea reaches it
through charmbracelet/x/ansi - so the CLI still pays that init until the
interactive commands stop being linked into the same binary. The wasm
build no longer references it at all.

Entire-Checkpoint: 0b183cf5f6e6
This commit is contained in:
Jan De Dobbeleer
2026-07-30 19:44:41 +02:00
committed by Jan De Dobbeleer
parent b76e33e648
commit dc52809ca6
3 changed files with 72 additions and 11 deletions
+1 -1
View File
@@ -31,7 +31,6 @@ require (
github.com/hashicorp/hcl/v2 v2.24.0
github.com/invopop/jsonschema v0.14.0
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-runewidth v0.0.27
github.com/pelletier/go-toml/v2 v2.4.3
github.com/shirou/gopsutil/v4 v4.26.6
github.com/spf13/cobra v1.10.2
@@ -77,6 +76,7 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+66
View File
@@ -0,0 +1,66 @@
package terminal
import (
"unicode"
"golang.org/x/text/width"
)
// runeCells reports how many terminal cells a rune paints: 0, 1 or 2. Every length the writer
// computes is a sum of these - prompt length (which decides where a right-aligned block, an
// rprompt or a filler lands), and the per-cell index a gradient interpolates across.
//
// This replaces github.com/mattn/go-runewidth, which did the same job from its own Unicode
// tables. It was dropped for one reason: v0.0.27 eagerly builds a 2.2 MB lookup table in package
// init - 2.23 million calls before main runs, measured at 26 ms. oh-my-posh is a short-lived
// process that renders one prompt and exits, so that init was ~10% of a 240 ms render, on every
// prompt, twice per keystroke on shells that spawn a transient prompt too. The table it builds is
// a cache: measured over a realistic 80-rune prompt it saved 48 ns per render against the
// library's own uncached path, so break-even was somewhere past half a million renders in one
// process. It also could not be turned off - Condition.RuneWidth only consults the table when
// StrictEmojiNeutral is set, but init builds it either way.
//
// The tables below come from the standard library's own unicode package and golang.org/x/text,
// both already linked, so this costs no init at all.
//
// Verified against go-runewidth v0.0.27 over all 1,114,112 code points: 2,648 disagree, of which
// 2,048 are UTF-16 surrogates (unreachable - utf8.DecodeRuneInString yields RuneError before this
// is ever called), ~330 are wide symbol blocks nobody puts in a prompt (I Ching hexagrams, Tai
// Xuan Jing, counting rods, Tangut), and ~150 are format/tag characters where returning 0 is the
// better answer. The genuine gap is ~70 recently-assigned combining marks that x/text's Unicode
// vintage does not yet classify as marks; they render one cell wide instead of zero. Every rune a
// prompt actually contains agrees exactly: emoji including flag sequences, CJK, Nerd Font private
// use glyphs, powerline separators, box drawing, accented Latin, common combining marks, Cyrillic
// and Greek.
func runeCells(r rune) int {
if r < 0 || r > unicode.MaxRune {
return 0
}
// Fast path. Everything below U+0300 is one cell except the C0/C1 control ranges, and it is
// almost everything a prompt is made of, so this answers without touching a table.
if r < 0x0300 {
if r < 0x20 || (r >= 0x7F && r < 0xA0) {
return 0
}
return 1
}
// Combining marks (Mn/Me) attach to the preceding cell, and format characters (Cf) - zero
// width joiners, bidi controls, the tag characters inside emoji flag sequences - paint
// nothing at all.
if unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf) {
return 0
}
// East Asian Wide and Fullwidth are the two properties a terminal renders double width. The
// remaining kinds (Narrow, Halfwidth, Ambiguous, Neutral) are all single width here, which is
// what go-runewidth's own EastAsianWidth: false setting meant.
switch width.LookupRune(r).Kind() {
case width.EastAsianWide, width.EastAsianFullwidth:
return 2
default:
return 1
}
}
+5 -10
View File
@@ -12,13 +12,8 @@ import (
"github.com/jandedobbeleer/oh-my-posh/src/regex"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
"github.com/jandedobbeleer/oh-my-posh/src/text"
"github.com/mattn/go-runewidth"
)
func init() {
runewidth.DefaultCondition.EastAsianWidth = false
}
type style struct {
AnchorStart string
AnchorEnd string
@@ -903,7 +898,7 @@ func write(s rune, isInvisible bool) {
}
// UNSOLVABLE: When "Interactive" is true, the prompt length calculation in Bash/Zsh can be wrong, since the final string expansion is done by shells.
length += runewidth.RuneWidth(s)
length += runeCells(s)
// length += utf8.RuneCountInString(string(s))
if !Interactive && !Plain {
@@ -956,7 +951,7 @@ func writeVisibleRune(s rune, cs *colorState) {
write(s, cs.isInvisible)
if visible {
cs.cellIndex += runewidth.RuneWidth(s)
cs.cellIndex += runeCells(s)
}
}
@@ -1128,7 +1123,7 @@ func collapseGradientStop(stop color.Ansi, isBackground bool) color.Ansi {
// countVisibleCells is the pre-pass a gradient channel needs before streaming
// starts: it walks txt with the exact same tokenization rules as
// writeBody/writeBodyGradient (scanAnchor, the hyperlink tokens, the "link"
// no-text fallback) and sums runewidth.RuneWidth over every rune write()
// no-text fallback) and sums runeCells over every rune write()
// would count toward length, so color.GradientCells gets the right cell
// count and the streaming loop's cellIndex never drifts from it.
// startHyperlink mirrors the loop having already consumed a leading
@@ -1162,7 +1157,7 @@ func countVisibleCells(txt string, startHyperlink, startInvisible bool) int {
if s != '<' {
if !hyperlink && !invisible {
cells += runewidth.RuneWidth(s)
cells += runeCells(s)
}
i += size
continue
@@ -1171,7 +1166,7 @@ func countVisibleCells(txt string, startHyperlink, startInvisible bool) int {
match, kind := classifyAnchor(txt[i:])
if kind == anchorNone {
if !hyperlink && !invisible {
cells += runewidth.RuneWidth(s)
cells += runeCells(s)
}
i += size
continue