Files
MeshTerm/tests/test_emoji_width.py
T
Jean-Pierre MartineauandClaude Opus 4.8 0b8ec9f75f Chat ragged border: fix country flags, add-narrow-emoji skill
A country flag (🇨🇦) is a Regional Indicator pair, not a lone codepoint:
Rich and the terminal draw the pair as one two-cell glyph, but prompt_toolkit
counts each indicator as two (wcwidth), measuring the flag as four and placing
the border two columns short — a worse notch than a lone emoji's one.

Handle flags as a category, not per-country: an indicator is measured as one
cell, so any flag sums to two in both authorities. The indicators are shared
across flags (🇨🇦 and 🇨🇳 both start with C), so listing a single flag would
half-fix its neighbours and skip the rest — the block is the only correct unit.
prompt_toolkit's cache is now always swapped when the renderer draws emoji
narrow (not only when the lone allowlist is non-empty), since flags need it
even with an empty MESHTERM_NARROW_EMOJI.

Add the add-narrow-emoji skill: a classify.py that reports an emoji's
codepoints and its width under wcwidth/Rich/pt (now and fixed) and names the
class + action, plus a SKILL.md procedure. It steers a lone wide codepoint into
the allowlist, recognises flags and VS16 as already handled, and refuses ZWJ /
keycap / modifier clusters (their parts would misalign) — encoding the
per-glyph, confirm-visually, never-blanket discipline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:41:03 -04:00

125 lines
5.7 KiB
Python

"""Terminal-aligned emoji width: VS16 sequences and curated lone-codepoint emoji.
The border-alignment story lives in :mod:`meshterm.ui.tui.emoji_width`. These tests pin the
two things that make a chat row with ``👋`` frame flush: Rich must *measure* the glyph as one
cell (so it pads the row to the right width) and prompt_toolkit must *count* it as one (so it
places the panel's right border where the terminal actually draws it). An emoji the terminal
draws two wide — a menu icon like ``📡``, never on the allowlist — must stay two in both.
The factory helpers (``_make_cell_len``, ``_make_pt_cache``) are exercised in isolation so no
process-global width table is touched; the two :func:`calibrate` tests that do patch the real
tables snapshot and restore them in a ``finally`` so nothing leaks into a later test.
"""
from __future__ import annotations
import prompt_toolkit.utils as ptu
import rich.cells as cells
from meshterm.ui.tui import emoji_width as ew
# One confirmed narrow glyph, plus one the same terminal draws two wide, used throughout.
_WAVE = "👋" # the confirmed lone-codepoint emoji this terminal draws in one cell
_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
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The allowlist seeds to the confirmed glyph; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
assert _WAVE in ew._narrow_lone_set()
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "👋🤙")
assert {"👋", "🤙"} <= ew._narrow_lone_set()
# Empty string is the off switch — a way back to Rich/pt defaults with no code change.
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "")
assert ew._narrow_lone_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))
assert cell_len(_WAVE) == 1
assert cell_len(_DISH) == 2 # unlisted: the terminal draws it wide, so leave it wide
assert cell_len("Bob 👋 hi") == 8 # the narrowed glyph flows through a whole line
# 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
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))
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
def test_flags_measure_two_cells_in_both_authorities() -> None:
"""A country flag is a Regional Indicator pair: prompt_toolkit's wcwidth calls it four
cells, Rich and the terminal draw it as one two-cell glyph. Narrowing the indicators as a
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))
for flag in (_CA, _CN):
assert cell_len(flag) == 2
assert cache[flag] == 2 # was 4 unpatched: two indicators at wcwidth 2 each
# A flag rides a chat line without dragging the border: "eh? 🇨🇦" measures its plain
# cells plus the flag's two.
assert cell_len("eh 🇨🇦") == len("eh ") + 2
def _snapshot() -> tuple:
"""Capture the mutable width state :func:`calibrate` patches, to restore afterwards."""
return (cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED)
def _restore(snap: tuple) -> None:
"""Put the width authorities (and the once-only flag) back, clearing Rich's memo cache."""
cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED = snap
cells.cached_cell_len.cache_clear()
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)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=1)
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 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
finally:
_restore(snap)
def test_calibrate_width2_leaves_both_authorities_untouched(monkeypatch) -> None:
"""On a terminal that draws emoji two wide, narrowing would itself break the border."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=2)
assert cells.cell_len(_WAVE) == 2
assert get_cwidth(_WAVE) == 2
finally:
_restore(snap)