diff --git a/src/go.mod b/src/go.mod index f175c0a81..673bee6f6 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 diff --git a/src/terminal/width.go b/src/terminal/width.go new file mode 100644 index 000000000..07d1f7236 --- /dev/null +++ b/src/terminal/width.go @@ -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 + } +} diff --git a/src/terminal/writer.go b/src/terminal/writer.go index 0792dc7d8..c0fddf08b 100644 --- a/src/terminal/writer.go +++ b/src/terminal/writer.go @@ -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