diff --git a/.changeset/text-measure-float-slack.md b/.changeset/text-measure-float-slack.md new file mode 100644 index 00000000..85124c2e --- /dev/null +++ b/.changeset/text-measure-float-slack.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Allow 0.01px of slack on the width limit when measuring text. Yoga hands the width back as a float32, which can land a hair under the text's own width and split a short word across lines ("TV-14" as "TV-1" / "4"). diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index dc9c4810..953fdfcc 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -37,6 +37,7 @@ const MAX_SIZEOF_UPDATE = 1024 * 10; // Grow-only text remeasure converges monotonically (1-3 passes in practice). // This is a runaway backstop, not an expected limit. const MAX_LAYOUT_PASSES = 10; +const TEXT_MEASURE_SLACK = 0.01; export class YogaManager { private _elementMap: Map = new Map(); @@ -206,8 +207,12 @@ export class YogaManager { return { width: 0, height: 0 }; } + // Yoga hands the width back as a float32, which can land a hair under the + // text's own width and wrap a single word ("TV-14" -> "TV-1" / "4"). const maxWidth = - widthMode === 0 || !Number.isFinite(availableWidth) ? Infinity : availableWidth; + widthMode === 0 || !Number.isFinite(availableWidth) + ? Infinity + : availableWidth + TEXT_MEASURE_SLACK; return layoutText(font, text.props, maxWidth); } diff --git a/packages/plugin-flexbox/src/measureText.integration.test.ts b/packages/plugin-flexbox/src/measureText.integration.test.ts index 7cd7f91b..55420d86 100644 --- a/packages/plugin-flexbox/src/measureText.integration.test.ts +++ b/packages/plugin-flexbox/src/measureText.integration.test.ts @@ -196,3 +196,55 @@ describe('Yoga text measurement (real yoga)', () => { } }); }); + +describe('Yoga text measurement float32 widths', () => { + // Fractional advances at a non-integer font scale (20 / 21), so the text's + // own width isn't representable as a float32 and rounds down. + const tvAtlas: AtlasData = { + info: { size: 21, face: 'TV' }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 0, + unitsPerEm: 1000, + }, + chars: [ + { id: 84, xadvance: 11.7, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // T + { id: 86, xadvance: 12.9, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // V + { id: 45, xadvance: 7.3, xoffset: 0, yoffset: 0, width: 6, height: 2 }, // - + { id: 49, xadvance: 11.1, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // 1 + { id: 52, xadvance: 11, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // 4 + { id: 32, xadvance: 5, xoffset: 0, yoffset: 0, width: 0, height: 0 }, // (space) + ], + kernings: [], + }; + + it('keeps a word on one line when yoga hands back its own width as a float32', async () => { + const manager = new YogaManager(); + await manager.init(); + + const internals = manager as unknown as { + _fontStore: { register: (f: string, d: AtlasData) => void }; + _measureText: ( + id: number, + width: number, + widthMode: number, + ) => { width: number; height: number }; + }; + + internals._fontStore.register('TV', tvAtlas); + manager.addNode(1); + manager.setTextMeasure(1, 'TV', { ...textProps, text: 'TV-14' }); + + const own = internals._measureText(1, Infinity, 0); + const float32Width = Math.fround(own.width); + + expect(float32Width).toBeLessThan(own.width); + + const measured = internals._measureText(1, float32Width, 2); + + expect(measured.height).toBe(20); + expect(measured.width).toBe(own.width); + }); +});