How wide is a string?
May 4, 2026 · 10 min read
Draw a border around a string. The feature fits in a sentence: a box, some text inside it, a ─ repeated across the top. It works in the demo, ships, and then someone puts "你好" or a 🍔 in the box and the right edge shears off:
┌────────────┐ ┌──────────┐
│ hello box │ │ 你好 box │
└────────────┘ └──────────┘
The top border of the second box was sized by len() — six bytes for two ideographs that paint four cells — so the frame and the content disagree about where the right edge is. Every terminal UI hits this bug, usually in week one, and almost everyone reaches for the same fix: ah, len() counts bytes, I should count characters, and swaps in .chars().count().
That fix is also wrong. It's wrong for CJK, wrong for emoji, wrong for combining accents, and spectacularly wrong the moment the string contains a color escape. In Rust, "how long is this string?" has at least four defensible answers, and for drawing a border, all of the easy ones are wrong.
This is the problem the measure-text crate in inkrs exists to solve. inkrs is a Rust port of Ink, the terminal-UI framework, and measure-text is its port of the measuring layer — the JS string-width and widest-line packages folded into one small crate. It's the foundation everything else in the framework stands on, so it's the right place to start reading the codebase.
The four answers
Here are five strings, counted four ways. Every number in this table was computed by running the actual code — str::len, chars().count(), unicode-segmentation's grapheme iterator, and measure_text::string_width — not estimated:
| string | len() (bytes) |
chars().count() |
graphemes | terminal cells |
|---|---|---|---|---|
"hello" |
5 | 5 | 5 | 5 |
"cafe\u{301}" (café, decomposed) |
6 | 5 | 4 | 4 |
"你好" |
6 | 2 | 2 | 4 |
"🍔" |
4 | 1 | 1 | 2 |
"\x1b[31mred\x1b[0m" |
12 | 12 | 12 | 3 |
Read the columns and each one turns out to be the correct answer to a different question:
len()answers "how many bytes do I allocate?" Rust strings are UTF-8, so你is three bytes and🍔is four.chars().count()answers "how many Unicode scalar values are there?" That's what acharis — not a character in any human sense, a code point.- Graphemes (via the
unicode-segmentationcrate) answer "how many user-perceived characters are there?": the units the cursor jumps over when you press an arrow key. The decomposedéis two scalars but one grapheme. - Terminal cells answer the question the border actually asks: "how many columns does this occupy on screen?"
Note that no column agrees with any other column all the way down. ASCII is the only place they coincide, which is exactly why the bug survives the demo: "hello" is 5 by every measure, and your test strings are always "hello".
The last column is the one Rust doesn't give you. There is no .cells() on str, because cell width isn't a property of the string — it's a property of the string as a terminal renders it. That's the fourth way of counting, and it needs its own machinery.
Where cell width comes from
The terminal's model is a grid of fixed-width cells, and every scalar value gets a cell budget: most take one, some take two, some take zero. The lineage of this idea is POSIX's wcwidth(3) — the C function that answers "how many columns does this wide character need?" — and the data behind it is Unicode's East Asian Width property, UAX #11. The rules that matter:
- Wide and Fullwidth characters get 2 cells. That's CJK ideographs, hiragana, katakana, Hangul syllables, and most emoji.
你is twice as wide asyin a monospace grid; fonts and terminals agree on this. - Combining marks and other zero-width characters get 0 cells. The
U+0301in decomposedcafédoesn't advance the cursor; it stacks on thee. - Control characters get no width at all:
wcwidthreturns −1 for them, an error.
measure-text doesn't carry its own width tables. It delegates to the unicode-width crate, the Rust ecosystem's wcwidth descendant, and wraps it in exactly one function:
/// Width of a single grapheme cluster expressed as a `char`.
The unwrap_or(0) is a policy decision: UnicodeWidthChar::width returns None for control characters, and for layout purposes a control character occupies nothing. That single line is the crate's entire opinion about Unicode; everything else is bookkeeping around it.
One design note hiding in that function's doc comment: it's pub so that the paint layer uses the same width source as the measure layer. If measuring and painting ever disagree about how wide 你 is, every box in the UI shears at the seam. One function, one truth.
Emoji: where the model gets honest
For CJK, the two-cell rule is settled. For emoji, it isn't. This is where any measuring library has to make a choice and own it.
The problem is that modern emoji aren't scalar values; they're sequences. 👨👩👦 (family) is five scalars: three emoji glued by two U+200D ZERO WIDTH JOINER characters. ❤️ is a heart plus U+FE0F VARIATION SELECTOR-16, which requests colorful emoji presentation for a character that predates emoji. Grapheme segmentation says each is one user-perceived character. Terminals then render that one grapheme as... it depends. Two cells in most modern terminals. Six in terminals that don't collapse ZWJ sequences and draw each family member separately. The ecosystem disagrees.
measure-text makes the simplest defensible choice: sum the per-scalar widths and don't segment. For the family emoji, that arithmetic looks like this (each width from unicode-width):
U+1F468 MAN → 2
U+200D ZERO WIDTH JOINER → 0
U+1F469 WOMAN → 2
U+200D ZERO WIDTH JOINER → 0
U+1F466 BOY → 2
total 6
So string_width("👨👩👦") is 6, and string_width("❤️") is 1 (heart is East-Asian-Neutral, so 1; the variation selector is 0 — even though an emoji-presentation terminal will draw it 2 wide). Flags land better by the same arithmetic: 🇳🇱 is two regional-indicator scalars at 1 cell each, total 2, which is what most terminals draw for the assembled flag. The crate's own doc comment states the scope plainly: it mirrors what string-width does in JS "for the common cases Ink renders." Single emoji like 🍔, CJK, combining marks, flags, ANSI — exact. ZWJ sequences and presentation selectors — approximate, in a domain where the terminals themselves haven't agreed what the right answer is.
That's a defensible trade, not a cop-out: a width function that second-guessed every terminal's ZWJ collapsing would be differently wrong on each of them. A per-scalar sum is deterministic, cheap, and exactly right for the overwhelming majority of strings a TUI renders.
ANSI counts for nothing
Back to the last row of the table. "\x1b[31mred\x1b[0m" is twelve bytes and twelve scalars, and it paints exactly three cells: a red red. The escape sequences — ESC [ 31 m to set the color, ESC [ 0 m to reset — are instructions to the terminal, not content. Any of the twelve scalars fed to a width table individually would produce nonsense, because [, 3, 1, m are all perfectly ordinary width-1 characters when they're text. Whether they're text depends on what came before them.
So you can't measure ANSI-laced strings with a character table at all. You need a parser first. inkrs has one as a separate crate, ansi-tokenizer, whose tokenize_ansi function splits a string into typed tokens — AnsiToken::Text, AnsiToken::Csi, OSC/DCS control strings, and so on — handling the real grammar: CSI parameter and intermediate bytes, C1 controls, BEL-terminated OSC, even tmux's ESC ESC payload escaping.
With that in hand, measuring becomes almost embarrassingly simple. This is the actual string_width from measure-text, in full:
/// Equivalent of JS `string-width`: strip ANSI, sum display widths.
Tokenize, keep only the Text tokens, sum the per-char widths. Escape sequences never reach the width table because they never survive the if let. The separation of concerns is what keeps it that simple: the tokenizer knows the ANSI grammar and nothing about widths; the measurer knows widths and nothing about ANSI. (There's also a fast path buried in tokenize_ansi itself — a string with no ESC or C1 bytes comes back as a single Text token without any parsing, so plain strings pay almost nothing for the generality.)
The API, verified
The full public surface of measure-text is four functions and a struct — char_display_width and string_width above, plus:
/// Equivalent of JS `widest-line`.
widest_line lifts the measurement to multiline text — a block is as wide as its widest line — and measure_text returns the bounding box a layout engine wants: widest line by line count. The measurement itself is the two expressions in the comment; the elision hides one production concern worth pausing on.
Measuring is hot-path code
A TUI re-measures on every frame. The same button labels, the same spinner glyphs, the same headers — measured sixty times a second, forever. So measure_text keeps a memoization cache in front of the computation, and the interesting part isn't that the cache exists but that it's bounded, twice over:
/// Maximum number of cached strings. Long-running TUIs that stream text
/// measure an ever-growing string every frame; without a bound every unique
/// intermediate stage would be retained forever (O(n²) memory over the life
/// of the process). On overflow the whole map is cleared — cheap, leaves no
/// stale weight behind, and hot short strings simply re-cache.
const MAX_CACHE_ENTRIES: usize = ;
/// Only cache short strings: measuring is O(len) anyway, so the cache only
/// pays off for small, frequently repeated strings (labels, spinners);
/// big transcript blobs would otherwise dominate the cache's memory.
const MAX_CACHED_TEXT_LEN: usize = ;
The failure mode those comments describe is specific: a UI that streams text (a log pane, a chat transcript) measures "a", then "ab", then "abc" — every frame a brand-new string, every one a fresh cache key. An unbounded memo cache turns that into O(n²) memory over the life of the process, a leak that no allocation profiler will flag as anything but "strings, working as intended." The countermeasures are deliberately crude — skip anything over a kilobyte, and when the map hits 4096 entries, clear it entirely rather than doing LRU bookkeeping. Hot labels re-cache within a frame; nothing stale survives.
And because a regression here wouldn't fail any correctness test, the bound itself is pinned by one — the suite streams 10,000 growing strings through measure_text and then asserts the cache didn't keep them:
let mut s = Stringnew;
for i in ..u32
let entries = cache.lock.unwrap.len;
assert!;
Two asserts, one thesis
The crate's test suite is where all of this essay's claims come from. Two of the actual tests:
Twelve scalars of colored text measure 3 wide. Two emoji on one line beat three ASCII letters on the next, so the block is 4 wide and 2 high. Those two asserts are the whole essay in miniature: ANSI counts for nothing, emoji count double, and the box is as wide as its widest line.
The fourth count is the foundation
string_width is fifteen lines, and the hardest part was already done by the Unicode consortium and the unicode-width maintainers. But every question a terminal layout engine ever answers — where does this text wrap? how wide is this column? where does the border go? — bottoms out in this one function. Get the fourth way of counting right, and boxes stop shearing; get it wrong, and no amount of layout cleverness above it can save you.
A border is just a claim about width, and measure-text is what makes the claim true.
Thoughts? Find me on LinkedIn.