Skip to content

Add intrinsic sizing APIs: minContentWidth and maxContentWidth#45

Open
ishtihoss wants to merge 1 commit intochenglou:mainfrom
ishtihoss:add-intrinsic-width-apis
Open

Add intrinsic sizing APIs: minContentWidth and maxContentWidth#45
ishtihoss wants to merge 1 commit intochenglou:mainfrom
ishtihoss:add-intrinsic-width-apis

Conversation

@ishtihoss
Copy link
Copy Markdown

@ishtihoss ishtihoss commented Mar 30, 2026

Summary

  • Adds minContentWidth() and maxContentWidth() — CSS-standard intrinsic width queries over prepared text, addressing the open design question in TODO.md
  • Both are pure arithmetic over cached segment widths (no DOM, no canvas), consistent with the layout() hot-path philosophy
  • minContentWidth: narrowest container where no word needs grapheme-level breaking; accounts for soft-hyphen discretionary width
  • maxContentWidth: single-line width with no wrapping, excluding trailing whitespace; handles pre-wrap hard breaks and tab stops

Test plan

  • All 73 tests pass (12 new intrinsic width invariant tests)
  • TypeScript type-checks cleanly
  • Key invariants verified: layout(prepared, maxContentWidth(prepared), lineHeight).lineCount === 1 and layout(prepared, minContentWidth(prepared), lineHeight) produces no grapheme-level word breaks
  • Verify lint passes (bun run check)

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>
Copy link
Copy Markdown
Author

@ishtihoss ishtihoss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

What works well

  • API surface: Both functions accept PreparedText (not just PreparedTextWithSegments), 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: The contentWidth / lineWidth split is clean. Tabs advance position but only count toward contentWidth when followed by real content. Trailing spaces and tabs are excluded. This matches CSS max-content.
  • getTabAdvance export: Reusing the existing function from line-break.ts instead of duplicating logic. Minimal, surgical change.
  • Hard-break chunk logic: maxContentWidth correctly resets per hard-break chunk and returns the widest one, which is exactly CSS max-content for pre-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 layout round-trip tests (lines 681–694) are particularly valuable — they verify the invariant that actually matters: layout(p, maxContentWidth(p), lh).lineCount === 1 and layout(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)

  1. Trailing SHY edge caseminContentWidth("word\u00AD") currently returns word + hyphenWidth, but a trailing SHY never activates (no content after it to wrap to the next line). The true CSS min-content is just word. This is conservative (wider than necessary), not incorrect, and unlikely to matter in practice.

  2. Tab test gap — There's no explicit test for maxContentWidth with tabs (e.g., "\thello\tworld" in pre-wrap). The logic looks correct from reading the code, but a small test would lock it in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant