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:
1 parent
3ac43b7c35
commit
598b272483
2 files changed
+100
-16
No files matched your search
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user