MIT requires a statement of what was changed when a modified copy is distributed. This is that statement. Upstream is https://github.com/vfyjxf/taffy-java (a pure-Java port of Rust Taffy); the vendored baseline is the published sources of 1.1.4, unmodified except where listed below.
Every change is a diff against that baseline, kept small and annotated in place with a
// CrystalGUI: comment so it survives a future re-vendor. Do not reformat these files — a
whitespace pass would make the next upstream diff unreadable, which is the whole reason a vendored
dependency is worth less than a pinned one.
The engine's leaf-measurement path (MeasureFunc) is unusable under flex-wrap: wrap, and that is
not a configuration we can avoid: it is the only way a row of widgets reflows. The defect is one line
inside the flexbox algorithm, so it can only be fixed here. plan/engine-rewrite.md D3 records the
decision; spike S1 is the measurement that justified it.
The package name is deliberately left as dev.vfyjxf.taffy. mc1710 already declares Taffy as
shadowImplementation, which relocates it under com.crystalgui.shadow. in the shipped jar, so
another mod shipping stock 1.1.4 cannot win a classloader race against this fork — the hazard that
would otherwise make renaming compulsory. Keeping the name leaves 165 call sites across core/ and
the harness untouched.
Upstream line ~1469 read:
float knownMain = isWrap ? NaN : (isRow ? item.targetSize.width : item.targetSize.height);so an item's hypothetical cross size was measured against the container's inner main size
rather than its own flex-resolved main size, whenever the container had flex-wrap: wrap. The
nowrap path was correct. It is now unconditional, which is what upstream Rust Taffy does.
A row whose items all fit on one line must lay out identically whether or not it is allowed to wrap. Upstream failed that for every shape whose cross size had to be measured, and only for those — an item with a definite cross size never takes this path at all, which is why the defect survived: it is invisible for every fixed-size widget in a layout.
A measured leaf reported a height for a width it was never given. Measured on a 200px row holding
two flex-grow: 1 leaves, each wrapping 300px of text:
| container | measure func told | leaf box | text needs |
|---|---|---|---|
nowrap |
known.width = 100 → 3 lines |
100 × 30 | 30 ✓ |
wrap (before) |
known.width = NaN, falls back to available 200 → 2 lines |
100 × 20 | 30 ✗ |
wrap (after) |
known.width = 100 → 3 lines |
100 × 30 | 30 ✓ |
The leaf's width is correct either way and only its height is short, so nothing about the box looks wrong — the last line is simply clipped. That is what makes it expensive to find from a screenshot.
And aspect-ratio items — the case the branch was written for — collapsed. Its comment claimed it "prevents AR from inflating the hypothetical cross based on flex growth, keeping the container cross based on pre-flex-growth AR values (matching browser behavior)". It does neither. Row heights for a 200px wrapping row, measured:
shape (aspect-ratio: 1) |
nowrap |
wrap before |
wrap after |
|---|---|---|---|
one square, flex-basis: 20 |
20 | 0 | 20 |
one square, flex-grow: 1 |
200 | 0 | 200 |
two squares, flex-grow: 1 |
100 | 0 | 100 |
| three fixed 80px squares (genuinely two lines) | 67 | 160 | 160 |
| a fixed-width square beside a plain item | 40 | 40 | 40 |
Four of five wrap shapes came out zero-high. The last row is the one that was never affected: a
definite width makes the cross definite through maybeApplyAspectRatio, so the measure path is
skipped. The two-line case is unchanged by the deletion — the fix does not stop wrapping working.
That narrowing was written first and rejected on the measurements above: it fixes the measured leaf and leaves every aspect-ratio row zero-high, because those items do have aspect ratios and so keep the branch. Three independent reasons agree on deletion:
- CSS Flexbox §9.4 step 7 determines the hypothetical cross size "by performing layout with the
used main size" — the flex-resolved one.
resolveFlexibleLengthsruns immediately before this call (FlexboxComputerlines 281 and 287), sotargetSizeis the used main size. - Upstream Rust Taffy passes
child.target_sizeunconditionally; there is no branch there. - Measured, per the tables above: after deletion every single-line shape agrees with
nowrap, and the genuinely-wrapping shape is untouched.
TaffyWrapMeasureTest in this module — the measured-leaf case (asserting the width handed to the
measure function, not only the height that came out), the aspect-ratio case, and
wrappingStillWraps() as the counter-assertion, because every other assertion is of the form "wrap
must equal nowrap" and a fix that simply stopped wrapping would satisfy all of them. Mutation-checked:
restoring the isWrap ? NaN fails the first two and passes the third.
Upstream pulls fastutil 8.5.12 for seven imports across two files, tree/GridComputer and
tree/TaffyTree: IntList/IntArrayList, FloatList/FloatArrayList,
Int2FloatMap/Int2FloatOpenHashMap, Long2ObjectOpenHashMap. fastutil is one artifact of 12,795
classes, and a jar that shades it pays all of it — measured in CrystalGUI's merged jar, 19.65 MB of
31.20, and 12,808 of 15,892 entries. 63% of a mod, for seven collections.
This fork no longer depends on fastutil at all. The seven types are reimplemented in
dev.vfyjxf.taffy.collection under the same names and signatures, so the two files above changed
only their import lines and all ~80 call sites are untouched — which is what keeps the next
upstream diff readable. build.gradle.kts now declares no compile dependency whatsoever. The merged
jar went to 8.44 MB.
The replacements carry exactly the methods Taffy calls and no more — no java.util.List or Map
implementation, and no remove on Int2FloatOpenHashMap, because Taffy never removes from one. That
is deliberate: an unimplemented method is a compile error at the call site that wants it, which is the
right moment to decide whether the assumed semantics are the ones written here.
Upstream measured every absolutely positioned child against a definite available space:
LayoutOutput output = layoutComputer.performChildLayout(
childId, knownDimensions, new FloatSize(insetRelativeWidth, insetRelativeHeight),
new TaffySize<>(AvailableSpace.definite(containerSize.width),
AvailableSpace.definite(containerSize.height)),
SizingMode.INHERENT_SIZE, new TaffyLine<>(false, false));even on an axis where knownDimensions is NaN — i.e. exactly where the box is shrink-to-fit and the
measurement is supposed to be answering what its content wants. Such an axis is now measured at
AvailableSpace.maxContent(), and only if that overruns what the containing block leaves
(shrinkToFitBound) is it measured a second time against that bound — which is the call above,
unmodified.
A shrink-to-fit box takes min(max-content, available), and its subtree must know it is being
measured. A definite available space says the opposite: the size is already decided. Nothing below
is ever in an intrinsic pass, so a descendant percentage resolves against the containing block — and
CSS Sizing §5 says a percentage against an indefinite containing block behaves as auto during
intrinsic sizing precisely to stop what happens next.
A min-width: 100% on any descendant becomes self-fulfilling: the child claims the whole of the
outer width, the shrink-to-fit ancestor takes that as its content width, and the percentage is then
satisfied by the size it caused. The declaration decides the ancestor rather than following it.
It needs three things at once and is invisible with any two — an out-of-flow ancestor with no width, a scroll container under it, and a percentage minimum under that. Measured on the real tree, with 300px of content in an 800px work area:
| shape | width |
|---|---|
| the percentage minimum on an ordinary child | 300 ✓ |
| a scroll container with no percentage minimum under it | 300 ✓ |
| both, in flow | 300 ✓ |
| both, under an out-of-flow ancestor | 800 ✗ |
Found as every window on CrystalGUI's desktop coming out exactly as wide as the work area whatever it
contained — a window's content slot is a scroll container whose viewport carries min-width: 100%, so
a caller's own width: 100% row cannot collapse. A window that spans the area still looks like a
window, and a full-width one centres at x=0, which is where an unplaced one sits anyway; it surfaced
only in the cascade, which cannot step a window that already fills the area.
Shrink-to-fit is min(max(min-content, available), max-content), and all three terms are
load-bearing. AvailableSpace.definite(...) used to supply the bound as a side effect of being
wrong, so removing it is what makes the bound explicit; but clamping the max-content answer to
available and stopping there implements min(max-content, available) and drops the max(min-content, …), which squeezes anything that cannot shrink. That is not hypothetical — it took the editor's fold
chip, whose own inset leaves it almost no room, to a third of its width at high zoom.
Re-measuring against the bound gets the missing term for free, because a definite available space is
the right answer once the box genuinely is being sized to it: what made it wrong in the first place
was being told the size was decided while it was still being computed. shrinkToFitBound is the
containing block less the insets and margins that are stated, and the result is taken before the
style's own min/max clamp so a declared min-width still wins — which is CSS's order.
The second pass runs only when the first overruns, so nothing that fits pays for it.
TaffyPercentMinIntrinsicTest in this module — the reported shape, the same through a scroll
container, and the three-part repro. Then three counter-assertions, because the others are all of the
form "must not be widened" and a fix that ignored percentage minima, one that measured unboundedly,
and one that clamped flat to available would each satisfy every one of them:
| test | what it forbids |
|---|---|
andStillFillsAParentWhoseWidthIsKnown |
ignoring percentage minima — one against a known width must still bind |
shrinkToFitStillDoesNotExceedTheContainingBlock |
measuring unboundedly — content that can shrink (a wrapping row: max-content 1600, min-content 400, available 800) must come down to the block |
andContentThatCannotShrinkIsNotSqueezedBelowIt |
clamping flat to available — the fold-chip case |
The middle one deliberately uses a wrapping row rather than a rigid box: CSS lets rigid content overflow, so asserting on it would pin a clamp CSS does not have.
Upstream resolved an intrinsic keyword in maxSize to no maximum: there is no length to resolve it against, so
maybeResolveSize answers NaN and the item is uncapped. The main-axis max-content case now measures the item at
max-content, as the flex-basis path already does, and uses that as its maximum.
It is what flex: 1 1 0; max-width: max-content needs to mean: an equal share of the row that stops at each item's
natural width — a squeezed tab strip, where a tab's width must not follow its content or a tab that grew its close
button on hover took width from every other.
Min-content, fit-content and the cross axis are unchanged.
TaffyMaxContentMaxSizeTest — items with room stop at their content; without it they share the row equally.
Two behaviours are silent when wrong, and both are pinned by CollectionsTest:
Int2FloatMap.defaultReturnValueis whatgetanswers for an absent key, and it defaults to0f, notNaN.GridComputersets it toNaNfor its fit-content limits precisely so a stored zero is distinguishable from a missing entry.Long2ObjectOpenHashMap.removeshifts, it does not tombstone. Linear probing ends a chain at the first free slot, so blanking a slot mid-chain strands every entry behind it: they are still in the table,getreturns null,sizeis right, and nothing throws. The removal is Knuth 6.4 algorithm R in fastutil's own formulation, and the test drives it against ajava.util.HashMapover 200,000 random operations followed by a sweep of the whole key space — because no hand-written case finds a stranded entry, and a random one finds it on the first bad shift.
dev.vfyjxf.taffy.collection.CollectionsTest (12 assertions' worth, differential against the JDK),
:core:test --tests "com.crystalgui.ui.box.*", and prodSmoke — the four installed Minecraft clients,
which lay out a full workbench through GridComputer and photograph it.