Emoji width: the motorway is two cells wide, whoever says otherwise
🛣 (U+1F6E3) heads the Trophy case's Longest distance board, and it pushed that heading's row a column past the border. Both width authorities call it one cell — it sits outside Emoji_Presentation and its East-Asian width is Neutral — while the font draws it as a two-cell colour glyph. That is a new shape for the wide set, which until now held VS16 bases the narrow verdict got wrong (🛩, whose selector-stripped base would otherwise collapse to one). Nothing narrowed the motorway; it was never measured right in the first place, so it overran its row on any screen that pads to a width. The fix is the same either way — force the base to two in Rich and in prompt_toolkit — so the set is now `_DEFAULT_WIDE_BASE`, seeded with both confirmed glyphs and still overridden outright by MESHTERM_WIDE_EMOJI. Curated, as always, because which glyph a terminal draws wide is the font's business and a cursor probe reads the PTY, not the renderer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
65a47af037
commit
f17f7b95a1
2 files changed
+58
-32
No files matched your search
@@ -52,16 +52,20 @@ as *four* (a two-column notch, worse than a lone emoji's one). Since the indicat
|
||||
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.
|
||||
Some glyphs run the other way — this terminal draws them **two** cells where the authorities say
|
||||
one — and they are carved back out into a curated *wide* set (:data:`_DEFAULT_WIDE_BASE`, extended
|
||||
without a code change via ``MESHTERM_WIDE_EMOJI``) counted two in **both** authorities. A VS16
|
||||
sequence lands here when the "width 1" verdict is wrong for it: the renderer is not uniform — the
|
||||
probe ``☀️`` paints in one cell, but ``🛩️`` (a small airplane, U+1F6E9 U+FE0F) paints in two, and
|
||||
narrowing it along with the rest pulls its chat-row border a column short and smears the line. A
|
||||
*lone* codepoint lands here for a plainer reason: an emoji outside Emoji_Presentation whose
|
||||
East-Asian width is Neutral (``🛣``, U+1F6E3) is measured as one cell by Rich and wcwidth alike,
|
||||
yet the font draws it as a two-cell colour glyph — never narrowed, just never measured right, so
|
||||
it overruns its row by a column wherever the row pads to a width. Either way it is the mirror image
|
||||
of the lone-narrow allowlist, curated for the same reason: which glyph 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 with its VS16 form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -89,16 +93,28 @@ _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
|
||||
#: Base codepoints this terminal draws *two* cells wide where the width authorities say one.
|
||||
#: Two sources feed it, and both are the same font story:
|
||||
#:
|
||||
#: * a **VS16 sequence** the narrow-VS16 verdict gets wrong — 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;
|
||||
#: * a **lone codepoint** Rich and wcwidth both already call one — the emoji outside
|
||||
#: Emoji_Presentation, whose East-Asian width is Neutral (``🛣`` U+1F6E3, the Trophy case's
|
||||
#: Longest-distance icon) — that the font nonetheless draws as a two-cell colour glyph. Nothing
|
||||
#: narrowed these; they were never measured right in the first place, so they overrun their row
|
||||
#: by a column on every screen that pads to a width.
|
||||
#:
|
||||
#: There is no rule separating either from its correctly-measured neighbours — 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 glyphs 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
|
||||
_DEFAULT_WIDE_BASE = (
|
||||
"\U0001f6e9" # 🛩 airplane (U+1F6E9), drawn two-wide with its VS16 selector
|
||||
"\U0001f6e3" # 🛣 motorway (U+1F6E3), drawn two-wide though both authorities measure one
|
||||
)
|
||||
|
||||
#: Set once :func:`calibrate` has run so repeated calls are cheap no-ops.
|
||||
_CALIBRATED = False
|
||||
@@ -130,18 +146,18 @@ 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`).
|
||||
def _wide_base_set() -> frozenset[str]:
|
||||
"""The base codepoints to measure two cells wide (see :data:`_DEFAULT_WIDE_BASE`).
|
||||
|
||||
``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
|
||||
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.
|
||||
:func:`calibrate`); a terminal that already draws emoji two wide measures these right on its
|
||||
own, and nothing here would have narrowed them.
|
||||
"""
|
||||
listed = frozenset(os.environ.get("MESHTERM_WIDE_EMOJI", _DEFAULT_WIDE_VS16))
|
||||
listed = frozenset(os.environ.get("MESHTERM_WIDE_EMOJI", _DEFAULT_WIDE_BASE))
|
||||
return listed - frozenset(_ZERO_WIDTH)
|
||||
|
||||
|
||||
@@ -167,7 +183,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(), _wide_vs16_set())
|
||||
_install_terminal_widths(_narrow_lone_set(), _wide_base_set())
|
||||
|
||||
|
||||
def _decide_vs16_width() -> int | None:
|
||||
|
||||
@@ -25,6 +25,7 @@ _CA = "🇨🇦" # a flag: two Regional Indicators; prompt_toolkit miscounts it
|
||||
_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
|
||||
_ROAD = "\U0001f6e3" # 🛣 a lone codepoint both authorities measure one, drawn two wide here
|
||||
|
||||
|
||||
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
|
||||
@@ -40,24 +41,26 @@ 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."""
|
||||
def test_wide_base_set_defaults_extends_and_disables(monkeypatch) -> None:
|
||||
"""The wide set seeds to the confirmed bases; the env var overrides it outright."""
|
||||
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
|
||||
assert _PLANE_BASE in ew._wide_vs16_set()
|
||||
# Both sources of a wide base: a VS16 sequence the narrow verdict gets wrong (the airplane)
|
||||
# and a lone codepoint both authorities under-measure at one cell (the motorway).
|
||||
assert {_PLANE_BASE, _ROAD} <= ew._wide_base_set()
|
||||
|
||||
# ✈ (U+2708) is a second VS16 airplane base; both list cleanly.
|
||||
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "\U0001f6e9✈")
|
||||
assert {"\U0001f6e9", "✈"} <= ew._wide_vs16_set()
|
||||
assert {"\U0001f6e9", "✈"} <= ew._wide_base_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()
|
||||
wide = ew._wide_base_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()
|
||||
assert ew._wide_base_set() == frozenset()
|
||||
|
||||
|
||||
def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
@@ -74,6 +77,9 @@ def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
# frames flush instead of collapsing to one and smearing the row.
|
||||
assert cell_len(_PLANE) == 2
|
||||
assert cell_len("hi 🛩️") == len("hi ") + 2
|
||||
# A listed *lone* base is the same fix for a glyph nothing narrowed: Rich reads U+1F6E3 as
|
||||
# one cell on its own, so a heading carrying it would overrun its row by a column.
|
||||
assert ew._make_cell_len(frozenset(), frozenset(_ROAD))(_ROAD) == 2
|
||||
|
||||
|
||||
def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
@@ -89,6 +95,8 @@ def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
# 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")
|
||||
# And a listed lone base, which wcwidth also under-measures at one, lands on two as well.
|
||||
assert ew._make_pt_cache(frozenset(), frozenset(_ROAD))[_ROAD] == 2
|
||||
|
||||
|
||||
def test_flags_measure_two_cells_in_both_authorities() -> None:
|
||||
@@ -134,10 +142,12 @@ def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> N
|
||||
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 cells.cell_len(_ROAD) == 2 # a lone base the authorities under-measured at one
|
||||
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
|
||||
assert get_cwidth(_ROAD) == 2 # likewise for the motorway, in the border authority
|
||||
finally:
|
||||
_restore(snap)
|
||||
|
||||
|
||||
Reference in new issue
Block a user