Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
707f5a6
feat(templates): a receipt laid out like a receipt, not an invoice wi…
DemchaAV Aug 9, 2026
16ed4f1
feat(templates): add the ClassicInvoice layered invoice preset (#608)
DemchaAV Aug 30, 2026
2abfa20
feat(templates): add the structured proposal document model (#609)
DemchaAV Aug 30, 2026
6e34071
feat(templates): add the NorthlineProposal structured proposal preset…
DemchaAV Aug 30, 2026
8dd8934
feat(templates): add the ConsultingInvoice preset and the structured …
DemchaAV Aug 30, 2026
b278f11
feat(templates): add the EditorialProposal structured proposal preset…
DemchaAV Aug 30, 2026
a5dbd61
feat(templates): add the ProfessionalSidebar layered CV preset (#613)
DemchaAV Aug 30, 2026
6ad4018
feat(templates): add the NavySidebar CV preset and a portrait on CvId…
DemchaAV Aug 30, 2026
0f9ce2d
test(examples): guard the sidebar CV samples against a wrapped contac…
DemchaAV Aug 30, 2026
456e407
feat(templates): make the Navy Sidebar contact channels clickable (#616)
DemchaAV Aug 30, 2026
5c0f11b
feat(templates): give CvEntry a location, a mark and a builder (#618)
DemchaAV Aug 31, 2026
8979ce0
feat(templates): add the SerifHeadline two-column CV preset (#619)
DemchaAV Aug 31, 2026
ff546e3
feat(templates): link SerifHeadline titles and keep its band columns …
DemchaAV Aug 31, 2026
a160168
feat(templates): add the CharcoalGold photographic CV preset (#621)
DemchaAV Aug 31, 2026
80cc372
feat(templates): carry a monogram, a ship-to, per-line tax and a paye…
DemchaAV Aug 31, 2026
361f769
feat(templates): promote the Luma Studio invoice onto the structured …
DemchaAV Aug 31, 2026
ac64e9d
feat(templates): add the Terracotta Rail two-column CV preset (#624)
DemchaAV Aug 31, 2026
58dbe13
fix(templates): put the Terracotta Rail contact rows on one axis (#625)
DemchaAV Aug 31, 2026
c76f847
feat(templates): link every title Terracotta Rail draws (#626)
DemchaAV Aug 31, 2026
adf961d
feat(templates): add the Teal Pulse clinical CV preset (#627)
DemchaAV Aug 31, 2026
31cdd94
feat(templates): give CvSkill the level as the document words it (#628)
DemchaAV Aug 31, 2026
6a519a2
feat(templates): add the Slate Orange masthead-and-rail CV preset (#629)
DemchaAV Aug 31, 2026
dc7d905
feat(templates): add the Violet Grid banded CV preset (#631)
DemchaAV Aug 31, 2026
f3a34ad
feat(templates): add the Orange Ops two-column CV preset (#632)
DemchaAV Aug 31, 2026
ccb83b1
feat(templates): add the Midnight Navy plate CV preset (#634)
DemchaAV Aug 31, 2026
c6577f7
feat(templates): give an invoice line the mark its design draws (#636)
DemchaAV Sep 1, 2026
13e9473
feat(templates): add the Payments paginating invoice preset (#638)
DemchaAV Sep 1, 2026
5fb298f
feat(templates): let a billed party print its own registration (#640)
DemchaAV Sep 1, 2026
0300249
feat(templates): add the Workspace SaaS invoice preset (#641)
DemchaAV Sep 1, 2026
f6a1ff8
Merge remote-tracking branch 'origin/develop' into chore/sync-develop…
DemchaAV Sep 1, 2026
86823da
chore(templates): bring the promotion branch up to develop
DemchaAV Sep 1, 2026
038f379
fix(templates): set the invoice page number in the sheet's own face (…
DemchaAV Sep 1, 2026
61f7ea6
feat(templates): make seven CV presets' phone numbers dialable (#643)
DemchaAV Sep 1, 2026
c4ae294
feat(templates): let a supplier carry the legal line it prints under …
DemchaAV Sep 1, 2026
5419617
feat(templates): add a metered-usage invoice preset (#645)
DemchaAV Sep 1, 2026
a4df7e5
test(ci): stop the CodeQL scope guard from being emptied by a rewritt…
DemchaAV Sep 1, 2026
533a65c
feat(templates): let an invoice line carry where it was delivered (#647)
DemchaAV Sep 1, 2026
519f589
feat(templates): add a region-billed platform invoice preset (#648)
DemchaAV Sep 1, 2026
95de7c2
feat(templates): add a per-seat subscription invoice preset (#649)
DemchaAV Sep 1, 2026
52763ab
feat(templates): add a dark invoice preset (#650)
DemchaAV Sep 1, 2026
23cf2eb
feat(templates): add a commerce invoice preset (#651)
DemchaAV Sep 1, 2026
f9f087e
feat(templates): give a proposal the header a one-page sales proposal…
DemchaAV Sep 1, 2026
1006bc3
feat(templates): give a proposal block the paragraph that opens it (#…
DemchaAV Sep 2, 2026
3c0eb8a
feat(templates): add a one-page sales proposal preset (#654)
DemchaAV Sep 2, 2026
36e0bd2
feat(templates): model a staff rota, and retire the roster model noth…
DemchaAV Sep 2, 2026
5ef01bf
feat(templates): add a staff rota preset
DemchaAV Sep 2, 2026
f41d58d
test(templates): invent the schedule fixtures' people and venue
DemchaAV Sep 2, 2026
77283a5
refactor(templates): one place turns a printed contact into a followa…
DemchaAV Sep 3, 2026
6366afc
fix(templates): dial the number the sheet printed, not one like it
DemchaAV Sep 3, 2026
3835925
Merge pull request #656 from DemchaAV/feat/rota-preset
DemchaAV Sep 7, 2026
05b8845
Merge pull request #657 from DemchaAV/fix/schedule-fixture-real-names
DemchaAV Sep 7, 2026
f193583
Merge remote-tracking branch 'origin/feature/template-promotion' into…
DemchaAV Sep 7, 2026
6407b73
Merge pull request #659 from DemchaAV/refactor/contact-uri-sweep
DemchaAV Sep 7, 2026
75cec89
fix(examples): foot the schedule board at the bottom, on the centre line
DemchaAV Sep 7, 2026
cf60cad
Merge pull request #662 from DemchaAV/fix/schedule-footer
DemchaAV Sep 7, 2026
b3ee75d
Merge remote-tracking branch 'origin/feat/templates-payment-receipt' …
DemchaAV Sep 11, 2026
c587a17
chore(templates): ship the receipt family as 2.4.0 work, not 2.2.0
DemchaAV Sep 11, 2026
d3edc26
Merge origin/develop into feature/template-promotion
DemchaAV Sep 12, 2026
dfcfe06
test(receipt): hold the receipt geometry, not only its pixels
DemchaAV Sep 12, 2026
81c8134
refactor(templates): put Midnight Navy's roles on a real timeline
DemchaAV Sep 12, 2026
85f43d8
refactor(templates): put Charcoal Gold's roles on a real timeline
DemchaAV Sep 12, 2026
7852d51
refactor(templates): stop Navy Sidebar's rail at its last marker, by …
DemchaAV Sep 12, 2026
f7a794f
refactor(templates): stop Serif Headline's rail at its last marker, b…
DemchaAV Sep 12, 2026
43f6511
refactor(templates): put Slate Orange's roles on a real timeline
DemchaAV Sep 12, 2026
9e0a76d
test(qa): normalize visual baselines after the engine merge
DemchaAV Sep 12, 2026
4bb0a81
refactor(templates): put both of Terracotta Rail's rails on real time…
DemchaAV Sep 12, 2026
cdbc04c
refactor(templates): retire the education rail mask, which never mask…
DemchaAV Sep 12, 2026
e2b4bc5
test(qa): pin what Violet Grid promises past its one page
DemchaAV Sep 12, 2026
e7b943f
refactor(templates): set Serif Headline's bullet gap as a measurement
DemchaAV Sep 12, 2026
fe80493
refactor(templates): drop a Timeline Minimal bullet that never reache…
DemchaAV Sep 12, 2026
0bd0ff8
refactor(templates): give the receipt real tracking instead of padded…
DemchaAV Sep 12, 2026
204bca1
refactor(templates): track Editorial Proposal's label instead of padd…
DemchaAV Sep 12, 2026
5fd1fb5
refactor(templates): track Northline's label instead of padding it
DemchaAV Sep 12, 2026
839d919
test(qa): re-record the seven baselines the timeline campaign moved
DemchaAV Sep 12, 2026
b327531
docs(changelog): record what the timeline and spacing campaign changed
DemchaAV Sep 12, 2026
a01f469
chore(examples): regenerate the previews this campaign's renders moved
DemchaAV Sep 12, 2026
7dedf8a
chore(examples): regenerate the two previews the drift guard still named
DemchaAV Sep 12, 2026
361ff83
fix(layout): a box must not open on a page its first unit cannot star…
DemchaAV Sep 12, 2026
741054a
feat(timeline): give the gap before the marker its own number
DemchaAV Sep 12, 2026
386e65e
refactor(templates): put Violet Grid's dated rail on a real timeline
DemchaAV Sep 12, 2026
e6f950d
feat(timeline): choose a rail's two ends separately
DemchaAV Sep 12, 2026
4fae35f
feat(api): let a list item be styled in pieces
DemchaAV Sep 12, 2026
c05cebe
feat(api): let a list marker be drawn and carry its own colour
DemchaAV Sep 12, 2026
708722b
fix(examples): re-render the Professional Sidebar preview
DemchaAV Sep 12, 2026
eba6e9f
refactor(templates): put Teal Pulse's dotted lines on a real list
DemchaAV Sep 12, 2026
b5c60b2
refactor(templates): put Orange Ops' skills on a real list
DemchaAV Sep 12, 2026
1a440ea
fix(templates): give a wrapped degree title room instead of the line …
DemchaAV Sep 12, 2026
951133c
chore(templates): drop an import the dotted-line migration left behind
DemchaAV Sep 12, 2026
d0c946a
refactor(templates): hang a stacked row's body under its name
DemchaAV Sep 12, 2026
9b4f856
refactor(templates): drop the bullet offset the row migration left un…
DemchaAV Sep 12, 2026
b62838f
test(api): prove a drawn list marker reaches the page
DemchaAV Sep 12, 2026
0fe8b49
fix(examples): re-render the four previews the merge moved, and one b…
DemchaAV Sep 12, 2026
7ceb58e
docs(templates): say why a timeline goes in through a layer stack
DemchaAV Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1,400 changes: 1,387 additions & 13 deletions CHANGELOG.md

Large diffs are not rendered by default.

Binary file modified assets/readme/examples/cv-blue-banner-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-boxed-sections-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-centered-headline-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-charcoal-gold-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-engineering-resume-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-executive-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-midnight-navy-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-minimal-underlined-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-mint-editorial-v2-custom.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-mint-editorial-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-modern-professional-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-monogram-sidebar-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-navy-sidebar-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-orange-ops-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-panel-v2.pdf
Binary file not shown.
Binary file not shown.
Binary file added assets/readme/examples/cv-serif-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-sidebar-portrait-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-slate-orange-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-teal-pulse-v2.pdf
Binary file not shown.
Binary file not shown.
Binary file modified assets/readme/examples/cv-timeline-minimal-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/cv-violet-grid-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/invoice-classic-v2.pdf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added assets/readme/examples/invoice-payments-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/invoice-workspace-v2.pdf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added assets/readme/examples/receipt-modern.pdf
Binary file not shown.
Binary file modified assets/readme/examples/weekly-schedule.pdf
Binary file not shown.
157 changes: 145 additions & 12 deletions core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.demcha.compose.document.dsl;

import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.ListItem;
import com.demcha.compose.document.node.ListMarker;
import com.demcha.compose.document.node.ListNode;
Expand All @@ -19,7 +20,21 @@ public final class ListBuilder {
private final List<ListItem> items = new ArrayList<>();
private final Map<Integer, ListMarker> markerOverrides = new LinkedHashMap<>();
private String name = "";
private boolean usedNestedAuthoring = false;
/**
* Whether the items still fit the flat {@code List<String>} shape a list had
* before it could nest. Nesting is one reason they do not; a rich item, whose
* content is runs rather than a label, is the other — it has to reach the
* layout as a {@link ListItem}, and the flat path carries only labels.
*/
private boolean needsItemTree = false;
/**
* Whether the author declared depth. Kept apart from {@link #needsItemTree}
* because the two answer different questions, and only this one decides
* whose marker depth 0 takes: a nested list resolves every level from the
* per-depth cascade and {@link #markerFor(int, ListMarker)}, while a flat
* list's marker is its own — and a list of rich items is still flat.
*/
private boolean declaredDepth = false;
private ListMarker marker = ListMarker.bullet();
private DocumentTextStyle textStyle = DocumentTextStyle.DEFAULT;
private TextAlign align = TextAlign.LEFT;
Expand Down Expand Up @@ -50,7 +65,7 @@ private static List<ListItem> applyMarkerOverrides(List<ListItem> items,
? item.marker()
: overrides.get(depth);
List<ListItem> resolvedChildren = applyMarkerOverrides(item.children(), depth + 1, overrides);
out.add(new ListItem(item.label(), effective, resolvedChildren));
out.add(new ListItem(item.label(), item.runs(), effective, resolvedChildren));
}
return List.copyOf(out);
}
Expand Down Expand Up @@ -129,13 +144,77 @@ public ListBuilder addItem(String item) {
*/
public ListBuilder addItem(String label, Consumer<ListBuilder> body) {
Objects.requireNonNull(body, "body");
this.usedNestedAuthoring = true;
this.needsItemTree = true;
this.declaredDepth = true;
ListBuilder childScope = new ListBuilder();
body.accept(childScope);
this.items.add(new ListItem(label, null, childScope.snapshotItems()));
return this;
}

/**
* Appends one list item whose content is styled in pieces.
*
* <p>{@code "Bold label: normal description"} becomes one item rather than a
* row of two columns pretending to be one. The content is a {@link RichText}
* — the same inline runs a paragraph is made of, taken by the same builder
* {@link ParagraphBuilder#rich(Consumer)} takes — so the library has one
* rich-text model and not a second one for lists.</p>
*
* <p>The item lays out on the measured marker geometry:
* {@link #hangingIndent(boolean)} is required, and the layout says so if it
* is missing. The marker is measured, {@link #markerGap(double)} applies, and
* every visual line of the content — the first, the ones it wraps onto, the
* ones that continue on the next page — starts at one x. A rich item is
* content whatever its runs draw, so runs of an icon and no text are still a
* row.</p>
*
* <p>Seed the supplied builder with {@link RichText#plain(String)} — not
* {@code t.text(...)}: {@link RichText#text(String)} is a static factory, so
* that call compiles but builds a separate, discarded {@code RichText} and
* leaves this item empty.</p>
*
* @param content callback that appends this item's inline runs
* @return this builder
* @throws NullPointerException if {@code content} is null
* @since 2.4.0
*/
public ListBuilder addItem(Consumer<RichText> content) {
Objects.requireNonNull(content, "content");
RichText rich = RichText.empty();
content.accept(rich);
this.needsItemTree = true;
this.items.add(ListItem.ofRuns(rich.runs()));
return this;
}

/**
* Appends one nested list item whose own content is styled in pieces.
*
* <p>{@link #addItem(Consumer)} with children, so a styled label can head a
* sub-tree.</p>
*
* @param content callback that appends this item's inline runs
* @param body callback that adds children of this item
* @return this builder
* @throws NullPointerException if either callback is null
* @since 2.4.0
*/
public ListBuilder addItem(Consumer<RichText> content, Consumer<ListBuilder> body) {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(body, "body");
this.needsItemTree = true;
this.declaredDepth = true;
RichText rich = RichText.empty();
content.accept(rich);
ListBuilder childScope = new ListBuilder();
body.accept(childScope);
List<InlineRun> runs = rich.runs();
this.items.add(new ListItem(InlineRun.plainText(runs), runs, null,
childScope.snapshotItems()));
return this;
}

/**
* Overrides the marker used for items at the given depth when no
* per-item marker is set. Depth 0 is the top-level marker; depth 1
Expand Down Expand Up @@ -185,6 +264,49 @@ public ListBuilder marker(String marker) {
return marker(ListMarker.custom(marker));
}

/**
* Sets a marker that is drawn rather than typed — a coloured disc, an icon,
* a glyph in a face of its own.
*
* <p>The marker is a {@link RichText}, the same inline runs a paragraph and
* a list item are made of, so {@code m -> m.dot(4, ACCENT)} is a teal disc,
* {@code m -> m.color("•", ACCENT)} is an accent bullet beside near-black
* text, and {@code m -> m.svgIcon(icon, 8)} is an icon. A marker's colour is
* its own here; the text form takes the list's, which is what a design with
* a coloured mark and dark copy could not say.</p>
*
* <p>Drawn or typed, the marker is measured and occupies the marker column:
* {@link #markerGap(double)} is points of real space after it and every
* visual line of the item starts at one x. So this needs
* {@link #hangingIndent(boolean)}, and the layout says so if it is missing —
* the older layout puts the marker inside the item's text, where a drawing
* cannot go.</p>
*
* <p>Seed the supplied builder with {@link RichText#plain(String)} — not
* {@code m.text(...)}: {@link RichText#text(String)} is a static factory, so
* that call compiles but builds a separate, discarded {@code RichText} and
* leaves the list markerless.</p>
*
* <p>This sets the list's marker, which is its depth-0 marker. A drawn marker
* at a deeper level goes through {@link #markerFor(int, ListMarker)} as
* {@code markerFor(1, ListMarker.ofRuns(RichText.empty().dot(4, ACCENT).runs()))}
* — deliberately not a second lambda overload, because
* {@code markerFor(depth, null)} clears an override and a lambda overload
* would make that call ambiguous for code that already compiles.</p>
*
* @param marker callback that appends the marker's inline runs
* @return this builder
* @throws NullPointerException if {@code marker} is null
* @since 2.4.0
*/
public ListBuilder marker(Consumer<RichText> marker) {
Objects.requireNonNull(marker, "marker");
RichText rich = RichText.empty();
marker.accept(rich);
return marker(ListMarker.ofRuns(rich.runs()));
}


/**
* Uses bullet markers.
*
Expand Down Expand Up @@ -404,18 +526,19 @@ public ListBuilder margin(float top, float right, float bottom, float left) {
* Builds the semantic list node.
*
* <p>When only {@link #addItem(String)} was used the result is a
* flat list (back-compat with v1.4 / v1.5). As soon as
* {@link #addItem(String, Consumer)} is called at least once the
* result is a nested list — flat items added before the first
* nested call become depth-0 leaves alongside the nested entries,
* preserving source order. Per-depth marker overrides set via
* {@link #markerFor(int, ListMarker)} are baked into each item's
* resolved marker before the node is sealed.</p>
* flat list (back-compat with v1.4 / v1.5). As soon as something is
* added that a list of labels cannot hold — a nested item via
* {@link #addItem(String, Consumer)}, or a rich item via
* {@link #addItem(Consumer)}, either of them once — the result is an
* item tree; flat items added before that become depth-0 leaves
* alongside the rest, preserving source order. Per-depth marker
* overrides set via {@link #markerFor(int, ListMarker)} are baked
* into each item's resolved marker before the node is sealed.</p>
*
* @return list node
*/
public ListNode build() {
if (!usedNestedAuthoring) {
if (!needsItemTree) {
// Back-compat flat path. node.items() carries the labels
// and node.nestedItems() is empty; rendering matches the
// v1.4 / v1.5 flat-list behaviour exactly.
Expand All @@ -441,7 +564,17 @@ public ListNode build() {
}
// Nested path. Source order across flat and nested entries is
// preserved because both flow through the unified `items` list.
List<ListItem> resolved = applyMarkerOverrides(items, 0, markerOverrides);
// A flat list's marker is its own, whether its items are strings or
// runs. Only a list whose author declared depth hands depth 0 to the
// per-depth cascade — that is the nested contract, and markerFor(0, …)
// is how it is overridden. Without this a dashed list would render one
// bullet the moment one of its items needed styling.
Map<Integer, ListMarker> effectiveOverrides = markerOverrides;
if (!declaredDepth) {
effectiveOverrides = new LinkedHashMap<>(markerOverrides);
effectiveOverrides.putIfAbsent(0, marker);
}
List<ListItem> resolved = applyMarkerOverrides(items, 0, effectiveOverrides);
return new ListNode(
name,
List.of(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
*
* <p>The rail is one logical line, computed after layout from where the markers and
* entries actually landed and drawn as one fragment per page it crosses. How far it
* runs is a {@link TimelineRailExtent}; where it runs comes from the marker anchor,
* runs is a {@link TimelineRailEnd} at each end; where it runs comes from the marker anchor,
* a gutter to the left of the markers by default or through them after
* {@link #markerOnRail()}. It is drawn beneath the markers, so a filled marker
* covers the line passing under it.</p>
Expand All @@ -61,10 +61,13 @@ public final class TimelineBuilder {
private final List<TimelineEntryBuilder> entries = new ArrayList<>();
private DocumentStroke railStroke = DocumentStroke.of(DEFAULT_RAIL, 1.5);
private String railDeclaredBy;
private TimelineRailExtent railExtent = TimelineRailExtent.ENTRY_BOUNDS;
private TimelineRailEnd railStart = TimelineRailEnd.ENTRY_BOUND;
private TimelineRailEnd railEnd = TimelineRailEnd.ENTRY_BOUND;
private TimelineMarkerAnchor markerAnchor;
private double gutter = 8.0;
private double markerGap = 8.0;
/** Unset until asked for, because unset means "the same as the marker gap". */
private Double leadingGap;
private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10);
private String axisDeclaredBy;
private DocumentRowColumn leadingColumn;
Expand Down Expand Up @@ -143,8 +146,11 @@ public TimelineBuilder rail(Consumer<TimelineRailBuilder> spec) {
Objects.requireNonNull(spec, "spec");
TimelineRailBuilder builder = new TimelineRailBuilder();
spec.accept(builder);
if (builder.extent() != null) {
this.railExtent = builder.extent();
if (builder.start() != null) {
this.railStart = builder.start();
}
if (builder.end() != null) {
this.railEnd = builder.end();
}
if (builder.stroke() == null) {
return this;
Expand Down Expand Up @@ -234,6 +240,34 @@ public TimelineBuilder markerGap(double gap) {
return this;
}

/**
* Sets the horizontal gap between the leading column and the marker.
*
* <p>Without this the gaps either side of the marker column are one number, and that
* over-constrains a three-column timeline. Writing {@code x0} for where the columns
* start, {@code L} for the leading width and {@code A} for the axis, one gap {@code s}
* puts the rail at {@code x0 + L + s + A/2} and the content at {@code x0 + L + 2s + A};
* subtract them and {@code L = (rail − x0) − (s + A/2)}. The leading width is then
* decided by where the rail and the content sit, whatever {@code A} and {@code s} are
* given — so a design stating all three, as a dated timeline does, cannot have them.
* Nor can it recover them by padding the leading column, which only narrows what goes
* inside it, or by widening the axis, which re-pins {@code L} to the same number.</p>
*
* <p>With the two gaps separate, {@code L} is the caller's and this absorbs the
* difference. Unset, it is {@link #markerGap(double)} — which is the single-gap layout
* exactly, so a timeline that does not ask for this is laid out as it was.</p>
*
* @param gap gap in points; negative values are ignored
* @return this builder
* @since 2.4.0
*/
public TimelineBuilder leadingGap(double gap) {
if (gap >= 0) {
this.leadingGap = gap;
}
return this;
}

/**
* Sets the relative width of the marker column (its weight against a content
* weight of 1.0). Increase it for large numbered discs on narrow timelines.
Expand Down Expand Up @@ -471,21 +505,17 @@ private TimelineSpec normalize() {
// One owner per timeline, allocated here. Every marker below anchors on this
// instance, so the pass that draws the rail asks for it and gets these markers and
// nobody else's — two timelines on a page never merge.
if (railExtent == TimelineRailExtent.TIMELINE_BOUNDS) {
throw new IllegalArgumentException(
"TimelineRailExtent.TIMELINE_BOUNDS is not implemented. On one page it is the "
+ "same line as ENTRY_BOUNDS, and across pages there is nothing to measure it "
+ "against — a timeline's own box draws nothing. Use ENTRY_BOUNDS or "
+ "MARKER_TO_MARKER.");
}
TimelineRailSpec railSpec = new TimelineRailSpec(railStroke);
// The gutter is only knowable here, so the default anchor is resolved here too —
// and it is the same model the opted-in one uses, not a branch beside it.
TimelineMarkerAnchor anchor =
markerAnchor == null ? TimelineMarkerAnchor.atLeftEdge(gutter) : markerAnchor;
return new TimelineSpec(new TimelineRailOwner(railSpec, railExtent, anchor),
railSpec, leadingColumn, gutter, markerGap, axis, anchor, entrySpacing,
keepTogether, keepEntriesTogether, List.copyOf(specs));
// Unset resolves to the marker gap, which is the single-gap layout the two columns
// either side of the axis have always shared.
double resolvedLeadingGap = leadingGap == null ? markerGap : leadingGap;
return new TimelineSpec(new TimelineRailOwner(railSpec, railStart, railEnd, anchor),
railSpec, leadingColumn, resolvedLeadingGap, gutter, markerGap, axis, anchor,
entrySpacing, keepTogether, keepEntriesTogether, List.copyOf(specs));
}

/**
Expand All @@ -502,7 +532,9 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
// only when the rail moved into the axis; with the rail beside it the body spans the
// entry as it always has, and the header row publishes nothing.
boolean bodyClearsTheAxis = spec.markerAnchor().railRunsThroughTheAxis();
int contentColumn = spec.leadingColumn() == null ? 1 : 2;
// [axis][content] with no leading column; [leading][gap][axis][gap][content] with
// one, because the two gaps are columns rather than one row spacing.
int contentColumn = spec.leadingColumn() == null ? 1 : 4;
List<TimelineEntrySpec> entries = spec.entries();
for (int i = 0; i < entries.size(); i++) {
TimelineEntrySpec entry = entries.get(i);
Expand All @@ -524,25 +556,42 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
.padding(new DocumentInsets(0, 0, bottom, spec.gutter()))
.spacing(4);
Consumer<RowBuilder> headerSpec = header -> {
header.spacing(spec.markerGap());
DocumentRowColumn axis = column(spec.axis());
if (spec.leadingColumn() == null && spec.axis() instanceof TimelineAxisSize.Weight weight) {
// The same two columns either way — columns(weight, weight) resolves
// exactly as weights(...) does, confirmed by the snapshots. But
// weights(...) is what a timeline has always put on its RowNode, and
// RowNode.weights() is public; spelling it the other way empties that
// list for every timeline that exists. Sugar where the sugar applies.
header.spacing(spec.markerGap());
header.weights(weight.weight(), 1.0);
} else if (spec.leadingColumn() == null) {
header.spacing(spec.markerGap());
header.columns(axis, DocumentRowColumn.weight(1.0));
} else {
header.columns(spec.leadingColumn(), axis, DocumentRowColumn.weight(1.0));
// Three columns need two gaps, and a row spaces every pair of its
// columns by one number — which is what tied the leading width to
// the rail and content positions. So the gaps are columns of their
// own and the row spaces nothing. With leadingGap defaulting to
// markerGap this resolves to the same widths the single spacing
// gave: fixed columns take their width either way, and the weighted
// remainder is the same subtraction in a different order.
header.spacing(0.0);
header.columns(spec.leadingColumn(),
DocumentRowColumn.fixed(spec.leadingGap()),
axis,
DocumentRowColumn.fixed(spec.markerGap()),
DocumentRowColumn.weight(1.0));
// Present even when this entry put nothing in it: the column is the
// timeline's, not the entry's, and an entry that skipped it must
// still start its marker where every other entry starts one.
header.addSection(entry.leading() == null ? column -> { } : entry.leading());
header.addSection(column -> { });
}
header.addSection(anchoredMarker(spec, entry, index));
if (spec.leadingColumn() != null) {
header.addSection(column -> { });
}
header.addSection(entry.beside());
};

Expand Down
Loading
Loading