shaping: a VS16 inside a keycap sequence breaks the keycap ligature #30

Closed
opened 2026-10-05 01:19:41 +09:00 by natsukium · 0 comments
Owner

A keycap emoji (#️⃣, 1️⃣) draws as three glyphs instead of one: the base, a .notdef, and the enclosing keycap. Since #29 the cluster is fitted into its two cells, so it stays inside them but comes out much smaller than kitty or WezTerm draw it.

Cause: swash maps a VS16 that sits inside the sequence to .notdef and gives it a full advance. The .notdef breaks the font's keycap ligature. Shaped against the pinned Noto Color Emoji at 18 px:

Input Glyphs (id, advance)
# U+20E3 (1482, 22.4): one keycap glyph
# U+FE0F U+20E3 (4, 22.4), (0, 22.4), (21, 22.4)
1 U+20E3 (1485, 22.4)
❤ U+FE0F, ☺ U+FE0F, 😀 U+FE0F one glyph each

A trailing VS16 is absorbed. Only a selector followed by more of the cluster breaks, and the keycap sequence is the common case. HarfBuzz treats a default-ignorable code point the face does not map as invisible: lookups skip it and it gets no advance. That is why the other terminals ligate keycaps.

Fix direction:

  • In felis: before shaping a cluster, drop the variation selectors the resolved face does not map. A variation selector only picks the presentation, and felis has already chosen the presentation by picking the face. The rule depends on the face's coverage, not on the text.
  • Upstream: report it to swash (default-ignorables should be skipped during GSUB and zero-width when unmapped), and drop the felis-side filter once a release fixes it.
  • Test: shape #️⃣ against the pinned emoji face and expect one glyph, and a fitted keycap should no longer be needed (fit_px == 0 in two cells).

Related: #14 (which face an emoji-presentation character resolves to) is a separate question. This issue is about what the resolved face is given to shape.

A keycap emoji (`#️⃣`, `1️⃣`) draws as three glyphs instead of one: the base, a `.notdef`, and the enclosing keycap. Since #29 the cluster is fitted into its two cells, so it stays inside them but comes out much smaller than kitty or WezTerm draw it. **Cause:** swash maps a VS16 that sits inside the sequence to `.notdef` and gives it a full advance. The `.notdef` breaks the font's keycap ligature. Shaped against the pinned Noto Color Emoji at 18 px: | Input | Glyphs (id, advance) | | --- | --- | | `#` U+20E3 | `(1482, 22.4)`: one keycap glyph | | `#` U+FE0F U+20E3 | `(4, 22.4)`, `(0, 22.4)`, `(21, 22.4)` | | `1` U+20E3 | `(1485, 22.4)` | | `❤` U+FE0F, `☺` U+FE0F, `😀` U+FE0F | one glyph each | A trailing VS16 is absorbed. Only a selector followed by more of the cluster breaks, and the keycap sequence is the common case. HarfBuzz treats a default-ignorable code point the face does not map as invisible: lookups skip it and it gets no advance. That is why the other terminals ligate keycaps. **Fix direction:** - In felis: before shaping a cluster, drop the variation selectors the resolved face does not map. A variation selector only picks the presentation, and felis has already chosen the presentation by picking the face. The rule depends on the face's coverage, not on the text. - Upstream: report it to swash (default-ignorables should be skipped during GSUB and zero-width when unmapped), and drop the felis-side filter once a release fixes it. - Test: shape `#️⃣` against the pinned emoji face and expect one glyph, and a fitted keycap should no longer be needed (`fit_px == 0` in two cells). Related: #14 (which face an emoji-presentation character resolves to) is a separate question. This issue is about what the resolved face is given to shape.
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
felis-terminal/felis#30
No description provided.