diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d195bfa..25168907 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 00000000..7728bb76
Binary files /dev/null and b/assets/readme/examples/row-flex.pdf differ
diff --git a/examples/README.md b/examples/README.md
index 0a0cfd78..7986bbfe 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 4f4af036..f8266883 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 00000000..3fa14855
--- /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 d590c813..6d8f5eb8 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,58 @@ 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} ({@code 0} is a
+ * rigid, zero-width spacer)
+ * @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 +543,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 9dd95c30..c42b0ab8 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 d0511124..8f82fca8 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 bae2a425..f9009182 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 0acbe549..93512856 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 00000000..e345bd57
--- /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 54650e3f..4910f228 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 b643b83c..e58e4cc6 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 00000000..098244a0
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/dsl/RowFlexTest.java
@@ -0,0 +1,181 @@
+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 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)
+ .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();
+ }
+}