diff --git a/src/clayterm.c b/src/clayterm.c index 2af9afd..44fca04 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -285,6 +285,20 @@ static void render_text(struct Clayterm *ct, int x0, int y0, n = 1; cp = 0xfffd; } + /* grapheme cluster: base codepoint + U+FE0F (variation selector-16) + * forms a single emoji-presentation glyph of width 2. Place the base + * codepoint into the cell (never FE0F) and advance x by 2. */ + if (rem - n > 0) { + uint32_t next; + int n2 = utf8_decode(&next, p + n); + if (n2 > 0 && n2 <= rem - n && next == 0xfe0f) { + setcell(ct, x, y0, cp, fg, ATTR_DEFAULT); + x += 2; + p += n + n2; + rem -= n + n2; + continue; + } + } int cw = wcwidth(cp); if (cw < 0) cw = 1; @@ -690,6 +704,18 @@ void measure(int ret, int txt) { if (n <= 0) { n = 1; } + /* grapheme cluster: base codepoint + U+FE0F (variation selector-16) + * forms a single emoji-presentation glyph of width 2 */ + if (rem - n > 0) { + uint32_t next; + int n2 = utf8_decode(&next, p + n); + if (n2 > 0 && n2 <= rem - n && next == 0xfe0f) { + w += 2; + p += n + n2; + rem -= n + n2; + continue; + } + } int cw = wcwidth(cp); if (cw > 0) w += cw; diff --git a/test/width.test.ts b/test/width.test.ts new file mode 100644 index 0000000..06b6008 --- /dev/null +++ b/test/width.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fit, open, text } from "../ops.ts"; +import { print } from "./print.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +const VS16 = "\u{1F321}\u{FE0F}\u{26A0}\u{FE0F}\u{2705}"; // 🌡️⚠️✅ + +describe("emoji width", () => { + let term: Term; + beforeEach(async () => { + term = await createTerm({ width: 24, height: 3 }); + }); + + it("measures base emoji + U+FE0F as width-2 clusters", () => { + let r = term.render([ + open("box", { layout: { width: fit(), height: fit() } }), + text(VS16), + close(), + ]); + // three emoji-presentation clusters at width 2 each => 6, not 4 + expect(r.info.get("box")!.bounds.width).toBe(6); + }); + + it("upgrades a single base+FE0F cluster (⚠️) to width 2", () => { + let r = term.render([ + open("box", { layout: { width: fit(), height: fit() } }), + text("\u{26A0}\u{FE0F}"), // ⚠️ + close(), + ]); + // ⚠ alone is text-presentation width 1; +U+FE0F selects emoji width 2 + expect(r.info.get("box")!.bounds.width).toBe(2); + }); + + it("renders each base+FE0F as a width-2 cluster and drops the selector", () => { + let r = term.render([ + open("box", { layout: { width: fit(), height: fit() } }), + text(VS16), + close(), + ]); + let grid = print(decode(r.output), 24, 3); + // each cluster occupies two columns (base glyph + trailing cell), so the + // bases land on even columns; U+FE0F is consumed, never emitted. + expect(grid.split("\n")[0].trimEnd()).toBe("🌡 ⚠ ✅"); + expect(grid).not.toContain("\u{FE0F}"); + }); +});