"""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 _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 bare text-default emoji: one cell, and both authorities agree _WEB = "\U0001f578" # 🕸 its untouched sibling one Trophy board down — the control _SHRUG = "\U0001f937â€â™‚ī¸" # đŸ¤ˇâ€â™‚ī¸ a ZWJ sequence: base, joiner, male sign, selector — one glyph _FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 three joined people, still one glyph _ZWJ = "‍" # the joiner itself: the tell that its neighbours are a single glyph 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_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) # One source only: a VS16 sequence the narrow verdict gets wrong. A *bare* codepoint both # authorities already measure at one is not a candidate — see test_bare_text_default_emoji. assert _PLANE_BASE in ew._wide_base_set() assert not {_ROAD, _WEB} & 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_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_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_base_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), frozenset(_PLANE_BASE)) 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 # 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 # A ZWJ sequence is one glyph, measured as its base: the codepoints the joiner folds in # cost nothing, however many of them there are. Rich's stock loop already does this, and # the selector-skipping replacement must not lose it — counting the male sign as a cell # of its own is what pulls the row's right border a column in. assert cell_len(_SHRUG) == 2 assert cell_len(_FAMILY) == 2 assert cell_len(f"Bob {_FAMILY} hi") == len("Bob 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), 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") # The sequences prompt_toolkit added up part by part: three cells for the shrug and six # for the family, each of them one two-cell glyph, each pulling a border in by the excess. assert cache[_SHRUG] == 2 assert cache[_FAMILY] == 2 assert cache[f"Bob {_FAMILY} hi"] == len("Bob hi") + 2 # A lone joiner is still zero, and a codepoint a sequence joins keeps its own width when # it stands alone — ``↕`` is the reorder icon, not part of anything. assert cache[_ZWJ] == 0 assert cache["↕"] == 1 def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None: """An emoji outside Emoji_Presentation, drawn *bare*, is one cell — and stays one. ``đŸ›Ŗ`` and ``🕸`` have East-Asian width Neutral, so Rich and wcwidth both measure them at one; with no variation selector asking for emoji presentation, the font draws a one-cell text glyph, which is exactly what they said. Both authorities already agreeing is not a bug to correct: listing ``đŸ›Ŗ`` in the wide set made the Trophy case's Longest-distance heading reserve a cell the terminal never drew and pulled that row's border a column in, while ``🕸`` — same class, one board down, never listed — framed flush throughout. """ cell_len = ew._make_cell_len(frozenset(_WAVE), ew._wide_base_set()) cache = ew._make_pt_cache(frozenset(_WAVE), ew._wide_base_set()) for bare in (_ROAD, _WEB): assert cell_len(bare) == 1 and cache[bare] == 1 # A heading built around one costs its own cells and no more, so the row frames flush. assert cell_len(f"── {_ROAD} Longest distance ──") == len("── ") + 1 + len(" Longest distance ──") 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), frozenset()) cache = ew._make_pt_cache(frozenset(_WAVE), frozenset()) 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, ew._CLUSTERS) 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, ew._CLUSTERS = 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) monkeypatch.delenv("MESHTERM_WIDE_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 cells.cell_len(_PLANE) == 2 # wide-VS16 exception carved back out of the narrowing assert cells.cell_len(_ROAD) == 1 # bare text-default emoji: left exactly as measured assert cells.cell_len(_WEB) == 1 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) == 1 and get_cwidth(_WEB) == 1 # untouched, in both authorities assert cells.cell_len(_SHRUG) == 2 and get_cwidth(_SHRUG) == 2 # pt counted three assert cells.cell_len(_FAMILY) == 2 and get_cwidth(_FAMILY) == 2 # pt counted six finally: _restore(snap) def test_calibrate_width2_narrows_nothing_but_still_joins_clusters(monkeypatch) -> None: """On a terminal that draws emoji two wide, narrowing would itself break the border — but a joined sequence is over-measured at *every* width, so that one correction still lands. prompt_toolkit sums a ZWJ sequence's codepoints wherever it runs, which no terminal draws: the family is one two-cell glyph on the widest terminal as surely as on the narrowest. So the cluster rule is installed on its own here, and nothing else is — the curated sets and the flag category stay unconfirmed on this renderer, and Rich is not touched at all. """ 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 # Rich untouched: it still promotes a VS16 sequence to the two cells this terminal draws. assert cells.cell_len(_PLANE) == 2 and cells.cell_len("â˜€ī¸") == 2 # Neither curated set nor the flag category applies here — pt keeps its stock answers. assert get_cwidth("â˜€ī¸") == 1 assert get_cwidth(_CA) == 4 # The one correction that holds at either width. assert get_cwidth(_SHRUG) == 2 # was 3 assert get_cwidth(_FAMILY) == 2 # was 6 finally: _restore(snap) def test_join_zwj_clusters_merges_only_joined_runs() -> None: """The fragment merge gathers a whole sequence and leaves everything else alone. prompt_toolkit's ANSI text arrives one codepoint per fragment, so a sequence is a run: a base, an optional selector, then *joiner + component + optional selector* groups. """ def line(text: str) -> list: return [("", char) for char in text] def texts(fragments: list) -> list: return [text for _style, text in fragments] # A line with no joiner is handed straight back — the same object, not a copy. plain = line("hi 📡") assert ew._join_zwj_clusters(plain) is plain # The shrug's four fragments become one; the bars around it are untouched. assert texts(ew._join_zwj_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"] # Three joined people, five fragments, still one glyph. assert texts(ew._join_zwj_clusters(line(_FAMILY))) == [_FAMILY] # A selector on the *base*, before the joiner, belongs to the run: â¤ī¸â€đŸ”Ĩ. burning = "â¤ī¸â€\U0001f525" assert texts(ew._join_zwj_clusters(line(burning))) == [burning] # A bare VS16 pair is not a sequence — prompt_toolkit already folds it into the cell # before it, so those fragments are left exactly as they came. assert texts(ew._join_zwj_clusters(line(f"â˜€ī¸{_SHRUG}"))) == ["☀", "ī¸", _SHRUG] # A trailing joiner with nothing to join is not a sequence either. assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ] # A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit # build one Char of it instead of one per codepoint. (merged,) = texts(ew._join_zwj_clusters(line(_SHRUG))) assert list(merged) == [_SHRUG] and merged == _SHRUG def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch) -> None: """The delivery half: one ``Char`` per glyph, with every codepoint still written out. Measuring the sequence right is not enough on its own — prompt_toolkit lays out one codepoint at a time, so an uncorrected screen puts the male sign in a cell of its own and everything after it a column late. The control merges the run, so the window makes a single two-cell ``Char`` of it and the text after lands where the terminal draws it. The codepoints themselves are untouched: all four still reach the screen, in order, so the terminal composes the same glyph. """ monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False) monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False) from prompt_toolkit.application import Application from prompt_toolkit.application.current import set_app from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.input import DummyInput from prompt_toolkit.layout import Layout, Window from prompt_toolkit.layout.mouse_handlers import MouseHandlers from prompt_toolkit.layout.screen import Screen, WritePosition from prompt_toolkit.output import DummyOutput text = f"|{_SHRUG}|{_FAMILY}|end" def paint() -> list: window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True) app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput()) with set_app(app): screen = Screen(default_char=None, initial_width=40, initial_height=1) window.write_to_screen( screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None ) return screen.data_buffer[0] snap = _snapshot() try: ew._CALIBRATED = False ew.calibrate(force_width=1) row = paint() # Each sequence is one cell-occupying Char, two cells wide, exactly as drawn. assert (row[1].char, row[1].width) == (_SHRUG, 2) assert (row[4].char, row[4].width) == (_FAMILY, 2) # So the text after them starts at column 6 rather than 13 — seven cells of border # drift removed (one for the shrug, four for the family, two from the second bar). assert "".join(row[x].char for x in range(6, 10)) == "|end" # Nothing was dropped on the way: the row still spells the source exactly. assert "".join(row[x].char for x in range(40)).rstrip() == text finally: _restore(snap)