Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
55f3743
test(timeline): measure what the resolved graph actually says about t…
DemchaAV Sep 9, 2026
d6b0308
test(timeline): find out which container can hold a splittable entry
DemchaAV Sep 9, 2026
6a3cb91
test(timeline): pin what the timeline renders before the rail moves
DemchaAV Sep 9, 2026
39319a6
test(timeline): record what the timeline looks like, not only where i…
DemchaAV Sep 9, 2026
c31ea6c
refactor(timeline): give the timeline one internal model to lay out from
DemchaAV Sep 9, 2026
509dda3
feat(timeline): let an entry fill its own content column
DemchaAV Sep 9, 2026
142afb8
fix(timeline): reject a second entry marker whatever declared the first
DemchaAV Sep 9, 2026
4843aac
feat(timeline): give a timeline a column before its markers
DemchaAV Sep 9, 2026
5b13d68
feat(timeline): let the marker column be points as well as a share
DemchaAV Sep 9, 2026
cbbd0f0
feat(timeline): let a caller bring their own marker
DemchaAV Sep 9, 2026
2a525a6
feat(timeline): make every marker report where it landed
DemchaAV Sep 9, 2026
5564c0c
feat(timeline): make the rail one configuration instead of two arguments
DemchaAV Sep 9, 2026
1fb0c70
feat(timeline): draw the rail as one line from where the markers landed
DemchaAV Sep 9, 2026
62cd858
fix(timeline): put every marker on the rail, not just the first
DemchaAV Sep 9, 2026
b036576
fix(docx): keep the content inside a wrapper the semantic backend can…
DemchaAV Sep 9, 2026
4b60d4a
test(timeline): pin the finished visual model, one instrument per scene
DemchaAV Sep 10, 2026
22b3a87
fix(layout): draw a pass's rail under its own content, not under the …
DemchaAV Sep 10, 2026
ac56383
fix(timeline): give a marker the box it declared instead of measuring…
DemchaAV Sep 10, 2026
7184f11
feat(layout): let a row publish the columns it resolved, for content …
DemchaAV Sep 10, 2026
d5e22eb
fix(timeline): put a marker-on-rail body in the content column, off t…
DemchaAV Sep 10, 2026
c6eba63
test(layout): pin what a published column does when its block moves t…
DemchaAV Sep 10, 2026
31595e0
docs(timeline): describe the timeline that shipped, not the one that …
DemchaAV Sep 10, 2026
414d7d7
refactor(timeline): drop the marker-bounds accessor nothing reads
DemchaAV Sep 10, 2026
e748833
test(timeline): give the timeline baselines the cross-platform budget…
DemchaAV Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

Binary file modified assets/readme/examples/feature-catalog.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
import com.demcha.compose.document.layout.ResolvedLayoutAddition;
import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
import com.demcha.compose.document.layout.ResolvedLayoutPass;
import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

