add-narrow-emoji: teach the skill the direction it grew
The width machinery gained a *wide* set — glyphs the font paints in two cells where Rich and wcwidth both say one, so they overrun a padded row instead of notching a border — but the skill never learned about it. classify.py still called _make_cell_len(narrow) and died on the signature; its "lone narrow codepoint" verdict said "no change needed", which is exactly wrong for the glyph that motivated the wide set (🛣 U+1F6E3); and nothing reported that a glyph was already listed, which is the commonest answer to "please add this one". Both sets are now applied, both directions documented, and every branch says whether the decision has already been made. The frontmatter advertises the overrun symptom too, so the skill is findable from the half of the problem it could not previously see. No production change: 🛣 has been in _DEFAULT_WIDE_BASE since the set existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c68f50bf5
commit
09bae4cf43
2 files changed
+93
-41
No files matched your search
@@ -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.
|
||||
@@ -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 <emoji>")
|
||||
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}")
|
||||
|
||||
Reference in new issue
Block a user