diff --git a/src/main/java/com/demcha/compose/document/layout/CompilerState.java b/src/main/java/com/demcha/compose/document/layout/CompilerState.java index b38a743b..59bd2bb4 100644 --- a/src/main/java/com/demcha/compose/document/layout/CompilerState.java +++ b/src/main/java/com/demcha/compose/document/layout/CompilerState.java @@ -2,6 +2,8 @@ import com.demcha.compose.engine.components.style.Margin; +import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; + /** * Mutable bookkeeping for the page-flow path of {@link LayoutCompiler}: the * canvas the document is being placed on, the active page index, the height @@ -80,4 +82,40 @@ void newPage() { void touchPage() { maxTouchedPage = Math.max(maxTouchedPage, pageIndex); } + + /** + * Advances the flow by {@code amount}, spilling to a fresh page first when the + * amount does not fit in the remaining height (and the page has already been + * used). A non-positive amount is dropped; the used height never exceeds the + * active page's content height. + */ + void advanceSpace(double amount) { + if (amount <= EPS) { + return; + } + if (amount > remainingHeight() + EPS && usedHeight > EPS) { + newPage(); + } + touchPage(); + usedHeight = Math.min(activeInnerHeight(), usedHeight + amount); + } + + /** + * Closes out a composite's bottom edge. A positive bottom inset advances the + * flow as usual; a NEGATIVE one (a negative bottom margin) pulls the following + * sibling up — symmetric with a negative top margin, which already offsets via + * {@code placementTopY}. The plain {@link #advanceSpace} drops a non-positive + * amount, so the closing edge needs this dedicated path. The top-of-node + * reservation deliberately stays on {@link #advanceSpace} so a negative top + * margin keeps its existing flow behaviour; only the closing edge gains the + * pull-up. The cursor never drops below the page top. + */ + void closeBottomSpace(double amount) { + if (amount >= EPS) { + advanceSpace(amount); + } else if (amount <= -EPS) { + touchPage(); + usedHeight = Math.max(0.0, usedHeight + amount); + } + } } 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 56d5fe7d..3f9aa36d 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -311,7 +311,7 @@ private void compileComposite(PreparedNode prepared, int nodeIndex = nodes.size(); nodes.add(null); - advanceSpace(startReservation, state); + state.advanceSpace(startReservation); List children = definition.children(node); double childRegionX = placementX + padding.left(); double childRegionWidth = Math.max(0.0, availableWidth - padding.horizontal()); @@ -346,11 +346,11 @@ private void compileComposite(PreparedNode prepared, nodes, fragments); if (index < children.size() - 1) { - advanceSpace(layoutSpec.spacing(), state); + state.advanceSpace(layoutSpec.spacing()); } } - closeBottomSpace(padding.bottom() + margin.bottom(), state); + state.closeBottomSpace(padding.bottom() + margin.bottom()); int endPage = state.pageIndex; double endPageBottomY = state.pageTop() - state.usedHeight + margin.bottom(); @@ -1395,35 +1395,6 @@ private void addPlacedFragments(List emitted, fragments.addAll(CompositeDecoration.toPlacedFragments(emitted, placement)); } - private void advanceSpace(double amount, CompilerState state) { - if (amount <= EPS) { - return; - } - if (amount > state.remainingHeight() + EPS && state.usedHeight > EPS) { - state.newPage(); - } - state.touchPage(); - state.usedHeight = Math.min(state.activeInnerHeight(), state.usedHeight + amount); - } - - /** - * Closes out a composite's bottom edge. A positive bottom inset advances the - * flow as usual; a NEGATIVE one (a negative bottom margin) pulls the following - * sibling up — symmetric with a negative top margin, which already offsets via - * {@code placementTopY}. The plain {@link #advanceSpace} drops a non-positive - * amount, so the closing edge needs this dedicated path. The top-of-node - * reservation deliberately stays on {@link #advanceSpace} so a negative top - * margin keeps its existing flow behaviour; only the closing edge gains the - * pull-up. The cursor never drops below the page top. - */ - private void closeBottomSpace(double amount, CompilerState state) { - if (amount >= EPS) { - advanceSpace(amount, state); - } else if (amount <= -EPS) { - state.touchPage(); - state.usedHeight = Math.max(0.0, state.usedHeight + amount); - } - } private String pathFor(DocumentNode node, String parentPath, int childIndex) { String base = semanticName(node); diff --git a/src/test/java/com/demcha/compose/document/layout/CompilerStateTest.java b/src/test/java/com/demcha/compose/document/layout/CompilerStateTest.java new file mode 100644 index 00000000..d47c5cb6 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/layout/CompilerStateTest.java @@ -0,0 +1,78 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.engine.components.style.Margin; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Unit coverage for the page-flow cursor arithmetic on {@link CompilerState} + * (the {@code advanceSpace} / {@code closeBottomSpace} helpers pulled off + * {@link LayoutCompiler}). A 200×100 canvas with 10pt margins gives an 80pt + * content height, so the page fills at {@code usedHeight == 80}. + */ +class CompilerStateTest { + + private static final double EPS = 1e-9; + + private static CompilerState state() { + return new CompilerState(LayoutCanvas.from(200, 100, Margin.of(10))); + } + + @Test + void advanceSpaceConsumesHeightWhenItFits() { + CompilerState state = state(); + state.advanceSpace(30); + assertThat(state.usedHeight).isCloseTo(30, within(EPS)); + assertThat(state.pageIndex).isZero(); + assertThat(state.maxTouchedPage).isZero(); + } + + @Test + void advanceSpaceSpillsToANewPageWhenItOverflowsAUsedPage() { + CompilerState state = state(); + state.usedHeight = 70; // 10pt left on the page + state.advanceSpace(20); // doesn't fit → new page, then consume + assertThat(state.pageIndex).isEqualTo(1); + assertThat(state.usedHeight).isCloseTo(20, within(EPS)); + } + + @Test + void advanceSpaceDropsANonPositiveAmount() { + CompilerState state = state(); + state.usedHeight = 15; + state.advanceSpace(0); + state.advanceSpace(-5); + assertThat(state.usedHeight).isCloseTo(15, within(EPS)); + assertThat(state.pageIndex).isZero(); + } + + @Test + void advanceSpaceDoesNotPaginateAFreshPageAndCapsAtContentHeight() { + CompilerState state = state(); // usedHeight 0 → the "already used" guard is false + state.advanceSpace(1000); // over-tall on an empty page: stays, capped + assertThat(state.pageIndex).isZero(); + assertThat(state.usedHeight).isCloseTo(80, within(EPS)); + } + + @Test + void closeBottomSpacePositiveAdvancesLikeAdvanceSpace() { + CompilerState state = state(); + state.usedHeight = 10; + state.closeBottomSpace(20); + assertThat(state.usedHeight).isCloseTo(30, within(EPS)); + } + + @Test + void closeBottomSpaceNegativePullsTheCursorUpClampedAtZero() { + CompilerState state = state(); + state.usedHeight = 30; + state.closeBottomSpace(-10); + assertThat(state.usedHeight).isCloseTo(20, within(EPS)); + + state.usedHeight = 5; + state.closeBottomSpace(-10); // would go negative → clamped to 0 + assertThat(state.usedHeight).isZero(); + } +}