diff --git a/.claude/skills/add-narrow-emoji/SKILL.md b/.claude/skills/add-narrow-emoji/SKILL.md index 3675016..135bb94 100644 --- a/.claude/skills/add-narrow-emoji/SKILL.md +++ b/.claude/skills/add-narrow-emoji/SKILL.md @@ -1,24 +1,34 @@ --- name: add-narrow-emoji -description: Fix a ragged/short right border on a MeshTerm chat row that contains an emoji the terminal draws narrower than it measures. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster) and adds it to the terminal-aligned width handling the correct way — a lone codepoint joins the allowlist; flags and VS16 are already handled; ZWJ clusters need a maintainer. Use when a chat message with an emoji notches the panel's right border. +description: Fix a MeshTerm row whose layout an emoji breaks — a right border pulled a column short, or a glyph overrunning its lane — because the terminal draws it in a different number of cells than Rich/prompt_toolkit measure. Works in both directions: a glyph drawn narrower than measured joins the narrow allowlist, one drawn wider joins the wide set. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster), says whether it is already listed, and adds it the correct way. Use whenever an emoji "fucks up the layout", notches a panel border, or smears a row. --- -# Adding a narrow emoji to MeshTerm's border-alignment allowlist +# Adding an emoji to MeshTerm's terminal-aligned width sets MeshTerm aligns Rich's and prompt_toolkit's glyph widths with how the terminal -*actually draws* an emoji, so a chat row frames flush instead of notching its right -border a column (or more) short. The machinery lives in -[meshterm/ui/tui/emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design -is in that module's docstring and in the `glyph-width-alignment` memory. Background: -the terminal's narrow/wide split is **per-glyph and font-driven** — there is no rule and -no way to probe it — so lone emoji are a hand-curated allowlist, extended one glyph at a -time as they are confirmed. This skill does that one addition correctly. +*actually draws* an emoji, so a row frames flush instead of notching its right border a +column short or overrunning its lane. The machinery lives in +[emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design is in that module's +docstring and in the `glyph-width-alignment` memory. Background: the terminal's +narrow/wide split is **per-glyph and font-driven** — there is no rule and no way to probe +it (a cursor probe reads the PTY, not the renderer) — so the exceptions are two +hand-curated sets, extended one confirmed glyph at a time. This skill does that one +addition correctly. ## When to run -A chat message containing an emoji renders with its right border pulled in (a blank sits -between the border and the frame edge), or a smear to the right of the glyph. That is the -symptom of the terminal drawing the glyph in fewer cells than Rich/prompt_toolkit reserve. +An emoji in a row breaks its layout, in either of two ways: + +- **Border pulled in** (a blank sits between the border and the frame edge), or a smear to + the right of the glyph — the terminal draws it in *fewer* cells than the authorities + reserve. → the narrow allowlist (`_DEFAULT_NARROW_LONE`). +- **The row overruns**, pushing what follows a column right — the terminal draws it in + *more* cells than the authorities reserve. → the wide set (`_DEFAULT_WIDE_BASE`). This is + the case for an emoji outside Emoji_Presentation, which wcwidth and Rich both call one + cell while the font paints a two-cell colour glyph. + +Step 1 tells you which; it also tells you when the glyph is **already listed**, which is +the commonest answer to "please add this one". ## Steps @@ -32,26 +42,37 @@ symptom of the terminal drawing the glyph in fewer cells than Rich/prompt_toolki and after the fix (`Rich fixed`, `pt fixed`), the class, and the action. `pt now` is the number that matters — prompt_toolkit places the panel border. -2. **Act on the class:** - - **lone wide codepoint** (e.g. `👋`, `👍`): the allowlist case. Only add it **if the - terminal draws it in one cell** — confirm visually first. Append the character to +2. **Act on the class.** The machinery runs in *both* directions — a glyph drawn narrower + than measured joins `_DEFAULT_NARROW_LONE`, one drawn wider joins `_DEFAULT_WIDE_BASE` + — and the classifier names which, including "already listed", the commonest answer: + - **already listed** (either set): nothing to add. If a row still misaligns, some + *other* glyph on it is the culprit — classify that one. + - **lone wide codepoint** (e.g. `👋`, `👍`): the narrow-allowlist case. Only add it **if + the terminal draws it in one cell** — confirm visually first. Append the character to `_DEFAULT_NARROW_LONE` in [emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py): `_DEFAULT_NARROW_LONE = "👋👍"`. (For a throwaway session test instead of a code change, set `MESHTERM_NARROW_EMOJI="👋👍"` in the environment.) + - **lone narrow codepoint** (e.g. `🛣` U+1F6E3): both authorities already say one. If + the font nonetheless paints a two-cell colour glyph — an emoji outside + Emoji_Presentation whose East-Asian width is Neutral — it *overruns* every row that + pads to a width; nothing narrowed it, it was never measured right. Add it to + `_DEFAULT_WIDE_BASE` (or `MESHTERM_WIDE_EMOJI` for a session test). Otherwise no change. + - **VS16 emoji-presentation sequence** (e.g. `☀️`): **handled by default** (the variation + selector is skipped, so the base is measured alone). But the renderer is not uniform — + if *this* one paints two cells wide, add its **base** codepoint to `_DEFAULT_WIDE_BASE`, + as `🛩️` (U+1F6E9) needed. Keying on the base makes the bare form ride along. - **flag (Regional Indicator pair)** (e.g. `🇨🇦`): **already handled** as a whole category — no entry. Confirm `pt fixed` is 2 and stop. Never list an individual flag: indicators are shared, so it would half-fix that flag's neighbours and skip the rest. - - **VS16 emoji-presentation sequence** (e.g. `☀️`): **already handled** (the variation - selector is skipped). No entry. - **ZWJ sequence / keycap / skin-tone / other cluster** (e.g. `👨‍👩‍👧`, `1️⃣`, `👋🏽`): - **not covered** by the lone/flag mechanism — narrowing its parts individually would - misalign it. Do not force it into the allowlist; note it for bespoke handling. + **not covered** by the lone/flag mechanism — resizing its parts individually would + misalign it. Do not force it into either set; note it for bespoke handling. 3. **Extend the tests.** In [tests/test_emoji_width.py](../../..//tests/test_emoji_width.py), add the new glyph to `test_rich_cell_len_narrows_only_allowlisted_lone_emoji` and - `test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell), and to - the width-1 calibrate test. Keep a known-wide emoji (`📡`) asserted at two, so a future - over-broad change is caught. + `test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell for a + narrow addition, two for a wide one), and to the width-1 calibrate test. Keep a + known-wide emoji (`📡`) asserted at two, so a future over-broad change is caught. 4. **Run the tests:** @@ -68,12 +89,13 @@ symptom of the terminal drawing the glyph in fewer cells than Rich/prompt_toolki ## Guardrails -- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`). The - fix only helps a glyph drawn *narrower* than measured; narrowing a correctly-wide glyph - introduces the notch instead of removing it. When unsure, leave it out. -- **One glyph, confirmed, at a time.** An unlisted narrow emoji is no worse than before - (the `erase_down` full-repaint still prevents its smear); a wrongly-listed one breaks a - border. Bias to omission. +- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`), and + never widen one it draws in one cell. Each set only helps glyphs mismeasured in its own + direction; a wrong entry *introduces* the misalignment it was meant to remove. When + unsure, leave it out. +- **One glyph, confirmed, at a time.** An unlisted emoji is no worse than before (the + `erase_down` full-repaint still prevents a narrow one's smear); a wrongly-listed one + breaks a border. Bias to omission. - **Do not touch Rich/prompt_toolkit width globally.** A blanket emoji remap has been tried and reverted twice — it wrecks the menu icons the terminal genuinely draws two wide. The curated allowlist and the flag category are the only sanctioned levers. diff --git a/.claude/skills/add-narrow-emoji/classify.py b/.claude/skills/add-narrow-emoji/classify.py index 53defb7..71b79dd 100644 --- a/.claude/skills/add-narrow-emoji/classify.py +++ b/.claude/skills/add-narrow-emoji/classify.py @@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth # noqa: E402 from meshterm.ui.tui.emoji_width import ( # noqa: E402 _DEFAULT_NARROW_LONE, + _DEFAULT_WIDE_BASE, _is_regional_indicator, _make_cell_len, _make_pt_cache, @@ -37,13 +38,38 @@ from meshterm.ui.tui.emoji_width import ( # noqa: E402 _ZWJ = "‍" _VS16 = "️" +_NARROW = frozenset(_DEFAULT_NARROW_LONE) +_WIDE = frozenset(_DEFAULT_WIDE_BASE) - frozenset((_ZWJ, _VS16)) + + +def _base(emoji: str) -> str: + """The emoji's base codepoint — what the wide set is keyed on (selectors dropped).""" + others = [c for c in emoji if c not in (_VS16, _ZWJ)] + return others[0] if len(others) == 1 else "" + def _classify(emoji: str) -> tuple[str, str]: """Return ``(label, action)`` for ``emoji`` — what it is and what the skill should do.""" - cps = [ord(c) for c in emoji] - if not cps: + if not emoji: return "empty", "Nothing to classify — pass an emoji as the argument." - if all(_is_regional_indicator(c) for c in emoji) and len(emoji) == 2: + + # Already curated? Say so first — it is the commonest answer to "please add this one", + # and every branch below would otherwise re-derive advice for a decision already made. + base = _base(emoji) + if base and base in _WIDE: + return ( + "lone codepoint drawn WIDE — already listed", + "ALREADY IN _DEFAULT_WIDE_BASE (both authorities measure it 2, bare and with its " + "VS16 selector). Nothing to add. If a row still misaligns, the culprit is another " + "glyph on it — classify that one instead.", + ) + if len(emoji) == 1 and emoji in _NARROW: + return ( + "lone codepoint drawn NARROW — already listed", + "ALREADY IN _DEFAULT_NARROW_LONE (both authorities measure it 1). Nothing to add.", + ) + + if len(emoji) == 2 and all(_is_regional_indicator(c) for c in emoji): return ( "flag (Regional Indicator pair)", "ALREADY HANDLED as a category — no allowlist entry. Verify pt width is 2 below; " @@ -54,7 +80,7 @@ def _classify(emoji: str) -> tuple[str, str]: return ( "ZWJ sequence (multi-glyph cluster)", "NOT covered by the lone/flag mechanism (a family, a profession, a flag-with-tag). " - "Do not add it to the allowlist — the parts would be narrowed individually and " + "Do not add it to either set — the parts would be resized individually and " "misalign. Flag this to a maintainer for bespoke handling.", ) if len(emoji) == 1: @@ -67,19 +93,23 @@ def _classify(emoji: str) -> tuple[str, str]: ) return ( "lone narrow codepoint", - "Already one cell everywhere — it never notches a border. No change needed.", + "Both authorities measure it ONE. If your terminal draws it that way, no change is " + "needed. If the font draws it as a two-cell colour glyph (an emoji outside " + "Emoji_Presentation, e.g. U+1F6E3 🛣), it overruns every row that pads to a width — " + "add it to _DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI for a session test).", ) - others = [c for c in emoji if c not in (_VS16, _ZWJ)] - if len(others) == 1 and _VS16 in emoji: + if base and _VS16 in emoji: return ( "VS16 emoji-presentation sequence", - "ALREADY HANDLED (the variation selector is skipped so the base is measured alone). " - "No allowlist entry needed.", + "HANDLED BY DEFAULT (the variation selector is skipped, so the base is measured " + "alone and the narrow-VS16 verdict applies). If your terminal draws THIS one two " + "cells wide anyway — the renderer is not uniform — add its BASE codepoint to " + "_DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI), as U+1F6E9 🛩️ needed.", ) return ( "multi-codepoint cluster (keycap / skin-tone / other)", "NOT a plain lone codepoint — the lone/flag mechanism does not cover it. Flag this to a " - "maintainer rather than adding parts to the allowlist.", + "maintainer rather than adding parts to either set.", ) @@ -88,7 +118,6 @@ def main(argv: list[str]) -> int: print("usage: classify.py ") return 2 emoji = argv[1] - narrow = frozenset(_DEFAULT_NARROW_LONE) label, action = _classify(emoji) print(f"emoji : {emoji}") @@ -98,8 +127,9 @@ def main(argv: list[str]) -> int: + ", ".join(str(wcwidth(c)) for c in emoji) + ")") print(f"Rich now : {rc.cell_len(emoji)}") print(f"pt now : {get_cwidth(emoji)} <- the authority that places the panel border") - print(f"Rich fixed : {_make_cell_len(narrow)(emoji)}") - print(f"pt fixed : {_make_pt_cache(narrow)[emoji]}") + # "fixed" = what the width-1 calibration path already produces today, both sets applied. + print(f"Rich fixed : {_make_cell_len(_NARROW, _WIDE)(emoji)}") + print(f"pt fixed : {_make_pt_cache(_NARROW, _WIDE)[emoji]}") print() print(f"class : {label}") print(f"action : {action}")