diff --git a/meshterm/ui/tui/emoji_width.py b/meshterm/ui/tui/emoji_width.py index c3ae674..0cb7b76 100644 --- a/meshterm/ui/tui/emoji_width.py +++ b/meshterm/ui/tui/emoji_width.py @@ -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"] diff --git a/tests/test_emoji_width.py b/tests/test_emoji_width.py index 8b696c0..480f7e9 100644 --- a/tests/test_emoji_width.py +++ b/tests/test_emoji_width.py @@ -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)