From 415cdc7d9448fdf6d21a2edc70b8e61343336f5c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 02:47:38 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20row=20flexSpacer=20/=20pushRig?= =?UTF-8?q?ht=20/=20arrangement=20=E2=80=94=20main-axis=20row=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 9 + assets/readme/examples/row-flex.pdf | Bin 0 -> 1382 bytes examples/README.md | 19 ++ .../demcha/examples/GenerateAllExamples.java | 2 + .../features/layout/RowFlexExample.java | 101 +++++++++++ .../compose/document/dsl/RowBuilder.java | 55 +++++- .../compose/document/dsl/SpacerBuilder.java | 17 +- .../document/layout/LayoutCompiler.java | 31 +++- .../layout/NodeDefinitionSupport.java | 4 +- .../compose/document/layout/RowSlots.java | 120 +++++++++++++ .../compose/document/node/RowArrangement.java | 27 +++ .../demcha/compose/document/node/RowNode.java | 59 ++++++- .../compose/document/node/SpacerNode.java | 22 ++- .../compose/document/dsl/RowFlexTest.java | 167 ++++++++++++++++++ 14 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 assets/readme/examples/row-flex.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/RowFlexExample.java create mode 100644 src/main/java/com/demcha/compose/document/node/RowArrangement.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d195bfae..251689078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`RowBuilder.flexSpacer()` / `pushRight()` / `arrangement(...)` + `RowArrangement` + + `SpacerBuilder.grow(...)`** (`@since 1.9.0`). Main-axis (`justify-content`) layout + for a row. A `flexSpacer()` (or `pushRight()`) is an invisible spring that absorbs + the row's leftover width — a title stays left while a badge sits flush right; a + spacer's `grow(...)` factor sets its share. `arrangement(START / CENTER / END / + SPACE_BETWEEN / SPACE_AROUND / SPACE_EVENLY)` justifies content-sized children + instead. Flex is mutually exclusive with `weights` / `columns`. The default + (`START`, no grow) is byte-for-byte unchanged, so existing rows are unaffected. + - **`RowBuilder.verticalAlign(...)` + `RowVerticalAlign`** (`@since 1.9.0`). Seats a row's children on the cross axis within the row band, whose height is that of the tallest child: `TOP` (the default), `CENTER`, or `BOTTOM` — the `align-items` diff --git a/assets/readme/examples/row-flex.pdf b/assets/readme/examples/row-flex.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7728bb760a2c04a6e82fdba4e8a8f0417dd304ca GIT binary patch literal 1382 zcmY!laBZ^4=fsl4ocwey{jk)c;>`R! z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y zE~&}+DXAb`#U(|liMhO76?2w`9?X+66xjP)TPJkYr0$K!Hz!B0(h^n8EY9+_C~}KS_%HJL&z?n(edmr(I3ndNcmiOa4Mz z(fdb>k25XU^Ig{ahd8HIEQ{N5moNtQiBDgq|22rzmH)Q6`{q&^*JHbvS4O@~O5C^e z{LOT6FHOCC^CG?S+tc^V4nCLg^Kc9!_hp$Dy&xqI1Gmfr0#6oiXg&XF>joCCUiRss zx)WL6iY$6sHT&k5H6p%wdqNqze$<|e320SIf7*~1BbaYe6Du?OrO>}s!L8}XDs#bitK@6 zmLL9X{&#Guoo74LG-bDBYwWAtwYO^VrM6kVd|v#O>%Ii^zAC)?Np0m>uIcksm#z)3 z3%I4^@3!X+&)HWM%Ac0*$W>F_dFgKKnS~qDJH7aIU6yYCS2d%qVe!oWK~mhQr5B#{ zM%CMf>^{7z%5vh(gwJdYR`VaMx^+JM^)D9r1`)U3^GE-0TJ`<*-S@$>rPgvx3_O2y z|LSkQuck%rzq$>{q9uv3ovv5+u;#6b-dTCo{zUu6$Kmpm z)Sa)JJhQmatL-5Cku&z5%bz;UIZF?G|4}0L?s(Stc!|E6pYMK({gCvU9Khvf!6q^Hja;9tzuPLBI3xB z=qUcE^`#Q`M(K5Kduw-d{f#+u;ODb5XFfhuWcYsYV3VlQ1_LJ^F=G|Zx$)(UR@del zEZCdFd+LCm$O?y@a#tDJO#YTP@hzHKx@^A2-d6VTJH>b7cg0yo>%UoNKX;LngzdN9 z+Jos6vQ2O7bhLQ+^WZfn>G$jgvp;J;XPDgW_qcS%l4hPMSKZD`p1Z^Lh{9~CErGpx zOJ6yNue^5Z1pfy1`!dQthp>c@C1wa219N{+etwApsH_O$()V;xh_*4dv~V$Sb+&Z0 za5XZpFm^R`G&FQGHnDItcXoAhG<33bbh9vXG%_%CbaHWYaWr){vNSNVFtIc@GchapkN795v~wz2rRY?flPvhQgTAV zkMk!EoIh~n0OO$^hcib`JYY{UW;Wzjz8ufSp!1q(HkJ@8E=epZsVD-vz|g?dluK3B I)!&T^03d}oSO5S3 literal 0 HcmV?d00001 diff --git a/examples/README.md b/examples/README.md index 0a0cfd787..7986bbfe9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -78,6 +78,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Content bleed](#content-bleed) | `band.bleedToEdge(TOP, LEFT, RIGHT)` / `bleed(DocumentBleed.of(...))` — a section's fill reaches the trimmed page edge while its children stay in the content margin | [PDF](../assets/readme/examples/content-bleed.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) | | [Row columns & TOC](#row-columns--toc) | `row.columns(auto(), weight(1), auto())` — size columns by content / fixed points / weight; with `line().fill()` it builds a dot-leader table of contents | [PDF](../assets/readme/examples/row-columns.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) | | [Row vertical align](#row-vertical-align) | `row.verticalAlign(TOP / CENTER / BOTTOM)` — seat a row's children on the cross axis within the band set by the tallest child | [PDF](../assets/readme/examples/row-vertical-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java) | +| [Row flex & arrangement](#row-flex--arrangement) | `row.pushRight()` / `flexSpacer()` springs + `arrangement(SPACE_BETWEEN / CENTER / …)` — push children apart or justify leftover width | [PDF](../assets/readme/examples/row-flex.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowFlexExample.java) | ### 📋 Templates recommended @@ -512,6 +513,24 @@ flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM) [📄 View PDF](../assets/readme/examples/row-vertical-align.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java) +### Row flex & arrangement + +`RowBuilder.pushRight()` / `flexSpacer()` add an invisible spring that absorbs the +row's leftover width — a header title stays left while a status badge sits flush +right. `arrangement(...)` instead justifies content-sized children across the row +(`SPACE_BETWEEN`, `CENTER`, `END`, `SPACE_AROUND`, `SPACE_EVENLY`) — the +`justify-content` analogue, no manual coordinates. `START` is the default, so +existing rows are unchanged. + +```java +flow.addRow(r -> r.addParagraph(title).pushRight().addParagraph(status)); // title left, status right +flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN) + .addParagraph(a).addParagraph(b).addParagraph(c)); // spread edge-to-edge +``` + +[📄 View PDF](../assets/readme/examples/row-flex.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowFlexExample.java) + ### Advanced tables `DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`. diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 4f4af036d..f82668830 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -9,6 +9,7 @@ import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.layout.BleedExample; import com.demcha.examples.features.layout.RowColumnsExample; +import com.demcha.examples.features.layout.RowFlexExample; import com.demcha.examples.features.layout.RowVerticalAlignExample; import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; @@ -159,6 +160,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + BleedExample.generate()); System.out.println("Generated: " + RowColumnsExample.generate()); System.out.println("Generated: " + RowVerticalAlignExample.generate()); + System.out.println("Generated: " + RowFlexExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/layout/RowFlexExample.java b/examples/src/main/java/com/demcha/examples/features/layout/RowFlexExample.java new file mode 100644 index 000000000..3fa148554 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/layout/RowFlexExample.java @@ -0,0 +1,101 @@ +package com.demcha.examples.features.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.RowArrangement; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 row flex layout: {@code pushRight()} / {@code + * flexSpacer()} springs absorb the row's leftover width, and {@code + * arrangement(...)} justifies content-sized children — the {@code justify-content} + * analogue for a horizontal row, no manual coordinates. + * + *
{@code
+ * flow.addRow(r -> r.addParagraph(title).pushRight().addParagraph(status)); // title left, status right
+ * flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN)
+ *     .addParagraph(a).addParagraph(b).addParagraph(c));                    // spread across the row
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class RowFlexExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135); + private static final DocumentColor GREEN = DocumentColor.rgb(22, 128, 70); + private static final DocumentColor BAND = DocumentColor.rgb(238, 241, 246); + + private RowFlexExample() { + } + + /** + * Renders a {@code pushRight()} header, a {@code SPACE_BETWEEN} nav bar, and a + * {@code CENTER} footer. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/layout", "row-flex.pdf"); + + DocumentTextStyle label = DocumentTextStyle.DEFAULT.withSize(12).withColor(INK); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(400, 280) + .margin(DocumentInsets.of(34)) + .create()) { + document.pageFlow(page -> { + heading(page, "Row flex & arrangement"); + + // pushRight(): title on the left, status flush to the right edge. + band(page, r -> r + .addParagraph(p -> p.text("Invoice #1042").textStyle(label)) + .pushRight() + .addParagraph(p -> p.text("PAID") + .textStyle(DocumentTextStyle.DEFAULT.withSize(12).withColor(GREEN)))); + + // SPACE_BETWEEN: a nav bar spread edge-to-edge. + band(page, r -> r.arrangement(RowArrangement.SPACE_BETWEEN) + .addParagraph(p -> p.text("Overview").textStyle(label)) + .addParagraph(p -> p.text("Details").textStyle(label)) + .addParagraph(p -> p.text("History").textStyle(label)) + .addParagraph(p -> p.text("Settings").textStyle(label))); + + // CENTER: a centred footer note. + band(page, r -> r.arrangement(RowArrangement.CENTER) + .addParagraph(p -> p.text("· thank you for your business ·") + .textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED)))); + }); + + document.buildPdf(); + } + + return pdfFile; + } + + private static void heading(PageFlowBuilder page, String text) { + page.addParagraph(p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))); + page.addParagraph(p -> p.text("pushRight() / flexSpacer() and arrangement(...) on a row") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED)) + .padding(DocumentInsets.bottom(10))); + } + + private static void band(PageFlowBuilder page, java.util.function.Consumer spec) { + page.addRow(r -> { + r.fillColor(BAND).cornerRadius(8).padding(DocumentInsets.of(12)); + spec.accept(r); + }); + page.addSpacer(s -> s.height(10)); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index d590c8138..3fcd56bdf 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -43,6 +43,7 @@ public final class RowBuilder { private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO; private DocumentBorders borders = DocumentBorders.NONE; private RowVerticalAlign verticalAlign = RowVerticalAlign.TOP; + private RowArrangement arrangement = RowArrangement.START; /** * Creates a row builder. @@ -195,6 +196,57 @@ public RowBuilder verticalAlign(RowVerticalAlign verticalAlign) { return this; } + /** + * Sets the main-axis distribution of the row's leftover width when its + * children do not fill it — the {@code justify-content} analogue. Only takes + * effect when the children are content-sized (no weights / columns / grow + * spacer absorbs the slack). + * + * @param arrangement main-axis arrangement; {@code null} resets to + * {@link RowArrangement#START} + * @return this builder + * @since 1.9.0 + */ + public RowBuilder arrangement(RowArrangement arrangement) { + this.arrangement = arrangement == null ? RowArrangement.START : arrangement; + return this; + } + + /** + * Adds a flex spacer — an invisible spring with {@code grow == 1} that absorbs + * the row's leftover width, pushing the children before and after it apart. + * + * @return this builder + * @since 1.9.0 + */ + public RowBuilder flexSpacer() { + return flexSpacer(1.0); + } + + /** + * Adds a flex spacer with the given grow factor. Multiple flex spacers share + * the leftover width in proportion to their grow factors. + * + * @param grow grow factor; must be finite and {@code > 0} + * @return this builder + * @since 1.9.0 + */ + public RowBuilder flexSpacer(double grow) { + return add(new SpacerBuilder().grow(grow).build()); + } + + /** + * Inserts a {@link #flexSpacer()} at the current position. With one spacer this + * pushes the children added after it to the right edge; with several it splits + * the leftover width between them. + * + * @return this builder + * @since 1.9.0 + */ + public RowBuilder pushRight() { + return flexSpacer(); + } + /** * Replaces the per-child weights used to distribute the row's inner width. * @@ -490,7 +542,8 @@ public RowNode build() { cornerRadius, borders, List.copyOf(columns), - verticalAlign); + verticalAlign, + arrangement); } private void validate() { diff --git a/src/main/java/com/demcha/compose/document/dsl/SpacerBuilder.java b/src/main/java/com/demcha/compose/document/dsl/SpacerBuilder.java index 9dd95c305..c42b0ab86 100644 --- a/src/main/java/com/demcha/compose/document/dsl/SpacerBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/SpacerBuilder.java @@ -15,6 +15,7 @@ public final class SpacerBuilder { private double height; private DocumentInsets padding = DocumentInsets.zero(); private DocumentInsets margin = DocumentInsets.zero(); + private double grow; /** * Creates a spacer builder. @@ -68,6 +69,20 @@ public SpacerBuilder size(double width, double height) { return this; } + /** + * Sets the flex grow factor. Inside a row, a spacer with {@code grow > 0} + * becomes a spring that absorbs a share of the row's leftover width + * proportional to its grow factor; {@code 0} (the default) is rigid. + * + * @param grow grow factor; must be finite and {@code >= 0} + * @return this builder + * @since 1.9.0 + */ + public SpacerBuilder grow(double grow) { + this.grow = grow; + return this; + } + /** * Sets spacer padding. * @@ -96,6 +111,6 @@ public SpacerBuilder margin(DocumentInsets margin) { * @return spacer node */ public SpacerNode build() { - return new SpacerNode(name, width, height, padding, margin); + return new SpacerNode(name, width, height, padding, margin, grow); } } diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index d0511124c..8f82fca80 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.node.RowArrangement; import com.demcha.compose.document.node.RowNode; import com.demcha.compose.document.node.RowVerticalAlign; import com.demcha.compose.document.style.DocumentBleed; @@ -511,7 +512,27 @@ private void compileHorizontalRow(PreparedNode prepared, if (!children.isEmpty()) { List columns = node instanceof RowNode row ? row.columns() : List.of(); double[] slotWidths; - if (!columns.isEmpty()) { + // flexLeading / flexExtraGap stay 0.0 for every non-flex row, so the + // cursor math below is byte-identical to the pre-flex placement. + double flexLeading = 0.0; + double flexExtraGap = 0.0; + if (RowSlots.hasFlexLayout(node)) { + slotWidths = RowSlots.distributeFlex(children, layoutSpec.spacing(), childRegionWidth, prepareContext); + RowArrangement arrangement = node instanceof RowNode flexRow + ? flexRow.arrangement() : RowArrangement.START; + if (arrangement != RowArrangement.START) { + double available = RowSlots.rowAvailableWidth( + childRegionWidth, layoutSpec.spacing(), children.size()); + double used = 0.0; + for (double slot : slotWidths) { + used += slot; + } + double[] justify = RowSlots.flexJustify( + arrangement, Math.max(0.0, available - used), children.size()); + flexLeading = justify[0]; + flexExtraGap = justify[1]; + } + } else if (!columns.isEmpty()) { double available = RowSlots.rowAvailableWidth(childRegionWidth, layoutSpec.spacing(), children.size()); double[] intrinsic = RowSlots.intrinsicColumnWidths(children, columns, available, prepareContext); slotWidths = RowSlots.distributeColumns(columns, intrinsic, layoutSpec.spacing(), childRegionWidth, semanticName); @@ -522,7 +543,7 @@ private void compileHorizontalRow(PreparedNode prepared, RowVerticalAlign verticalAlign = node instanceof RowNode rowNode ? rowNode.verticalAlign() : RowVerticalAlign.TOP; double bandContentHeight = naturalMeasure.height() - padding.vertical(); - double cursorX = placementX + padding.left(); + double cursorX = placementX + padding.left() + flexLeading; for (int index = 0; index < children.size(); index++) { DocumentNode child = children.get(index); @@ -570,7 +591,8 @@ private void compileHorizontalRow(PreparedNode prepared, slotWidth, FixedSlotKind.ROW_SLOT, slotCtx); - cursorX += slotWidth + layoutSpec.spacing(); + cursorX += slotWidth + layoutSpec.spacing() + + (index < children.size() - 1 ? flexExtraGap : 0.0); continue; } @@ -618,7 +640,8 @@ private void compileHorizontalRow(PreparedNode prepared, childMargin, childPadding)); - cursorX += slotWidth + layoutSpec.spacing(); + cursorX += slotWidth + layoutSpec.spacing() + + (index < children.size() - 1 ? flexExtraGap : 0.0); } } diff --git a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java index bae2a4255..f9009182b 100644 --- a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java @@ -346,7 +346,9 @@ public static MeasureResult measureRow(RowNode node, double totalGap = n > 1 ? gap * (n - 1) : 0.0; double slotsTotal = Math.max(0.0, availableWidth - totalGap); double[] slotWidths = new double[n]; - if (!node.columns().isEmpty()) { + if (RowSlots.hasFlexLayout(node)) { + slotWidths = RowSlots.distributeFlex(node.children(), gap, availableWidth, ctx); + } else if (!node.columns().isEmpty()) { double[] intrinsic = RowSlots.intrinsicColumnWidths(node.children(), node.columns(), slotsTotal, ctx); slotWidths = RowSlots.distributeColumns(node.columns(), intrinsic, gap, availableWidth, node.name()); } else if (node.weights().isEmpty()) { diff --git a/src/main/java/com/demcha/compose/document/layout/RowSlots.java b/src/main/java/com/demcha/compose/document/layout/RowSlots.java index 0acbe5497..935128566 100644 --- a/src/main/java/com/demcha/compose/document/layout/RowSlots.java +++ b/src/main/java/com/demcha/compose/document/layout/RowSlots.java @@ -1,6 +1,9 @@ package com.demcha.compose.document.layout; import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.RowArrangement; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SpacerNode; import com.demcha.compose.document.style.DocumentRowColumn; import java.util.List; @@ -141,4 +144,121 @@ static void validateWeightsMatchChildren(List weights, int childCount) { + " weight(s) or leave weights empty for an even split."); } } + + /** + * Whether a row uses the flex distribution path — a grow spacer absorbs the + * leftover width, or a non-{@link RowArrangement#START START} arrangement + * justifies it. Only this returning {@code true} routes a row through + * {@link #distributeFlex}; every other row keeps the unchanged + * columns / weights / even split. + * + * @param node the node being distributed + * @return {@code true} when flex distribution applies + */ + static boolean hasFlexLayout(DocumentNode node) { + if (!(node instanceof RowNode row)) { + return false; + } + if (row.arrangement() != RowArrangement.START) { + return true; + } + return hasGrowChild(row.children()); + } + + private static boolean hasGrowChild(List children) { + for (DocumentNode child : children) { + if (child instanceof SpacerNode spacer && spacer.grow() > 0.0) { + return true; + } + } + return false; + } + + private static double growOf(DocumentNode child) { + return child instanceof SpacerNode spacer ? spacer.grow() : 0.0; + } + + /** + * Distributes the row width for the flex path: every non-grow child takes its + * intrinsic (natural content) width, and the leftover is shared across the + * grow children in proportion to their grow factors. With no grow child the + * children keep their intrinsic widths and the leftover is handed to + * {@link #flexJustify} for main-axis arrangement. Called identically by the + * compile and measure phases so the slot widths match. + * + * @param children the row's children + * @param gap gap between children + * @param innerWidth the row's content width + * @param ctx the prepare context (for intrinsic widths) + * @return resolved slot width per child + */ + static double[] distributeFlex(List children, + double gap, + double innerWidth, + PrepareContext ctx) { + int n = children.size(); + double available = rowAvailableWidth(innerWidth, gap, n); + double[] slots = new double[n]; + double totalGrow = 0.0; + double used = 0.0; + for (int i = 0; i < n; i++) { + DocumentNode child = children.get(i); + double grow = growOf(child); + if (grow > 0.0) { + totalGrow += grow; + } else { + double childInner = Math.max(0.0, available - child.margin().horizontal()); + double natural = ctx.prepare(child, BoxConstraints.natural(childInner)).measureResult().width(); + slots[i] = natural + child.margin().horizontal(); + used += slots[i]; + } + } + double remaining = Math.max(0.0, available - used); + if (totalGrow > 0.0) { + for (int i = 0; i < n; i++) { + double grow = growOf(children.get(i)); + if (grow > 0.0) { + slots[i] = remaining * (grow / totalGrow); + } + } + } + return slots; + } + + /** + * Resolves a non-START {@link RowArrangement} into a leading offset and an + * extra inter-child gap that justify {@code leftover} width across {@code n} + * content-sized children. The compile phase applies these to the cursor; the + * measure phase does not need them (they shift x only, not height). + * + * @param arrangement the row arrangement (must not be START) + * @param leftover the unused width to distribute + * @param n number of children + * @return {@code {leading, extraGap}} — leading offset before the first child, + * and extra gap inserted between adjacent children + */ + static double[] flexJustify(RowArrangement arrangement, double leftover, int n) { + double leading = 0.0; + double extraGap = 0.0; + switch (arrangement) { + case START -> { + } + case CENTER -> leading = leftover / 2.0; + case END -> leading = leftover; + case SPACE_BETWEEN -> { + if (n > 1) { + extraGap = leftover / (n - 1); + } + } + case SPACE_AROUND -> { + extraGap = n > 0 ? leftover / n : 0.0; + leading = extraGap / 2.0; + } + case SPACE_EVENLY -> { + leading = leftover / (n + 1); + extraGap = leading; + } + } + return new double[]{leading, extraGap}; + } } diff --git a/src/main/java/com/demcha/compose/document/node/RowArrangement.java b/src/main/java/com/demcha/compose/document/node/RowArrangement.java new file mode 100644 index 000000000..e345bd57b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/RowArrangement.java @@ -0,0 +1,27 @@ +package com.demcha.compose.document.node; + +/** + * Main-axis (horizontal) distribution of a row's leftover width when its children + * do not fill it — the {@code justify-content} analogue for a horizontal row. + * + *

Only meaningful when the children are content-sized (no weights, columns, or + * grow spacer absorbs the slack). {@link #START} is the default and leaves the + * children flush left, exactly as before.

+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public enum RowArrangement { + /** Children packed at the start (the default). */ + START, + /** Children packed and centred in the row. */ + CENTER, + /** Children packed at the end. */ + END, + /** Leftover split evenly between children, none at the edges. */ + SPACE_BETWEEN, + /** Leftover split around each child (half-size space at the edges). */ + SPACE_AROUND, + /** Leftover split into equal gaps, including at the edges. */ + SPACE_EVENLY +} diff --git a/src/main/java/com/demcha/compose/document/node/RowNode.java b/src/main/java/com/demcha/compose/document/node/RowNode.java index 54650e3f2..4910f228b 100644 --- a/src/main/java/com/demcha/compose/document/node/RowNode.java +++ b/src/main/java/com/demcha/compose/document/node/RowNode.java @@ -34,6 +34,10 @@ * with {@code weights}. * @param verticalAlign cross-axis placement of children within the row band * (defaults to {@link RowVerticalAlign#TOP}) + * @param arrangement main-axis distribution of the row's leftover width + * (defaults to {@link RowArrangement#START}); mutually + * exclusive with {@code weights} / {@code columns} and with a + * grow spacer * @author Artem Demchyshyn */ public record RowNode( @@ -48,7 +52,8 @@ public record RowNode( DocumentCornerRadius cornerRadius, DocumentBorders borders, List columns, - RowVerticalAlign verticalAlign + RowVerticalAlign verticalAlign, + RowArrangement arrangement ) implements DocumentNode { /** * Creates a normalized horizontal row container. @@ -87,11 +92,57 @@ public record RowNode( cornerRadius = cornerRadius == null ? DocumentCornerRadius.ZERO : cornerRadius; borders = borders == null ? DocumentBorders.NONE : borders; verticalAlign = verticalAlign == null ? RowVerticalAlign.TOP : verticalAlign; + arrangement = arrangement == null ? RowArrangement.START : arrangement; + boolean hasGrowChild = false; + for (DocumentNode child : children) { + if (child instanceof SpacerNode spacer && spacer.grow() > 0.0) { + hasGrowChild = true; + break; + } + } + if ((hasGrowChild || arrangement != RowArrangement.START) + && (!weights.isEmpty() || !columns.isEmpty())) { + throw new IllegalArgumentException("RowNode cannot combine flex (a grow spacer or a non-START " + + "arrangement) with weights or columns; use one distribution strategy."); + } if (gap < 0 || Double.isNaN(gap) || Double.isInfinite(gap)) { throw new IllegalArgumentException("gap must be finite and non-negative: " + gap); } } + /** + * Backwards-compatible constructor without a main-axis arrangement — defaults + * to {@link RowArrangement#START}. + * + * @param name node name used in snapshots and layout graph paths + * @param children child semantic nodes in source order + * @param weights optional per-child weights (length must match children, or be empty) + * @param gap horizontal gap between children + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side border strokes + * @param columns optional per-child column widths + * @param verticalAlign cross-axis placement of children within the row band + */ + public RowNode(String name, + List children, + List weights, + double gap, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + List columns, + RowVerticalAlign verticalAlign) { + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, columns, + verticalAlign, RowArrangement.START); + } + /** * Backwards-compatible constructor without a cross-axis vertical alignment — * defaults to {@link RowVerticalAlign#TOP}. @@ -120,7 +171,7 @@ public RowNode(String name, DocumentBorders borders, List columns) { this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, columns, - RowVerticalAlign.TOP); + RowVerticalAlign.TOP, RowArrangement.START); } /** @@ -149,7 +200,7 @@ public RowNode(String name, DocumentCornerRadius cornerRadius, DocumentBorders borders) { this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of(), - RowVerticalAlign.TOP); + RowVerticalAlign.TOP, RowArrangement.START); } /** @@ -175,6 +226,6 @@ public RowNode(String name, DocumentStroke stroke, DocumentCornerRadius cornerRadius) { this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE, - List.of(), RowVerticalAlign.TOP); + List.of(), RowVerticalAlign.TOP, RowArrangement.START); } } diff --git a/src/main/java/com/demcha/compose/document/node/SpacerNode.java b/src/main/java/com/demcha/compose/document/node/SpacerNode.java index b643b83c9..e58e4cc6d 100644 --- a/src/main/java/com/demcha/compose/document/node/SpacerNode.java +++ b/src/main/java/com/demcha/compose/document/node/SpacerNode.java @@ -10,6 +10,9 @@ * @param height spacer height contribution * @param padding inner padding * @param margin outer margin + * @param grow flex grow factor inside a row: {@code 0} (the default) is a rigid + * spacer; {@code > 0} makes it a spring that absorbs a share of the + * row's leftover width proportional to its grow factor * @author Artem Demchyshyn */ public record SpacerNode( @@ -17,7 +20,8 @@ public record SpacerNode( double width, double height, DocumentInsets padding, - DocumentInsets margin + DocumentInsets margin, + double grow ) implements DocumentNode { /** * Normalizes spacing defaults and validates spacer dimensions. @@ -32,5 +36,21 @@ public record SpacerNode( if (height < 0 || Double.isNaN(height) || Double.isInfinite(height)) { throw new IllegalArgumentException("height must be finite and non-negative: " + height); } + if (grow < 0 || Double.isNaN(grow) || Double.isInfinite(grow)) { + throw new IllegalArgumentException("grow must be finite and non-negative: " + grow); + } + } + + /** + * Backwards-compatible constructor for a rigid spacer ({@code grow == 0}). + * + * @param name node name used in snapshots and layout graph paths + * @param width spacer width contribution + * @param height spacer height contribution + * @param padding inner padding + * @param margin outer margin + */ + public SpacerNode(String name, double width, double height, DocumentInsets padding, DocumentInsets margin) { + this(name, width, height, padding, margin, 0.0); } } diff --git a/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java b/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java new file mode 100644 index 000000000..d2f27c3be --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java @@ -0,0 +1,167 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.RowArrangement; +import com.demcha.compose.document.node.SpacerNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.within; + +/** + * Row flex layout: a {@code flexSpacer()} spring absorbs the row's leftover width + * (pushing following children right), and {@code arrangement(...)} justifies the + * leftover across content-sized children. Page is 240pt wide with 20pt margins, + * so the row's inner width is 200pt (left edge x=20, right edge x=220). + */ +class RowFlexTest { + + private static PlacedNode placed(DocumentSession session, String name) { + return session.layoutGraph().nodes().stream() + .filter(n -> name.equals(n.semanticName())) + .findFirst() + .orElseThrow(() -> new AssertionError(name + " not placed")); + } + + private static double leftOf(DocumentSession session, String name) { + return placed(session, name).placementX(); + } + + private static double rightOf(DocumentSession session, String name) { + PlacedNode n = placed(session, name); + return n.placementX() + n.placementWidth(); + } + + private static DocumentSession row(Consumer spec) { + DocumentSession session = GraphCompose.document().pageSize(240, 120).margin(DocumentInsets.of(20)).create(); + session.pageFlow(page -> page.addRow(spec)); + return session; + } + + @Test + void flexSpacerPushesFollowingChildrenToTheRightEdge() { + try (DocumentSession session = row(r -> r.gap(0) + .addSpacer(s -> s.name("left").size(30, 10)) + .pushRight() + .addSpacer(s -> s.name("right").size(30, 10)))) { + assertThat(leftOf(session, "left")).isCloseTo(20.0, within(0.5)); // flush left + assertThat(rightOf(session, "right")).isCloseTo(220.0, within(0.5)); // flush right + } + } + + @Test + void twoFlexSpacersShareTheLeftoverByGrow() { + // grow 1 then grow 3 → leftover (200-20 = 180) splits 45 / 135. + try (DocumentSession session = row(r -> r.gap(0) + .addSpacer(s -> s.name("a").size(20, 10)) + .flexSpacer(1.0) + .flexSpacer(3.0) + .addSpacer(s -> s.name("b").size(0, 10)))) { + // a ends at 40; first spring 45 → b starts at 40+45+135 = 220 (right edge). + assertThat(leftOf(session, "b")).isCloseTo(220.0, within(0.5)); + } + } + + @Test + void spaceBetweenPinsFirstAndLastToTheEdges() { + try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.SPACE_BETWEEN) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSpacer(s -> s.name("b").size(20, 10)) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(leftOf(session, "a")).isCloseTo(20.0, within(0.5)); // first flush left + assertThat(rightOf(session, "c")).isCloseTo(220.0, within(0.5)); // last flush right + assertThat(leftOf(session, "b")).isCloseTo(110.0, within(0.5)); // middle centred + } + } + + @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) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSpacer(s -> s.name("b").size(20, 10)) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(leftOf(session, "a")).isCloseTo(90.0, within(0.5)); + assertThat(rightOf(session, "c")).isCloseTo(150.0, within(0.5)); + } + } + + @Test + void endArrangementPushesTheContentRight() { + try (DocumentSession session = row(r -> r.gap(0).arrangement(RowArrangement.END) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(rightOf(session, "c")).isCloseTo(220.0, within(0.5)); // flush right + assertThat(leftOf(session, "a")).isCloseTo(180.0, within(0.5)); // 220 - 40 + } + } + + @Test + 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) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSpacer(s -> s.name("b").size(20, 10)) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(leftOf(session, "a")).isCloseTo(43.33, within(0.5)); + assertThat(leftOf(session, "b")).isCloseTo(110.0, within(0.5)); + } + } + + @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) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSpacer(s -> s.name("b").size(20, 10)) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(leftOf(session, "a")).isCloseTo(55.0, within(0.5)); + assertThat(leftOf(session, "c")).isCloseTo(165.0, within(0.5)); + } + } + + @Test + void growRejectsNonFiniteFactors() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new SpacerBuilder().grow(Double.NaN).build()) + .withMessageContaining("grow"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new SpacerBuilder().grow(Double.POSITIVE_INFINITY).build()) + .withMessageContaining("grow"); + } + + @Test + void rowRejectsFlexCombinedWithWeights() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new RowBuilder() + .arrangement(RowArrangement.CENTER) + .addSpacer(s -> s.size(10, 10)) + .weights(1) + .build()) + .withMessageContaining("flex"); + } + + @Test + void rowRejectsFlexCombinedWithColumns() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new RowBuilder() + .arrangement(RowArrangement.CENTER) + .addSpacer(s -> s.size(10, 10)) + .columns(com.demcha.compose.document.style.DocumentRowColumn.auto()) + .build()) + .withMessageContaining("flex"); + } + + @Test + void rigidSpacerHasZeroGrowByDefault() { + SpacerNode spacer = new SpacerNode("s", 10, 10, DocumentInsets.zero(), DocumentInsets.zero()); + assertThat(spacer.grow()).isZero(); + } +} From 1cb2f2c34af0fb5984b0073e482f7f20e7db2e8d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 08:44:48 +0100 Subject: [PATCH 2/2] test(api): cover the composite-child flex advance; fix flexSpacer grow doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../demcha/compose/document/dsl/RowBuilder.java | 3 ++- .../demcha/compose/document/dsl/RowFlexTest.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index 3fcd56bdf..6d8f5eb8f 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -227,7 +227,8 @@ public RowBuilder flexSpacer() { * Adds a flex spacer with the given grow factor. Multiple flex spacers share * the leftover width in proportion to their grow factors. * - * @param grow grow factor; must be finite and {@code > 0} + * @param grow grow factor; must be finite and {@code >= 0} ({@code 0} is a + * rigid, zero-width spacer) * @return this builder * @since 1.9.0 */ diff --git a/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java b/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java index d2f27c3be..098244a0a 100644 --- a/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java @@ -127,6 +127,20 @@ void spaceEvenlyPutsEqualGapsIncludingTheEdges() { } } + @Test + void arrangementGapAppliesAcrossACompositeChild() { + // 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) + .addSpacer(s -> s.name("a").size(20, 10)) + .addSection(sec -> sec.name("box").addSpacer(s -> s.size(20, 10))) + .addSpacer(s -> s.name("c").size(20, 10)))) { + assertThat(leftOf(session, "a")).isCloseTo(20.0, within(0.5)); + assertThat(rightOf(session, "c")).isCloseTo(220.0, within(0.5)); + } + } + @Test void growRejectsNonFiniteFactors() { assertThatExceptionOfType(IllegalArgumentException.class)