Add intrinsic sizing APIs: minContentWidth and maxContentWidth#45
Add intrinsic sizing APIs: minContentWidth and maxContentWidth#45ishtihoss wants to merge 1 commit intochenglou:mainfrom
Conversation
CSS-standard intrinsic width queries over prepared text, answering the open design question in TODO.md. Both are pure arithmetic over cached segment widths — no DOM reads, no canvas calls — consistent with the layout() hot-path philosophy. minContentWidth: narrowest container where no word needs grapheme-level breaking. Accounts for soft-hyphen discretionary width at break points. maxContentWidth: single-line width with no soft wrapping, excluding trailing whitespace. For pre-wrap text with hard breaks, returns the widest chunk. Handles tab-stop advances. Co-Authored-By: ishtihoss <dexclaw@gmail.com>
There was a problem hiding this comment.
Code Review
What works well
- API surface: Both functions accept
PreparedText(not justPreparedTextWithSegments), so they compose with both the opaque fast path and the rich path. - Soft-hyphen handling in
minContentWidth: The discretionary hyphen width look-ahead (line 727–729) correctly models the CSS min-content contract; the break at a SHY activates a visible hyphen, so the widest atomic unit must account for that. - Trailing whitespace exclusion in
maxContentWidth: ThecontentWidth/lineWidthsplit is clean. Tabs advance position but only count towardcontentWidthwhen followed by real content. Trailing spaces and tabs are excluded. This matches CSS max-content. getTabAdvanceexport: Reusing the existing function fromline-break.tsinstead of duplicating logic. Minimal, surgical change.- Hard-break chunk logic:
maxContentWidthcorrectly resets per hard-break chunk and returns the widest one, which is exactly CSS max-content forpre-wrap. - Test coverage: 12 tests covering empty, whitespace-only, single word, multi-word, trailing whitespace, soft hyphens, pre-wrap hard breaks, CJK, and cross-prepare-path consistency. The
layoutround-trip tests (lines 681–694) are particularly valuable — they verify the invariant that actually matters:layout(p, maxContentWidth(p), lh).lineCount === 1andlayout(p, minContentWidth(p), lh)has no grapheme-level word breaks.
One thing to be aware of
The NBSP/glue case is actually fine here because the preprocessing in analysis.ts (lines 695–738) merges text + glue + text into single text segments before measurement. So "Total:\u00A0$100" arrives as one segment with combined width, and minContentWidth returns the correct unbreakable-unit width. But it would be worth adding a test that makes this explicit — something like:
test('NBSP glue: min treats non-breaking space as unbreakable', () => {
const withNbsp = prepareWithSegments('Total:\u00A0$100', FONT)
const withSpace = prepareWithSegments('Total: $100', FONT)
// NBSP binds into one unbreakable unit, so min should be wider than space version
expect(minContentWidth(withNbsp)).toBeGreaterThan(minContentWidth(withSpace))
})This documents the invariant and guards against a future preprocessing refactor accidentally unbundling glue from text.
Minor nits (non-blocking)
-
Trailing SHY edge case —
minContentWidth("word\u00AD")currently returnsword + hyphenWidth, but a trailing SHY never activates (no content after it to wrap to the next line). The true CSS min-content is justword. This is conservative (wider than necessary), not incorrect, and unlikely to matter in practice. -
Tab test gap — There's no explicit test for
maxContentWidthwith tabs (e.g.,"\thello\tworld"in pre-wrap). The logic looks correct from reading the code, but a small test would lock it in.
Summary
minContentWidth()andmaxContentWidth()— CSS-standard intrinsic width queries over prepared text, addressing the open design question in TODO.mdlayout()hot-path philosophyminContentWidth: narrowest container where no word needs grapheme-level breaking; accounts for soft-hyphen discretionary widthmaxContentWidth: single-line width with no wrapping, excluding trailing whitespace; handles pre-wrap hard breaks and tab stopsTest plan
layout(prepared, maxContentWidth(prepared), lineHeight).lineCount === 1andlayout(prepared, minContentWidth(prepared), lineHeight)produces no grapheme-level word breaksbun run check)