Skip to content

Commit 09315e3

Browse files
authored
feat(api): row flexSpacer / pushRight / arrangement — main-axis row layout (#240)
* feat(api): row flexSpacer / pushRight / arrangement — main-axis row layout A row distributed its width by even split, weights, or columns, and packed its children at the start; aligning a title left with a status flush right, or spreading a nav bar edge-to-edge, meant inserting hand-sized spacers. flexSpacer() (and the pushRight() alias) add an invisible spring — a SpacerNode with a grow factor — that absorbs the row's leftover width and pushes the children around it apart; several springs share the leftover in proportion to their grow. arrangement(RowArrangement.{START, CENTER, END, SPACE_BETWEEN, SPACE_AROUND, SPACE_EVENLY}) justifies content-sized children instead — the justify-content analogue. Flex is mutually exclusive with weights and columns. The slot math is a single shared helper (RowSlots.distributeFlex) called identically by the measure and compile phases, so the two cannot drift. The flex path is gated behind RowSlots.hasFlexLayout as the first branch in both phases; a row with no grow spacer and the default START arrangement never enters it, and the cursor's leading / extra-gap terms are 0, so existing rows are byte-for-byte unchanged (full suite green, no visual baselines moved). Tests: RowFlexTest pins the placement for pushRight, two springs sharing by grow, and START/CENTER/END/SPACE_BETWEEN/SPACE_AROUND/SPACE_EVENLY against a 240pt row, and rejects a non-finite grow and flex combined with weights or columns. Example: RowFlexExample (a pushRight header, a SPACE_BETWEEN nav bar, a CENTER footer). * test(api): cover the composite-child flex advance; fix flexSpacer grow doc Add a RowFlexTest case where a SPACE_BETWEEN row's middle child is a section (a composite column), exercising the composite cursor-advance branch with a non-zero arrangement gap — the last child still lands flush right. Correct the flexSpacer(grow) Javadoc to say the factor is >= 0 (0 is a rigid spacer), matching SpacerBuilder.grow and the SpacerNode validation.
1 parent cb01014 commit 09315e3

14 files changed

Lines changed: 636 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`RowBuilder.flexSpacer()` / `pushRight()` / `arrangement(...)` + `RowArrangement`
16+
+ `SpacerBuilder.grow(...)`** (`@since 1.9.0`). Main-axis (`justify-content`) layout
17+
for a row. A `flexSpacer()` (or `pushRight()`) is an invisible spring that absorbs
18+
the row's leftover width — a title stays left while a badge sits flush right; a
19+
spacer's `grow(...)` factor sets its share. `arrangement(START / CENTER / END /
20+
SPACE_BETWEEN / SPACE_AROUND / SPACE_EVENLY)` justifies content-sized children
21+
instead. Flex is mutually exclusive with `weights` / `columns`. The default
22+
(`START`, no grow) is byte-for-byte unchanged, so existing rows are unaffected.
23+
1524
- **`RowBuilder.verticalAlign(...)` + `RowVerticalAlign`** (`@since 1.9.0`). Seats a
1625
row's children on the cross axis within the row band, whose height is that of the
1726
tallest child: `TOP` (the default), `CENTER`, or `BOTTOM` — the `align-items`
1.35 KB
Binary file not shown.

examples/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ are with the canonical DSL, then jump to its detailed section below.
7878
| [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) |
7979
| [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) |
8080
| [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) |
81+
| [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) |
8182

8283
### 📋 Templates recommended
8384

@@ -512,6 +513,24 @@ flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM)
512513
[📄 View PDF](../assets/readme/examples/row-vertical-align.pdf) ·
513514
[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java)
514515

516+
### Row flex & arrangement
517+
518+
`RowBuilder.pushRight()` / `flexSpacer()` add an invisible spring that absorbs the
519+
row's leftover width — a header title stays left while a status badge sits flush
520+
right. `arrangement(...)` instead justifies content-sized children across the row
521+
(`SPACE_BETWEEN`, `CENTER`, `END`, `SPACE_AROUND`, `SPACE_EVENLY`) — the
522+
`justify-content` analogue, no manual coordinates. `START` is the default, so
523+
existing rows are unchanged.
524+
525+
```java
526+
flow.addRow(r -> r.addParagraph(title).pushRight().addParagraph(status)); // title left, status right
527+
flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN)
528+
.addParagraph(a).addParagraph(b).addParagraph(c)); // spread edge-to-edge
529+
```
530+
531+
[📄 View PDF](../assets/readme/examples/row-flex.pdf) ·
532+
[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowFlexExample.java)
533+
515534
### Advanced tables
516535

517536
`DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`.

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.demcha.examples.features.chrome.PdfChromeExample;
1010
import com.demcha.examples.features.layout.BleedExample;
1111
import com.demcha.examples.features.layout.RowColumnsExample;
12+
import com.demcha.examples.features.layout.RowFlexExample;
1213
import com.demcha.examples.features.layout.RowVerticalAlignExample;
1314
import com.demcha.examples.features.layout.BlockAlignExample;
1415
import com.demcha.examples.features.lists.NestedListExample;
@@ -159,6 +160,7 @@ public static void main(String[] args) throws Exception {
159160
System.out.println("Generated: " + BleedExample.generate());
160161
System.out.println("Generated: " + RowColumnsExample.generate());
161162
System.out.println("Generated: " + RowVerticalAlignExample.generate());
163+
System.out.println("Generated: " + RowFlexExample.generate());
162164
System.out.println("Generated: " + TransformsExample.generate());
163165
System.out.println("Generated: " + TableAdvancedExample.generate());
164166

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.demcha.examples.features.layout;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.dsl.PageFlowBuilder;
6+
import com.demcha.compose.document.node.RowArrangement;
7+
import com.demcha.compose.document.style.DocumentColor;
8+
import com.demcha.compose.document.style.DocumentInsets;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.examples.support.ExampleOutputPaths;
11+
12+
import java.nio.file.Path;
13+
14+
/**
15+
* Runnable showcase for v1.9 row flex layout: {@code pushRight()} / {@code
16+
* flexSpacer()} springs absorb the row's leftover width, and {@code
17+
* arrangement(...)} justifies content-sized children — the {@code justify-content}
18+
* analogue for a horizontal row, no manual coordinates.
19+
*
20+
* <pre>{@code
21+
* flow.addRow(r -> r.addParagraph(title).pushRight().addParagraph(status)); // title left, status right
22+
* flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN)
23+
* .addParagraph(a).addParagraph(b).addParagraph(c)); // spread across the row
24+
* }</pre>
25+
*
26+
* @author Artem Demchyshyn
27+
*/
28+
public final class RowFlexExample {
29+
30+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
31+
private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135);
32+
private static final DocumentColor GREEN = DocumentColor.rgb(22, 128, 70);
33+
private static final DocumentColor BAND = DocumentColor.rgb(238, 241, 246);
34+
35+
private RowFlexExample() {
36+
}
37+
38+
/**
39+
* Renders a {@code pushRight()} header, a {@code SPACE_BETWEEN} nav bar, and a
40+
* {@code CENTER} footer.
41+
*
42+
* @return path to the generated PDF
43+
* @throws Exception if rendering or file IO fails
44+
*/
45+
public static Path generate() throws Exception {
46+
Path pdfFile = ExampleOutputPaths.prepare("features/layout", "row-flex.pdf");
47+
48+
DocumentTextStyle label = DocumentTextStyle.DEFAULT.withSize(12).withColor(INK);
49+
50+
try (DocumentSession document = GraphCompose.document(pdfFile)
51+
.pageSize(400, 280)
52+
.margin(DocumentInsets.of(34))
53+
.create()) {
54+
document.pageFlow(page -> {
55+
heading(page, "Row flex & arrangement");
56+
57+
// pushRight(): title on the left, status flush to the right edge.
58+
band(page, r -> r
59+
.addParagraph(p -> p.text("Invoice #1042").textStyle(label))
60+
.pushRight()
61+
.addParagraph(p -> p.text("PAID")
62+
.textStyle(DocumentTextStyle.DEFAULT.withSize(12).withColor(GREEN))));
63+
64+
// SPACE_BETWEEN: a nav bar spread edge-to-edge.
65+
band(page, r -> r.arrangement(RowArrangement.SPACE_BETWEEN)
66+
.addParagraph(p -> p.text("Overview").textStyle(label))
67+
.addParagraph(p -> p.text("Details").textStyle(label))
68+
.addParagraph(p -> p.text("History").textStyle(label))
69+
.addParagraph(p -> p.text("Settings").textStyle(label)));
70+
71+
// CENTER: a centred footer note.
72+
band(page, r -> r.arrangement(RowArrangement.CENTER)
73+
.addParagraph(p -> p.text("· thank you for your business ·")
74+
.textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED))));
75+
});
76+
77+
document.buildPdf();
78+
}
79+
80+
return pdfFile;
81+
}
82+
83+
private static void heading(PageFlowBuilder page, String text) {
84+
page.addParagraph(p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK)));
85+
page.addParagraph(p -> p.text("pushRight() / flexSpacer() and arrangement(...) on a row")
86+
.textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED))
87+
.padding(DocumentInsets.bottom(10)));
88+
}
89+
90+
private static void band(PageFlowBuilder page, java.util.function.Consumer<com.demcha.compose.document.dsl.RowBuilder> spec) {
91+
page.addRow(r -> {
92+
r.fillColor(BAND).cornerRadius(8).padding(DocumentInsets.of(12));
93+
spec.accept(r);
94+
});
95+
page.addSpacer(s -> s.height(10));
96+
}
97+
98+
public static void main(String[] args) throws Exception {
99+
System.out.println("Generated: " + generate());
100+
}
101+
}

src/main/java/com/demcha/compose/document/dsl/RowBuilder.java

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public final class RowBuilder {
4343
private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO;
4444
private DocumentBorders borders = DocumentBorders.NONE;
4545
private RowVerticalAlign verticalAlign = RowVerticalAlign.TOP;
46+
private RowArrangement arrangement = RowArrangement.START;
4647

4748
/**
4849
* Creates a row builder.
@@ -195,6 +196,58 @@ public RowBuilder verticalAlign(RowVerticalAlign verticalAlign) {
195196
return this;
196197
}
197198

199+
/**
200+
* Sets the main-axis distribution of the row's leftover width when its
201+
* children do not fill it — the {@code justify-content} analogue. Only takes
202+
* effect when the children are content-sized (no weights / columns / grow
203+
* spacer absorbs the slack).
204+
*
205+
* @param arrangement main-axis arrangement; {@code null} resets to
206+
* {@link RowArrangement#START}
207+
* @return this builder
208+
* @since 1.9.0
209+
*/
210+
public RowBuilder arrangement(RowArrangement arrangement) {
211+
this.arrangement = arrangement == null ? RowArrangement.START : arrangement;
212+
return this;
213+
}
214+
215+
/**
216+
* Adds a flex spacer — an invisible spring with {@code grow == 1} that absorbs
217+
* the row's leftover width, pushing the children before and after it apart.
218+
*
219+
* @return this builder
220+
* @since 1.9.0
221+
*/
222+
public RowBuilder flexSpacer() {
223+
return flexSpacer(1.0);
224+
}
225+
226+
/**
227+
* Adds a flex spacer with the given grow factor. Multiple flex spacers share
228+
* the leftover width in proportion to their grow factors.
229+
*
230+
* @param grow grow factor; must be finite and {@code >= 0} ({@code 0} is a
231+
* rigid, zero-width spacer)
232+
* @return this builder
233+
* @since 1.9.0
234+
*/
235+
public RowBuilder flexSpacer(double grow) {
236+
return add(new SpacerBuilder().grow(grow).build());
237+
}
238+
239+
/**
240+
* Inserts a {@link #flexSpacer()} at the current position. With one spacer this
241+
* pushes the children added after it to the right edge; with several it splits
242+
* the leftover width between them.
243+
*
244+
* @return this builder
245+
* @since 1.9.0
246+
*/
247+
public RowBuilder pushRight() {
248+
return flexSpacer();
249+
}
250+
198251
/**
199252
* Replaces the per-child weights used to distribute the row's inner width.
200253
*
@@ -490,7 +543,8 @@ public RowNode build() {
490543
cornerRadius,
491544
borders,
492545
List.copyOf(columns),
493-
verticalAlign);
546+
verticalAlign,
547+
arrangement);
494548
}
495549

496550
private void validate() {

src/main/java/com/demcha/compose/document/dsl/SpacerBuilder.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public final class SpacerBuilder {
1515
private double height;
1616
private DocumentInsets padding = DocumentInsets.zero();
1717
private DocumentInsets margin = DocumentInsets.zero();
18+
private double grow;
1819

1920
/**
2021
* Creates a spacer builder.
@@ -68,6 +69,20 @@ public SpacerBuilder size(double width, double height) {
6869
return this;
6970
}
7071

72+
/**
73+
* Sets the flex grow factor. Inside a row, a spacer with {@code grow > 0}
74+
* becomes a spring that absorbs a share of the row's leftover width
75+
* proportional to its grow factor; {@code 0} (the default) is rigid.
76+
*
77+
* @param grow grow factor; must be finite and {@code >= 0}
78+
* @return this builder
79+
* @since 1.9.0
80+
*/
81+
public SpacerBuilder grow(double grow) {
82+
this.grow = grow;
83+
return this;
84+
}
85+
7186
/**
7287
* Sets spacer padding.
7388
*
@@ -96,6 +111,6 @@ public SpacerBuilder margin(DocumentInsets margin) {
96111
* @return spacer node
97112
*/
98113
public SpacerNode build() {
99-
return new SpacerNode(name, width, height, padding, margin);
114+
return new SpacerNode(name, width, height, padding, margin, grow);
100115
}
101116
}

src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.demcha.compose.document.node.DocumentNode;
66
import com.demcha.compose.document.node.LayerStackNode;
77
import com.demcha.compose.document.node.PageBreakNode;
8+
import com.demcha.compose.document.node.RowArrangement;
89
import com.demcha.compose.document.node.RowNode;
910
import com.demcha.compose.document.node.RowVerticalAlign;
1011
import com.demcha.compose.document.style.DocumentBleed;
@@ -511,7 +512,27 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
511512
if (!children.isEmpty()) {
512513
List<DocumentRowColumn> columns = node instanceof RowNode row ? row.columns() : List.of();
513514
double[] slotWidths;
514-
if (!columns.isEmpty()) {
515+
// flexLeading / flexExtraGap stay 0.0 for every non-flex row, so the
516+
// cursor math below is byte-identical to the pre-flex placement.
517+
double flexLeading = 0.0;
518+
double flexExtraGap = 0.0;
519+
if (RowSlots.hasFlexLayout(node)) {
520+
slotWidths = RowSlots.distributeFlex(children, layoutSpec.spacing(), childRegionWidth, prepareContext);
521+
RowArrangement arrangement = node instanceof RowNode flexRow
522+
? flexRow.arrangement() : RowArrangement.START;
523+
if (arrangement != RowArrangement.START) {
524+
double available = RowSlots.rowAvailableWidth(
525+
childRegionWidth, layoutSpec.spacing(), children.size());
526+
double used = 0.0;
527+
for (double slot : slotWidths) {
528+
used += slot;
529+
}
530+
double[] justify = RowSlots.flexJustify(
531+
arrangement, Math.max(0.0, available - used), children.size());
532+
flexLeading = justify[0];
533+
flexExtraGap = justify[1];
534+
}
535+
} else if (!columns.isEmpty()) {
515536
double available = RowSlots.rowAvailableWidth(childRegionWidth, layoutSpec.spacing(), children.size());
516537
double[] intrinsic = RowSlots.intrinsicColumnWidths(children, columns, available, prepareContext);
517538
slotWidths = RowSlots.distributeColumns(columns, intrinsic, layoutSpec.spacing(), childRegionWidth, semanticName);
@@ -522,7 +543,7 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
522543
RowVerticalAlign verticalAlign = node instanceof RowNode rowNode
523544
? rowNode.verticalAlign() : RowVerticalAlign.TOP;
524545
double bandContentHeight = naturalMeasure.height() - padding.vertical();
525-
double cursorX = placementX + padding.left();
546+
double cursorX = placementX + padding.left() + flexLeading;
526547

527548
for (int index = 0; index < children.size(); index++) {
528549
DocumentNode child = children.get(index);
@@ -570,7 +591,8 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
570591
slotWidth,
571592
FixedSlotKind.ROW_SLOT,
572593
slotCtx);
573-
cursorX += slotWidth + layoutSpec.spacing();
594+
cursorX += slotWidth + layoutSpec.spacing()
595+
+ (index < children.size() - 1 ? flexExtraGap : 0.0);
574596
continue;
575597
}
576598

@@ -618,7 +640,8 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
618640
childMargin,
619641
childPadding));
620642

621-
cursorX += slotWidth + layoutSpec.spacing();
643+
cursorX += slotWidth + layoutSpec.spacing()
644+
+ (index < children.size() - 1 ? flexExtraGap : 0.0);
622645
}
623646
}
624647

src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@ public static MeasureResult measureRow(RowNode node,
346346
double totalGap = n > 1 ? gap * (n - 1) : 0.0;
347347
double slotsTotal = Math.max(0.0, availableWidth - totalGap);
348348
double[] slotWidths = new double[n];
349-
if (!node.columns().isEmpty()) {
349+
if (RowSlots.hasFlexLayout(node)) {
350+
slotWidths = RowSlots.distributeFlex(node.children(), gap, availableWidth, ctx);
351+
} else if (!node.columns().isEmpty()) {
350352
double[] intrinsic = RowSlots.intrinsicColumnWidths(node.children(), node.columns(), slotsTotal, ctx);
351353
slotWidths = RowSlots.distributeColumns(node.columns(), intrinsic, gap, availableWidth, node.name());
352354
} else if (node.weights().isEmpty()) {

0 commit comments

Comments
 (0)