# I ported a correct emoji width table and it fixed nothing

URL: https://tuios.dev/blog/the-width-table-that-changed-nothing

> Emoji widths were wrong in the tuios browser client. I ported ghostty's per-codepoint width table and nothing changed, because width belongs to clusters.

Emoji were breaking layout in the [browser terminal](https://tuios.dev/docs/web). A family emoji would claim
the wrong number of columns, everything after it on the line would shift, and
box drawing further along would stop lining up.

The obvious fix was right there. ghostty has a VT implementation that gets
this right, compiled to wasm, with width tables I could extract. Take its
answers, use them instead of mine, done.

I built the table offline, wired it in, and measured against a corpus of hard
cases.

It changed nothing. Not "small improvement", not "fixed some cases". The output
was identical.

## Why a table cannot work

Here is the thing I had wrong, and you can check it yourself:

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/blog/the-width-table-that-changed-nothing)*

A per-codepoint table answers "how wide is this codepoint". Correctly, in
ghostty's case. But the terminal is not asking that question. It is asking "how
far does the cursor advance", and the answer depends on how codepoints combine,
not on what each of them is worth alone.

A family emoji is four people joined by zero-width joiners. Seven codepoints,
several of which are individually wide. Add up the table's answers and you get
eight columns. The terminal advances two, because it is one grapheme cluster
and a cluster gets one advance.

No table of per-codepoint values can produce that. The information is not in
the codepoints. It is in the boundaries between them, which is UAX 29
segmentation, a completely different algorithm. I had spent a day importing
correct answers to a question nobody was asking.

## What the corpus said

The corpus is 45 cases measured against real ghostty-vt with mode 2027
clustering enabled, so the expected values come from an implementation known to
be right rather than from my reading of a spec.

With the official grapheme addon in place, 39 of 45 agree. The remaining six
diverge in documented, degenerate cases, and they are asserted as expected
values rather than treated as outstanding bugs. That is a deliberate choice:
pretending they are bugs means either carrying a patch forever or having a
permanently red test.

The real fix was already available and was not a table at all. Use a segmenter
that implements UAX 29, and let the cluster boundaries fall out of it.
Everything else follows.

## What I kept from the exercise

The width table went in the bin, but the wasm build did not. It is now an
oracle.

That distinction turned out to be the useful part. As a runtime dependency,
ghostty-vt was a large coupling for a benefit I had not verified. As a test
oracle, it tells me whether my output is right, costs nothing at runtime, and
can be as heavy as it likes because it only runs in CI.

I would not have found that framing if the table had worked. It only came up
because I had to ask what the wasm build was still good for once the reason I
built it was gone.

## What parity means once pixels are involved

The corpus asserts integers, and integers can be compared for equality: a
cluster advances two columns or it does not. The renderer's own tests do not
have that luxury, and deciding what "matches" should mean for pixels took
longer than wiring up the oracle did.

Pixel parity is a tolerance, not equality. Four golden scenarios pass under a
budget of 2 percent differing pixels against the reference, and the emoji
scenario under 5 percent, because glyph rasterisation is allowed to differ in
its antialiasing without anything being wrong.

The 24-entry torture corpus needed a different comparison entirely. Those rows
are mostly background, and that is exactly what makes a per-pixel comparison
useless on them: a subpixel shift in glyph position swings the differing-pixel
fraction wildly while nothing is actually wrong, so the number measures jitter
rather than correctness. Those rows are compared by ink coverage per cell
instead. The question is whether the right cell contains the right amount of
glyph, not whether the exact pixels are identical.

Side by side, the three comparisons and what each one accepts:

| Suite                      | Compared by                            | Passes when                                                          |
| -------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
| Width corpus, 45 cases     | cursor advance, as integers            | equal to ghostty-vt (39 agree, 6 asserted as documented divergences) |
| Four golden scenarios      | differing pixels against the reference | under 2 percent                                                      |
| Emoji golden scenario      | differing pixels against the reference | under 5 percent                                                      |
| Torture corpus, 24 entries | ink coverage per cell                  | the right cell holds the right amount of glyph                       |

Every threshold in that list is a claim about the size of defect I am prepared
not to notice. Choosing them felt like bookkeeping at the time. It was not.
The same choice, made carelessly in another suite, later
[cost me three wrong closures on one bug](https://tuios.dev/blog/the-bug-i-closed-three-times).
A tolerance wider than a defect does not make the defect unlikely to be seen.
It makes it invisible.

## What the benchmarks refuse to claim

The renderer's benchmarks run under headless chromium, where WebGL executes on
SwiftShader and rasterises on the CPU. That is a different machine from the
one any user has, so the repo states plainly which columns transfer to real
hardware: the CPU timings, the draw-call counts and the allocation figures.
Nothing else, and no frame rate is claimed anywhere, because a frame rate
measured on a software rasteriser describes the rasteriser.

Writing down what a measurement cannot tell you costs a paragraph in a README.
I have since [been on the other end](https://tuios.dev/blog/measuring-before-optimising) of
skipping that paragraph, with three rounds of benchmarks vouching for a broken
code path, so it no longer reads as pedantry to me.

## The bit I would do differently

I should have run the cheap experiment first. Take one family emoji, ask the
current implementation how wide it thinks it is, ask ghostty, compare. Fifteen
minutes, and if both said eight, the table was never the problem and the bug
lived somewhere else entirely.

I skipped that because the fix felt obviously right. Correct data replacing
incorrect data is such a clean shape that I did not stop to ask whether the
data was the thing that was wrong.

The day was not a total loss. Nobody on this project will spend another day
importing width tables, and there is now a corpus and an oracle that would
catch anyone who tried.
