Skip to content

feat(api): row flexSpacer / pushRight / arrangement — main-axis row layout#240

Merged
DemchaAV merged 2 commits into
developfrom
feat/row-flex
Jun 26, 2026
Merged

feat(api): row flexSpacer / pushRight / arrangement — main-axis row layout#240
DemchaAV merged 2 commits into
developfrom
feat/row-flex

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A row distributed its width by even split, weights, or columns and packed its children at the start. Aligning a title left with a status flush right, or spreading a nav bar edge-to-edge, meant inserting hand-sized spacers. This is the main-axis half of the row-layout work (verticalAlign #239 was the cross-axis half).

What changed

  • RowBuilder.flexSpacer() / flexSpacer(grow) / pushRight() + SpacerBuilder.grow(...) — an invisible spring (a SpacerNode with a grow factor) that absorbs the row's leftover width and pushes the children around it apart; several springs share the leftover in proportion to their grow.
  • RowBuilder.arrangement(RowArrangement.{START, CENTER, END, SPACE_BETWEEN, SPACE_AROUND, SPACE_EVENLY}) — the justify-content analogue, justifying content-sized children. Flex is mutually exclusive with weights / columns (rejected at construction).

How (the hot-path discipline)

  • The flex slot math is a single shared helper RowSlots.distributeFlex, called identically by the measure (NodeDefinitionSupport.measureRow) and compile (LayoutCompiler.compileHorizontalRow) phases — the two distribution sites cannot drift (the same pattern columns() feat(api): RowBuilder.columns() — fixed / auto / weight row columns #235 used).
  • The flex path is gated behind RowSlots.hasFlexLayout(node) as the first branch in both phases; a row with no grow spacer and the default START arrangement never enters it, the existing columns/weights/even chain is literally untouched, and the cursor's flexLeading / flexExtraGap terms are 0.0 — so existing rows are byte-for-byte unchanged. (Both cursor-advance sites — composite and leaf — were updated together; a miss in one would desync placement.)

Lane: canonical DSL (RowBuilder, RowArrangement, SpacerBuilder, RowNode, SpacerNode) + a shared-engine slot helper. Purely additive → japicmp-safe (the new RowNode 13th and SpacerNode 6th components ship behind re-declared back-compat delegating constructors).

Verification

  • ./mvnw test -pl .green, 0 changed visual baselines → existing rows render identically. ./mvnw -P japicmp verify — binary-compatible.
  • RowFlexTest (11): pins placement for pushRight (status flush right), two springs sharing by grow, and CENTER / END / SPACE_BETWEEN / SPACE_AROUND / SPACE_EVENLY against a 240pt row; rejects a non-finite grow and flex combined with weights and columns.
  • A cold review confirmed the measure/compile lockstep, the byte-identical default path (both cursor sites), the justify math, and the back-compat ctors; it drove the SPACE_AROUND / SPACE_EVENLY / flex+columns tests.
  • Runnable RowFlexExample + committed preview: a pushRight() invoice header, a SPACE_BETWEEN nav bar, a CENTER footer.

This completes G6 (row main + cross axis): verticalAlign (#239) + this.

…ayout

A row distributed its width by even split, weights, or columns, and packed its
children at the start; aligning a title left with a status flush right, or
spreading a nav bar edge-to-edge, meant inserting hand-sized spacers.

flexSpacer() (and the pushRight() alias) add an invisible spring — a SpacerNode
with a grow factor — that absorbs the row's leftover width and pushes the
children around it apart; several springs share the leftover in proportion to
their grow. arrangement(RowArrangement.{START, CENTER, END, SPACE_BETWEEN,
SPACE_AROUND, SPACE_EVENLY}) justifies content-sized children instead — the
justify-content analogue. Flex is mutually exclusive with weights and columns.

The slot math is a single shared helper (RowSlots.distributeFlex) called
identically by the measure and compile phases, so the two cannot drift. The flex
path is gated behind RowSlots.hasFlexLayout as the first branch in both phases; a
row with no grow spacer and the default START arrangement never enters it, and
the cursor's leading / extra-gap terms are 0, so existing rows are byte-for-byte
unchanged (full suite green, no visual baselines moved).

Tests: RowFlexTest pins the placement for pushRight, two springs sharing by
grow, and START/CENTER/END/SPACE_BETWEEN/SPACE_AROUND/SPACE_EVENLY against a
240pt row, and rejects a non-finite grow and flex combined with weights or
columns. Example: RowFlexExample (a pushRight header, a SPACE_BETWEEN nav bar,
a CENTER footer).

@Test
void flexSpacerPushesFollowingChildrenToTheRightEdge() {
try (DocumentSession session = row(r -> r.gap(0)
@Test
void twoFlexSpacersShareTheLeftoverByGrow() {
// grow 1 then grow 3 → leftover (200-20 = 180) splits 45 / 135.
try (DocumentSession session = row(r -> r.gap(0)

@Test
void spaceBetweenPinsFirstAndLastToTheEdges() {
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.SPACE_BETWEEN)
@Test
void centerArrangementCentersTheContent() {
// 3 x 20 = 60 content, leftover 140, leading 70 → a at 20+70 = 90.
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.CENTER)

@Test
void endArrangementPushesTheContentRight() {
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.END)
void spaceAroundPutsEqualSpaceAroundEachChild() {
// 3 x 20 = 60, leftover 140, gap 140/3 ≈ 46.67, leading half-gap ≈ 23.33
// → a at 43.33, b centred at 110.
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.SPACE_AROUND)
@Test
void spaceEvenlyPutsEqualGapsIncludingTheEdges() {
// four equal 35pt gaps → a at 55, b at 110, c at 165.
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.SPACE_EVENLY)
…w doc

Add a RowFlexTest case where a SPACE_BETWEEN row's middle child is a section
(a composite column), exercising the composite cursor-advance branch with a
non-zero arrangement gap — the last child still lands flush right. Correct the
flexSpacer(grow) Javadoc to say the factor is >= 0 (0 is a rigid spacer),
matching SpacerBuilder.grow and the SpacerNode validation.
// The middle child is a section (a composite column), so its cursor advance
// goes through the composite branch — confirm the SPACE_BETWEEN extra gap is
// applied there too, pushing the last child flush right.
try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.SPACE_BETWEEN)
@DemchaAV DemchaAV merged commit 09315e3 into develop Jun 26, 2026
11 checks passed
@DemchaAV DemchaAV deleted the feat/row-flex branch June 26, 2026 07:51
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.

2 participants