[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

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

Nate Moore (Jun 5, 2026, 1:13 AM -0500) c164d295 d6b08e45

+33 -14
+26
src/clayterm.c
··· 285 285 n = 1; 286 286 cp = 0xfffd; 287 287 } 288 + /* grapheme cluster: base codepoint + U+FE0F (variation selector-16) 289 + * forms a single emoji-presentation glyph of width 2. Place the base 290 + * codepoint into the cell (never FE0F) and advance x by 2. */ 291 + if (rem - n > 0) { 292 + uint32_t next; 293 + int n2 = utf8_decode(&next, p + n); 294 + if (n2 > 0 && n2 <= rem - n && next == 0xfe0f) { 295 + setcell(ct, x, y0, cp, fg, ATTR_DEFAULT); 296 + x += 2; 297 + p += n + n2; 298 + rem -= n + n2; 299 + continue; 300 + } 301 + } 288 302 int cw = wcwidth(cp); 289 303 if (cw < 0) 290 304 cw = 1; ··· 689 703 int n = utf8_decode(&cp, p); 690 704 if (n <= 0) { 691 705 n = 1; 706 + } 707 + /* grapheme cluster: base codepoint + U+FE0F (variation selector-16) 708 + * forms a single emoji-presentation glyph of width 2 */ 709 + if (rem - n > 0) { 710 + uint32_t next; 711 + int n2 = utf8_decode(&next, p + n); 712 + if (n2 > 0 && n2 <= rem - n && next == 0xfe0f) { 713 + w += 2; 714 + p += n + n2; 715 + rem -= n + n2; 716 + continue; 717 + } 692 718 } 693 719 int cw = wcwidth(cp); 694 720 if (cw > 0)
+7 -14
test/vs16-emoji-width.test.ts
··· 1 1 import { beforeEach, describe, expect, it } from "./suite.ts"; 2 2 import { createTerm, type Term } from "../term.ts"; 3 - import { close, fit, fixed, open, rgba, text } from "../ops.ts"; 3 + import { close, fit, open, text } from "../ops.ts"; 4 4 import { print } from "./print.ts"; 5 5 6 6 const decode = (b: Uint8Array) => new TextDecoder().decode(b); ··· 33 33 expect(r.info.get("box")!.bounds.width).toBe(2); 34 34 }); 35 35 36 - it("fitted bordered box around ๐ŸŒก๏ธโš ๏ธโœ… is 8 cells wide", () => { 36 + it("renders each base+FE0F as a width-2 cluster and drops the selector", () => { 37 37 let r = term.render([ 38 - open("box", { 39 - layout: { width: fit(), height: fixed(3) }, 40 - border: { 41 - color: rgba(255, 255, 255), 42 - left: 1, 43 - right: 1, 44 - top: 1, 45 - bottom: 1, 46 - }, 47 - }), 38 + open("box", { layout: { width: fit(), height: fit() } }), 48 39 text(VS16), 49 40 close(), 50 41 ]); 51 42 let grid = print(decode(r.output), 24, 3); 52 - // 6 content cols + 2 border cols => top border โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ” (8 wide) 53 - expect(grid.split("\n")[0]).toBe("โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”" + " ".repeat(16)); 43 + // each cluster occupies two columns (base glyph + trailing cell), so the 44 + // bases land on even columns; U+FE0F is consumed, never emitted. 45 + expect(grid.split("\n")[0].trimEnd()).toBe("๐ŸŒก โš  โœ…"); 46 + expect(grid).not.toContain("\u{FE0F}"); 54 47 }); 55 48 });