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(); + } +}