diff --git a/changelog.d/text-measure-seam.md b/changelog.d/text-measure-seam.md new file mode 100644 index 00000000..43746634 --- /dev/null +++ b/changelog.d/text-measure-seam.md @@ -0,0 +1 @@ +fix: **Labels no longer wrap their last glyph**: render-time pixel snapping rounds each frame edge on its own and can shave a whole device pixel off a label sized exactly to its text, but the wrap budget only handed back half of that, so labels such as "Start" painted as "Star" / "t". The Native SDK now hands back the full pixel, and a capture fixture pins one laid-out line per text node. diff --git a/runtime/native-sdk b/runtime/native-sdk index 9a499f16..464ff65f 160000 --- a/runtime/native-sdk +++ b/runtime/native-sdk @@ -1 +1 @@ -Subproject commit 9a499f16ec01e498208def9f239fca4780eeecad +Subproject commit 464ff65faa02d36179b34e2252fa2a1cf46f7541 diff --git a/scripts/release-audit.mjs b/scripts/release-audit.mjs index 5cdde931..b15e2246 100755 --- a/scripts/release-audit.mjs +++ b/scripts/release-audit.mjs @@ -7,7 +7,7 @@ import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const expectedNativeCommit = "9a499f16ec01e498208def9f239fca4780eeecad"; +const expectedNativeCommit = "464ff65faa02d36179b34e2252fa2a1cf46f7541"; function filesBelow(root) { const output = []; diff --git a/test/capture-smoke.mjs b/test/capture-smoke.mjs index 28d98a99..b350e753 100644 --- a/test/capture-smoke.mjs +++ b/test/capture-smoke.mjs @@ -61,6 +61,12 @@ try { assert.match(snapshotText(noro), /role=button name="Seek"/); assert.match(snapshotText(noro), /name="00:42"/); + // Content-sized labels must hold their run on one line: the number of + // laid-out text lines equals the number of text nodes. + const textWrap = capture("text-wrap", "test/fixtures/text-wrap"); + assert.equal(textNodeCount(textWrap), 5); + assert.equal(textLineCount(textWrap), 5); + const noroSignal = capture("noro-signal", "examples/noro-signal", [ "--provider-fixture", join(root, "test", "capture", "noro.provider.json"), ]); @@ -68,6 +74,7 @@ try { assert.match(snapshotText(noroSignal), /role=button name="Seek"/); assert.match(snapshotText(noroSignal), /role=button name="PAUSE"/); assert.match(snapshotText(noroSignal), /name="00:42"/); + assert.equal(textLineCount(noroSignal), textNodeCount(noroSignal)); const noroSignalAction = capture("noro-signal-action", "examples/noro-signal", [ "--provider-fixture", join(root, "test", "capture", "noro-action.provider.json"), @@ -269,3 +276,13 @@ function recordClockSession() { function snapshotText(receipt) { return readFileSync(receipt.output.snapshot, "utf8"); } + +function textLineCount(receipt) { + const match = /text_layout_lines=(\d+)\//.exec(snapshotText(receipt)); + assert.ok(match, "snapshot header is missing text_layout_lines"); + return Number(match[1]); +} + +function textNodeCount(receipt) { + return snapshotText(receipt).split("\n").filter((line) => line.includes(" role=text ")).length; +} diff --git a/test/fixtures/text-wrap/tsconfig.json b/test/fixtures/text-wrap/tsconfig.json new file mode 100644 index 00000000..318a70a8 --- /dev/null +++ b/test/fixtures/text-wrap/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "@weaver/sdk", + "types": [], + "baseUrl": ".", + "paths": { + "@weaver/sdk": ["../../../sdk/index.d.ts"], + "@weaver/sdk/jsx-runtime": ["../../../sdk/jsx-runtime.d.ts"] + } + }, + "include": ["widget.tsx"] +} diff --git a/test/fixtures/text-wrap/widget.tsx b/test/fixtures/text-wrap/widget.tsx new file mode 100644 index 00000000..788c7b4f --- /dev/null +++ b/test/fixtures/text-wrap/widget.tsx @@ -0,0 +1,28 @@ +import { widget } from "@weaver/sdk"; + +// Regression fixture: labels sized exactly to their measured text, placed +// at fractional x positions by centering. Each must lay out on one +// line. Render-time pixel snapping rounds each frame edge on its own and +// can shave up to a whole device pixel off such a frame; the wrap budget +// used to hand back only half of that, so "Start" painted as "Star" / "t". +// The chip row is sized with room to spare so no flex shrink applies: +// a row that overflows its container shrinks its children and wraps them +// legitimately. +export default widget({ + name: "Text Wrap", + size: [300, 140], +}, () => ( + + + Focus + Short break + Long break + + + Start + + + fixture + + +));