Emoji width: carve wide-VS16 exceptions back out of the narrowing

A width-1 terminal isn't uniform: the ☀️ probe paints in one cell but
🛩️ (U+1F6E9 VS16) paints in two, so blanket-narrowing VS16 sequences
pulls the airplane's chat-row border a column short and smears the line.
Add a curated wide set (_DEFAULT_WIDE_VS16, MESHTERM_WIDE_EMOJI) — the
mirror of the lone-narrow allowlist — that forces those base codepoints
back to two in both width authorities, selector still skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-20 01:29:41 -04:00
1 parent 3ac43b7c35
commit 598b272483
2 files changed
+100 -16

No files matched your search

+63 -12
View File
@@ -51,6 +51,17 @@ glyph, while prompt_toolkit's wcwidth counts each indicator as two and so measur
as *four* (a two-column notch, worse than a lone emoji's one). Since the indicators are
shared across flags, they can only be handled as a whole category, never one country at a
time; narrowing every indicator to one cell makes any flag sum to two in both authorities.
A handful of VS16 sequences break the "width 1" verdict the other way. This terminal is not
uniform: the probe ``☀️`` paints in one cell, but ``🛩️`` (a small airplane, U+1F6E9 U+FE0F)
paints in *two*. Narrowing it along with the rest pulls its chat-row border a column short and
smears the line, so those glyphs are carved back out into a curated *wide* set
(:data:`_DEFAULT_WIDE_VS16`, extended without a code change via ``MESHTERM_WIDE_EMOJI``) that
counts them two in **both** authorities — the mirror image of the lone-narrow allowlist, curated
for the same reason: which VS16 a terminal draws wide is the font's business, not the codepoint's,
and a cursor probe reads the PTY, not the renderer. Keyed on the *base* codepoint (the trailing
``U+FE0F`` is skipped regardless), so a bare base airplane rides along too — rare, and the same
trade the lone allowlist already makes.
"""
from __future__ import annotations
@@ -78,6 +89,17 @@ _PROBE = "☀️"
#: the confirmed glyph and extended — no code change — through ``MESHTERM_NARROW_EMOJI``.
_DEFAULT_NARROW_LONE = "👋"
#: Base codepoints of VS16 emoji-presentation sequences this terminal draws *two* cells wide
#: despite the narrow-VS16 verdict that width-1 calibration applies to every such sequence. The
#: renderer is not uniform (see the module docstring): ``☀️`` — the calibration probe — paints in
#: one cell, but ``🛩️`` (U+1F6E9 U+FE0F) paints in two, so blanket-narrowing it pulls its chat-row
#: border a column short and smears the row. There is no rule separating the wide VS16 from the
#: narrow — it is the font's glyph, not the codepoint, and a probe reads the PTY, not the renderer —
#: so, the mirror of :data:`_DEFAULT_NARROW_LONE`, this is a curated set, seeded with the confirmed
#: glyph and extended, no code change, through ``MESHTERM_WIDE_EMOJI``. Keyed on the base codepoint
#: because the trailing selector is skipped by both authorities either way.
_DEFAULT_WIDE_VS16 = "\U0001f6e9" # 🛩 airplane (U+1F6E9), drawn two-wide with its VS16 selector
#: Set once :func:`calibrate` has run so repeated calls are cheap no-ops.
_CALIBRATED = False
@@ -108,6 +130,21 @@ def _narrow_lone_set() -> frozenset[str]:
return frozenset(os.environ.get("MESHTERM_NARROW_EMOJI", _DEFAULT_NARROW_LONE))
def _wide_vs16_set() -> frozenset[str]:
"""The VS16 base codepoints to keep two cells wide (see :data:`_DEFAULT_WIDE_VS16`).
``MESHTERM_WIDE_EMOJI`` overrides the default outright: set it to the base glyphs you have
confirmed this terminal draws two wide (e.g. ``MESHTERM_WIDE_EMOJI=🛩``), or to the empty
string to trust the narrow-VS16 verdict for every sequence. A trailing variation selector is
dropped, so pasting the whole rendered glyph (``🛩️``) still keys on the base and never counts
the zero-width selector as a wide cell itself. Only consulted on the width-1 path (see
:func:`calibrate`); a terminal that already draws emoji two wide never narrowed them, so there
is nothing to carve back out.
"""
listed = frozenset(os.environ.get("MESHTERM_WIDE_EMOJI", _DEFAULT_WIDE_VS16))
return listed - frozenset(_ZERO_WIDTH)
def calibrate(*, force_width: int | None = None) -> None:
"""Measure the terminal's emoji width once and patch Rich to match.
@@ -130,7 +167,7 @@ def calibrate(*, force_width: int | None = None) -> None:
# width 2 (or unknown/None) -> Rich's default already matches; leave everything alone
# (narrowing here would instead pull a correctly-wide emoji's border a column short).
if width == 1:
_install_terminal_widths(_narrow_lone_set())
_install_terminal_widths(_narrow_lone_set(), _wide_vs16_set())
def _decide_vs16_width() -> int | None:
@@ -274,14 +311,17 @@ def _posix_probe_width(probe: str, timeout: float = 0.3) -> int | None:
# --- the patch ----------------------------------------------------------------
def _make_cell_len(narrow: frozenset[str]) -> Callable[[str, str], int]:
def _make_cell_len(narrow: frozenset[str], wide: frozenset[str]) -> Callable[[str, str], int]:
"""Build Rich's ``_cell_len`` replacement: the terminal's true width for every string.
Two corrections fold into the one measuring loop the whole render pipeline reads:
Corrections fold into the one measuring loop the whole render pipeline reads:
* **VS16 promotion** — a variation selector (:data:`_ZERO_WIDTH`) is skipped, so a narrow
base (``☀`` = 1) is measured on its own instead of promoted to two by a trailing
``U+FE0F``. This is the general emoji-presentation fix (see the module docstring).
* **Wide VS16 exceptions** — a base codepoint in ``wide`` (``🛩`` and friends) that this
terminal draws two cells despite the narrow-VS16 verdict is forced back to two, so its
selector-skipped base does not collapse to one and smear the row.
* **Curated lone emoji** — a single codepoint in ``narrow`` (``👋`` and friends) that Rich
would call two but this terminal draws in one is counted as one, so a chat row carrying
it pads to a flush right border instead of a column-short notch.
@@ -300,7 +340,9 @@ def _make_cell_len(narrow: frozenset[str]) -> Callable[[str, str], int]:
for char in text:
if char in _ZERO_WIDTH:
continue
if char in narrow or _is_regional_indicator(char):
if char in wide:
total += 2
elif char in narrow or _is_regional_indicator(char):
total += 1
else:
total += get_size(char, unicode_version)
@@ -309,7 +351,7 @@ def _make_cell_len(narrow: frozenset[str]) -> Callable[[str, str], int]:
return _cell_len
def _make_pt_cache(narrow: frozenset[str]) -> "object":
def _make_pt_cache(narrow: frozenset[str], wide: frozenset[str]) -> "object":
"""Build a prompt_toolkit char-width cache that measures every ``narrow`` glyph as one.
prompt_toolkit is the authority that *places the panel's right border*: it lays the
@@ -320,6 +362,12 @@ def _make_pt_cache(narrow: frozenset[str]) -> "object":
and defers everything else to the stock ``wcwidth`` logic; because the base ``__missing__``
sums per character through the cache, a whole chat line ``"Bob 👋 hi"`` inherits the one-cell
``👋`` for free, and a flag ``"🇨🇦"`` sums its two one-cell indicators to a flush two.
A ``wide`` base codepoint (``🛩``) is the reverse case and matters most here: prompt_toolkit's
wcwidth already calls a VS16 base one on its own (it never promoted the pair), so without this
the airplane's border lands a column short of where the terminal paints its two-cell glyph. The
subclass forces such a base to two; the same per-character summation then gives ``"🛩️ hi"`` its
correct width, the trailing selector staying zero.
"""
import prompt_toolkit.utils as ptu
@@ -327,6 +375,9 @@ def _make_pt_cache(narrow: frozenset[str]) -> "object":
class _NarrowLoneCache(base_cache_cls): # type: ignore[valid-type, misc]
def __missing__(self, string: str) -> int:
if string in wide:
self[string] = 2
return 2
if string in narrow or _is_regional_indicator(string):
self[string] = 1
return 1
@@ -335,23 +386,23 @@ def _make_pt_cache(narrow: frozenset[str]) -> "object":
return _NarrowLoneCache()
def _install_terminal_widths(narrow: frozenset[str]) -> None:
def _install_terminal_widths(narrow: frozenset[str], wide: frozenset[str]) -> None:
"""Redirect both width authorities through the terminal-aligned measurement, in one shot.
Rich's ``cell_len`` delegates to the module-level ``_cell_len``; replacing that (and
clearing the memoised wrapper) steers every downstream caller — panels, tables, text —
through :func:`_make_cell_len`. prompt_toolkit is patched too, because it is what places the
border (see :func:`_make_pt_cache`): a lone emoji or a flag both need it, and even an empty
``narrow`` still leaves the flag-indicator category to correct, so the cache is always
swapped. Both run at :func:`calibrate` time, before a frame is drawn, so no stale two-cell
measurement or ``Char`` is ever painted.
border (see :func:`_make_pt_cache`): a lone emoji, a wide-VS16 exception, or a flag all need
it, and even empty ``narrow``/``wide`` sets still leave the flag-indicator category to
correct, so the cache is always swapped. Both run at :func:`calibrate` time, before a frame is
drawn, so no stale measurement or ``Char`` is ever painted.
"""
import prompt_toolkit.utils as ptu
import rich.cells as cells
cells.cached_cell_len.cache_clear()
cells._cell_len = _make_cell_len(narrow)
ptu._CHAR_SIZES_CACHE = _make_pt_cache(narrow)
cells._cell_len = _make_cell_len(narrow, wide)
ptu._CHAR_SIZES_CACHE = _make_pt_cache(narrow, wide)
__all__ = ["calibrate"]
+37 -4
View File
@@ -23,6 +23,8 @@ _WAVE = "👋" # the confirmed lone-codepoint emoji this terminal draws in one
_DISH = "📡" # a menu icon the terminal draws two wide — must never be narrowed
_CA = "🇨🇦" # a flag: two Regional Indicators; prompt_toolkit miscounts it as four cells
_CN = "🇨🇳" # a second flag sharing the "C" indicator — the whole category must be handled
_PLANE = "🛩️" # a VS16 sequence this terminal draws two wide despite the narrow-VS16 verdict
_PLANE_BASE = "\U0001f6e9" # its base codepoint (no selector) — what the wide set is keyed on
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
@@ -38,9 +40,29 @@ def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
assert ew._narrow_lone_set() == frozenset()
def test_wide_vs16_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The wide set seeds to the confirmed airplane base; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
assert _PLANE_BASE in ew._wide_vs16_set()
# ✈ (U+2708) is a second VS16 airplane base; both list cleanly.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "\U0001f6e9✈")
assert {"\U0001f6e9", "✈"} <= ew._wide_vs16_set()
# Pasting the whole rendered glyph keeps the base but strips the zero-width selector, so
# the selector is never itself counted as a wide cell.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", _PLANE)
wide = ew._wide_vs16_set()
assert _PLANE_BASE in wide and "️" not in wide
# Empty string trusts the narrow-VS16 verdict for every sequence.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "")
assert ew._wide_vs16_set() == frozenset()
def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
"""Rich's replacement measures a listed lone emoji as one, an unlisted one still as two."""
cell_len = ew._make_cell_len(frozenset(_WAVE))
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cell_len(_WAVE) == 1
assert cell_len(_DISH) == 2 # unlisted: the terminal draws it wide, so leave it wide
@@ -48,17 +70,25 @@ def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
# The pre-existing VS16 handling still applies: a narrow base is measured alone, its
# variation selector skipped, so "☀️" stays one cell rather than being promoted to two.
assert cell_len("☀️") == 1
# A wide-VS16 exception is forced back to two, selector still skipped, so the airplane
# frames flush instead of collapsing to one and smearing the row.
assert cell_len(_PLANE) == 2
assert cell_len("hi 🛩️") == len("hi ") + 2
def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
"""prompt_toolkit's cache — the authority that places the border — matches Rich."""
cache = ew._make_pt_cache(frozenset(_WAVE))
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cache[_WAVE] == 1
assert cache[_DISH] == 2
# The base cache sums a multi-char string per character through the cache, so the
# one-cell wave is inherited by any line that contains it.
assert cache["Bob 👋"] == 5
# The wide-VS16 airplane is the reverse: its base is forced to two, the selector stays
# zero, so the whole glyph (and any line holding it) keeps the terminal's two cells.
assert cache[_PLANE] == 2
assert cache["🛩️ hi"] == 2 + len(" hi")
def test_flags_measure_two_cells_in_both_authorities() -> None:
@@ -67,8 +97,8 @@ def test_flags_measure_two_cells_in_both_authorities() -> None:
category makes every flag sum to two in both authorities — no per-country allowlist entry,
and flags sharing an indicator (🇨🇦 / 🇨🇳) are all fixed at once."""
# Only the lone-emoji allowlist is passed; flags are handled by category, not by listing.
cell_len = ew._make_cell_len(frozenset(_WAVE))
cache = ew._make_pt_cache(frozenset(_WAVE))
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset())
for flag in (_CA, _CN):
assert cell_len(flag) == 2
@@ -93,6 +123,7 @@ def _restore(snap: tuple) -> None:
def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> None:
"""A renderer that draws emoji narrow gets both Rich and pt aligned to the terminal."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
@@ -102,9 +133,11 @@ def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> N
assert cells.cell_len(_WAVE) == 1
assert cells.cell_len(_DISH) == 2 # unlisted icon stays wide
assert cells.cell_len(_CA) == 2 # flag handled by category, no allowlist entry
assert cells.cell_len(_PLANE) == 2 # wide-VS16 exception carved back out of the narrowing
assert get_cwidth(_WAVE) == 1 # pt now places the border a cell earlier
assert get_cwidth(_DISH) == 2
assert get_cwidth(_CA) == 2 # was 4 unpatched
assert get_cwidth(_PLANE) == 2 # was 1 unpatched: the airplane smeared a cell short
finally:
_restore(snap)