/**
* Runs the resolved-layout passes over a compiled graph and splices what they contribute.
Expand All @@ -26,7 +31,12 @@
* </pre>
*
* <p>A pass running after backgrounds would have its under-body fragment prepended to
* index 0 — beneath an opaque page background, and invisible.</p>
* index 0 — beneath an opaque page background, and invisible. Within the body the same
* hazard is local rather than page-wide: an under-body fragment placed at the front of the
* list also sits beneath the fill of every container drawn before the feature, including
* the panel the feature is inside. So under-body means under the contributing feature's own
* content — spliced immediately before the first fragment its anchors produced on that
* page — and a pass that anchored nothing on a page keeps the front of the list.</p>
*
* @author Artem Demchyshyn
* @since 2.4.0
Expand All @@ -48,18 +58,30 @@ private ResolvedLayoutPasses() {
*/
static LayoutGraph apply(LayoutGraph base, List<ResolvedLayoutPass> passes) {
Objects.requireNonNull(base, "base");
if (passes == null || passes.isEmpty()) {
return base;
}

// Collected once, from the compiled graph, and handed to every pass. Nothing a
// pass contributes can seed a new anchor for a later pass, so the result does not
// depend on how the passes happen to interleave.
ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(base);

List<PlacedFragment> under = new ArrayList<>();
List<ResolvedLayoutPass> running = discover(metadata);
if (passes != null) {
running.addAll(passes);
}
if (running.isEmpty()) {
return base;
}

// Where each pass's own content starts, page by page, so that "under the body" can
// mean under *its* body. Prepending to index 0 instead puts the fragment beneath
// everything drawn before the feature — including the fill of a panel the feature
// sits inside, which paints over it and leaves no trace in the geometry.
Map<Object, Map<Integer, Integer>> ownContent = firstOwnFragmentPerPage(base);

Map<Integer, List<PlacedFragment>> under = new TreeMap<>();
List<PlacedFragment> over = new ArrayList<>();
for (ResolvedLayoutPass pass : passes) {
int underCount = 0;
for (ResolvedLayoutPass pass : running) {
List<ResolvedLayoutAddition> additions = pass.contribute(base, metadata);
if (additions == null) {
throw new IllegalStateException(
Expand All @@ -83,23 +105,94 @@ static LayoutGraph apply(LayoutGraph base, List<ResolvedLayoutPass> passes) {
requireFinite(pass, fragment.y(), "y");
requireFinite(pass, fragment.width(), "width");
requireFinite(pass, fragment.height(), "height");
(addition.depth() == LayoutDepth.UNDER_BODY ? under : over).add(fragment);
if (addition.depth() == LayoutDepth.UNDER_BODY) {
under.computeIfAbsent(
ownContent.getOrDefault(pass, Map.of())
.getOrDefault(fragment.pageIndex(), 0),
index -> new ArrayList<>())
.add(fragment);
underCount++;
} else {
over.add(fragment);
}
}
}

if (under.isEmpty() && over.isEmpty()) {
if (underCount == 0 && over.isEmpty()) {
return base;
}

List<PlacedFragment> combined =
new ArrayList<>(under.size() + base.fragments().size() + over.size());
combined.addAll(under);
combined.addAll(base.fragments());
List<PlacedFragment> body = base.fragments();
List<PlacedFragment> combined = new ArrayList<>(underCount + body.size() + over.size());
for (int i = 0; i < body.size(); i++) {
combined.addAll(under.getOrDefault(i, List.of()));
combined.add(body.get(i));
}
combined.addAll(under.getOrDefault(body.size(), List.of()));
combined.addAll(over);
// Nodes pass through untouched: a pass contributes drawing, never structure.
return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined);
}

/**
* The first fragment each anchor group put on each page.
*
* <p>An anchor's own fragment precedes its child's, so this is where a feature's
* content begins in draw order — and therefore where something drawn beneath that
* feature has to be spliced. One scan, and the answer for every pass at once.</p>
*
* @param base the compiled graph
* @return group key to page to first index; groups with no anchors are absent
*/
private static Map<Object, Map<Integer, Integer>> firstOwnFragmentPerPage(LayoutGraph base) {
Map<Object, Map<Integer, Integer>> first = new IdentityHashMap<>();
List<PlacedFragment> fragments = base.fragments();
for (int i = 0; i < fragments.size(); i++) {
PlacedFragment fragment = fragments.get(i);
if (fragment.payload() instanceof LayoutAnchorPayload anchor) {
first.computeIfAbsent(anchor.id().groupKey(), key -> new HashMap<>())
.putIfAbsent(fragment.pageIndex(), i);
}
}
return first;
}

/**
* The passes the document itself asks for, found in what it anchored.
*
* <p>A built-in feature declares an owner on the semantic tree and keys its anchors on
* it; an owner that is <em>also</em> a pass is a feature saying it has something to
* draw once the layout is settled. Nothing is registered, no session is handed around,
* and this class stays ignorant of every feature that uses it — it asks whether the
* owner is a pass, not what kind of thing it is.</p>
*
* <p>Order is first appearance in the anchor list, which is the compiler's placement
* order, which is reading order down the document. Two instances of one feature on a
* page therefore draw in the order they were written.</p>
*
* @param metadata the anchors the document resolved
* @return the passes to run, deduplicated by identity, in document order
*/
private static List<ResolvedLayoutPass> discover(ResolvedLayoutMetadata metadata) {
List<ResolvedLayoutPass> found = new ArrayList<>();
for (var anchor : metadata.anchors()) {
if (anchor.id().groupKey() instanceof ResolvedLayoutPass pass && !containsSame(found, pass)) {
found.add(pass);
}
}
return found;
}

/** Identity, not equality: two owners configured alike are still two features. */
private static boolean containsSame(List<ResolvedLayoutPass> passes, ResolvedLayoutPass pass) {
for (ResolvedLayoutPass known : passes) {
if (known == pass) {
return true;
}
}
return false;
}

private static void requireFinite(ResolvedLayoutPass pass, double value, String name) {
if (!Double.isFinite(value)) {
throw new IllegalStateException("Resolved-layout pass '" + pass.id()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.demcha.compose.document.dsl;

/**
* What a timeline anchor marks.
*
* <p>An enum constant because the resolved-layout seam compares an anchor's kind by
* reference — a string would work or fail on interning, which is why it refuses one.</p>
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
enum TimelineAnchorKind {

/** The marker in an entry's axis column — where the rail passes, horizontally. */
MARKER,

/**
* A whole entry — where the rail starts and stops, vertically.
*
* <p>Separate from the marker because they answer different questions, and because an
* entry is the only one of the two that can span pages: its slices carry each page's
* content band, which is what a rail crossing a page break needs.</p>
*/
ENTRY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.demcha.compose.document.dsl;

/**
* How wide the axis column — the one the markers sit in — is.
*
* <p>Two strategies, and deliberately not one number. A weight is a share of what the row
* has left; a fixed width is points. Converting one into the other would need the row's
* width, which the builder does not have, and doing it anyway is how a timeline that has
* rendered the same way for versions quietly moves: {@code markerColumnWeight(0.10)} means
* "a tenth of the remainder" on a 260pt page and on a 500pt one, and no single point value
* is both.</p>
*
* <p>So both survive to the layout, which asks the row to resolve whichever it was given.
* A closed set of two rather than a reused {@code DocumentRowColumn} because the third
* strategy that type offers — auto — cannot align a column across rows, which is the one
* thing this column has to do.</p>
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
sealed interface TimelineAxisSize {

/**
* An axis of exactly this many points, the same on any page width.
*
* @param points width in points
*/
record Fixed(double points) implements TimelineAxisSize {
}

/**
* An axis taking this share of the row's remaining space, against a content weight
* of 1.0.
*
* @param weight relative share
*/
record Weight(double weight) implements TimelineAxisSize {
}
}
Loading
Loading