Compare commits

...
Author SHA1 Message Date
iris-ai a2cb4f05da WIP: a stack does not take its sizing child's fraction twice
Every child gets the whole of the stack's box rather than `box_of(size)`,
and `widget_at` does not resolve a rule into a box already chosen from it.
Fixes half a row becoming a quarter, which no oracle can see because warm
and cold shrink alike. Pinned by
`a_stack_sized_by_a_child_does_not_take_that_childs_fraction_twice`.

Not landed. Seed 1091 at depth 4, `shuffle-swap-for-three`, disagrees by
three steps of the grid -- warm 1053.9971 against cold 1054 -- where the
oracle tolerates two. Not a structural divergence: `box_of` was also making
a child's placement exact, by handing it a box of exactly the length it
asked for, and giving it the whole box instead puts a rounding back at each
nesting level. Two nested stacks is three steps. Widening AGREE_STEPS is
not the answer; finding the composition that went from exact to rounded is.

Everything else is green: suite, shrinker at 400 seeds of depth 5, oracle at
1000 seeds of depth 6, and the rest of 2000 seeds at depth 4.

Also carries examples/text.rs's top panel taking the full width.
2026-09-17 04:50:00 -04:00
iris-ai a92c6acdbf Settle a frame strictly bottom-up rather than escalating into a parent
The queue was already deepest-first, but a widget that could not settle
where it was called `redraw` on its parent from inside itself. That drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending, and a parent drawing over a subtree that has not settled reads
answers about to move: the one that settles does so inside the parent's
draw, where its mark comes off and nothing compares what it now answers.
Seed 564 was exactly that, and it is the second time this shape has been
found.

So a widget that cannot settle defers instead. It marks its parent, stays
marked itself, and waits in `deferred` until the walk down the depths
reaches the parent -- which cannot be before everything deeper has settled,
because the walk always takes the deepest widget that is not waiting. The
category stops being something to check for. (Bryan, 2026-09-17.)

`dirty_size_under` stays in `draw_inner` for now: `update` draws the root
for a resize before `redraw_updates` runs at all, so the ordering does not
cover that entry.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6, and 2000 seeds at depth 4 over all fifteen cases. Drawn
widgets, widget draws and primitive writes are unchanged on every rig phase;
`many` pays 51 queue pops for 27 and 1059 depth reads for 410, which is the
deferring and nothing else.
2026-09-17 04:29:41 -04:00
iris-ai c8beca5753 Give the text example's aligned labels the width to align in
All three sat in the middle of a box the width of the widest of them, so
left, centred and right were the same picture. `text_align` puts the glyphs
somewhere in the box the text is given, and a text that reports the width of
its own glyphs is given exactly that -- there is nowhere for it to sit.
Declaring `rel(1.0)` on each hands it the row instead. (Bryan, 2026-09-17.)
2026-09-17 03:21:17 -04:00
iris-ai 4bd8607968 Report the step at or above a text's longest line
A wrapping text reported the width it used rounded to the nearest step,
which is under the line it measured half the time. A parent that sizes
itself from that report then hands the text back a box its own longest line
does not fit in, and breaking there is a different break -- one line more.

Two tolerances were hiding it and both go. `TextBuffer::shape` answered a
width up to 0.05 px under the longest line from the break in hand, which is
a structural decision taken on a hair's breadth: it kept a warm tree
self-consistent while a cold tree at the same width broke differently, and
0.05 px is fifty steps of the grid. The `Holds` range the text declares
started at the nearest step to its longest line for the same reason, so it
admitted boxes the line does not fit in. Both are the line itself now,
exactly, because the report no longer lands under it.

Found by seeds 1121 and 1839 at depth 4, which fail on `ea6dbae` and every
commit before it: a defect older than anything on this branch, reached by
running 2000 seeds at a depth the long runs do not use. Shrunk to the eight
widgets `a_text_is_given_back_a_box_the_line_it_measured_fits_in` builds.
2000 seeds at depth 4 over all fifteen cases are clean now, as are the
three long runs.

`text` is the one reference render that moves: its lower paragraph shifts a
pixel, the box being a step wider and its left edge crossing a snap
boundary. Same words, same lines, same breaks; `tabs`, `view`, `minimal`
and `random` are byte-identical.
2026-09-17 03:18:53 -04:00
iris-ai ffd79f32d3 Read a child's report as a fraction of the containing widget
`rel(0.5)` is half the span whatever else is in it and wherever the child
sits among them (Bryan, 2026-09-17). It was half of what the span had left
at the point it asked, because a report came back composed through the box
it was offered and a span offers each child the room from its cursor -- so
a nested span taking half of what it was given took a quarter of a row
whose first half was already spoken for, where the same half written as a
rule on the child took half the row.

The offer stays the remainder: a text has to wrap at the width actually
there, and `a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row`
pins that. What separates from it is the base a report's fractions are of,
which the ask now carries. It is the box the child was given wherever that
box is the child's whole area -- a pad's inset, a stack child, a scroll's
content -- and a span passes its own extent along the row.

`widget_decided` becomes `widget_at`, which says both things about an ask
rather than one of them; `widget_within` is still the sugar for neither.

Two spans asking for half each now take the whole row between them and a
third overflows, which the rewritten
`a_span_reads_a_child_report_as_a_fraction_of_the_row` states outright.
The five reference renders are byte-identical at 1920x1200 and `random`
live-resized still matches a cold render, so nothing that exists reports a
fraction to a span today.
2026-09-17 02:56:51 -04:00
iris-ai 0e0d4af326 Refuse a retained answer while something the widget measured is dirty
`draw_inner` took an answer from `try_reuse`, which checks only whether the
widget itself is marked, where `retained_answer` beside it also refused one
while anything the widget read a size from was dirty. A widget whose drawing
happened to be reusable therefore handed back the answer it gave before that
descendant changed.

Nothing puts that right afterwards. The comparison that tells a reader its
child's answer moved is in `redraw`, and a widget settled inside its parent's
own draw never goes through it -- the placing ask redraws the subtree, the
descendant's mark is cleared there, and the parent keeps a number the tree no
longer agrees with. So the check is not the optimization its comment claimed;
it is what makes the answer an answer, and both retained routes are answers,
so it is asked once in `draw_inner` rather than by one of them.

Found by the generated oracle at seed 564, depth 6, `shuffle-every-other`,
while reading a child's report as a fraction of the containing widget: that
reading lets a span overflow itself, which makes the two asks' boxes differ
far enough for the placing one to redraw.

Twenty-five rig work counters are unchanged on `cold`, `repaint`, `scroll`,
`resize` and `size`; `many` makes 18 fewer reuse attempts, 17 of which
already reported "dirty". Both long fuzzers green.
2026-09-17 02:46:25 -04:00
iris-aiandClaude Opus 5 ea6dbae0dc Hand a redrawn widget the mask it inherited, not its own
`ActiveData::mask` is the mask a widget's drawing is clipped to, which is
either one it set itself or the one it inherited. `redraw` passed it back as
the *inherited* mask, so a `Masked` widget settled on its own was handed its
own mask and `set_mask` asserted -- a panic on any local redraw of one, for
as long as there has been a local-redraw path. The two are separate facts, so
`parent_mask` keeps the second.

That also states the question `remap_subtree` was asking. It compared a
widget's mask with the one threaded down from its parent to find out whether
the widget owned it; the comparison is now between the two fields on the
widget, which is the same question asked where the answer lives, and the
parameter goes.

Checked: fmt, clippy, 87 suite tests including the new one, which panics
without this; 18 core unit tests; the release oracle at 100 seeds; the
fifteen shrinker cases at 400 seeds of depth 5; and `tabs` renders
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:18:39 -04:00
iris-aiandClaude Opus 5 32542d0c0b Thread a box in pixels down the draw, one multiply from its parent's
A box in pixels was composed back up the move chain, on a grid fine enough
that the walk rounded once, while a widget's offer was threaded down through
its ancestors' offers. Two routes to one length, which is what
`Holds::through` allowed for -- and the offer's route broke at a region node.
`offered_region` fell back to `UiRegion::FULL` there, and `redraw` resolved
that against the node's slot entry, which holds the box its parent *placed*
the node in. Under a `Scroll` that is as long as the content rather than the
viewport, so everything below was re-asked at a width its own answer had
produced and the old answer confirmed itself: shrinker seed 220 on `reorder`
left a widget 290px out.

`ActiveData` now keeps a widget's box as lengths of its parent's box --
`given_len`, and `offer_len` for the box it was first asked about -- and
`DrawInfo` carries the pixel lengths, threaded down one `Len::to_px` at a
time: the box its parent gave it, then the part of that box its own answer
placed the drawing in, which `placed_lens` states once for both `placed_box`
and the walk. `Painter::px_size` and `px_len` read that value, and
`UiRenderState::asked_px` takes the same steps back up the parent chain where
a local redraw starts part-way down the tree. Neither chain has a coordinate
frame in it, so neither can break at a region node, and warm and cold reach
every length by the same expression.

Three things follow. `Holds::through` is the exact preimage of
`px + floor(rel * box)` -- two divisions, no allowance, the whole of a box
mapping back to itself. A local redraw asks in the box its parent gave it and
only where that box is as long as the offer, which retires `redraw`'s third
ask and the region-node exception beside it; `draw_inner` places the answer
inside that box itself. And symbolic regions are left to the GPU, hit testing
and remaps, where `Moves::resolve` is the only walk: `wide.rs`,
`Moves::compose`, `Moves::size_of`, `px_of`, `px_region`, `offered_region`
and `slot_wide` are gone, 252 lines of `core/` net.

`px` is deliberately not stored beside those lengths. A resize every widget's
`Holds` admits redraws nothing, so a stored pixel length would be stale on
every widget in the tree with nothing on it to say so, and refreshing it costs
a walk down every reused subtree on the resize path.

Instructions:u, medians of 21 runs, seed 1 at depth 8:

| phase | before | after | |
| --- | ---: | ---: | ---: |
| `cold`, 200 frames | 313.1M | 312.9M | -0.04% |
| `resize` | 408.1M | 405.6M | -0.61% |
| `many` | 1,924M | 1,756M | -8.75% |
| `scroll` | 357.3M | 323.4M | -9.49% |
| `repaint` | 363.3M | 315.4M | -13.18% |

`cold` and `resize` have all twenty-five work counters identical, so those
two rows say the draw path costs the same threaded as composed. The other
three do less work: `repaint` goes from 23 draw requests and 13 widget draws
a frame to 1 and 1, `scroll` from 20 and 11 to 8 and 2, `many` from 273 and
186 to 207 and 157. Primitive writes are unmoved in every phase.

Verified: `view`, `minimal`, `random`, `tabs` and `text` render
byte-identical at 1920x1200 against `5b78002`, as does the `tabs` touch
replay before and after the gesture, and a live resize of `random` to
1280x800 is identical both to the old head's and to a cold render at that
size. The oracle passes 100 seeds in release and 120 in debug -- the debug
run is the one that exercises the `Holds` assertion -- and the fifteen
shrinker cases pass at 400 seeds of depth 5 and 1000 of depth 6. Seed 220 is
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`,
which needs both halves of this to fail: the old chain with the old allowance
passes it, and the old chain with the exact preimage does not.

`AGREE_STEPS` stays 2. One step passes the 100-seed oracle and fails the
400-seed shrinker on `resize-size` by 0.002 px, so what is left there is the
resize path re-expressing a part as a fraction of a box that changed length,
not a length reached two ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:12:56 -04:00
iris-aiandClaude Fable 5.1 5b7800264d Read a child's answer in the asker's frame, and drop the root move entry
A widget reports a fraction of the box it was given. Span added that
fraction straight into a cursor that counts fractions of the row, and Pad
summed its padding onto it, both right only while the offer had the
parent's whole extent -- which a span's does not after a relative child.
DrawResult::size and known_len now compose the answer through the offer's
length, so a container reads lengths of its own box.

That exposed placed_box scaling a fractional answer against a box the
parent had already chosen from it, halving a nested span twice. The
near-edge alignment override becomes per-axis `decided` flags: a box the
parent chose from the answer is the answer, and is not placed again.
Span decides the row axis; Scroll and Stack's sizing child decide both.
Alignment is always the widget's own property now.

The window is no longer a move entry. Chains bottom out in MoveIdx::NONE
and the window is applied where a fraction becomes pixels, in to_px on the
CPU and by the uniform in the shader, which now snaps the summed coordinate
since a floor does not distribute over a sum. A resize rewrites no entry.

Verified: view, minimal, random, tabs and text render byte-identical at
1920x1200 against 5f16617, a live resize to 1280x800 is identical to a
cold render, and the 100-seed oracle, all fifteen shrinker cases at 400
seeds of depth 5, and 1000 seeds of depth 6 pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:00:15 -04:00
iris-aiandClaude Opus 5 5f16617511 Carry the composed box down the draw, rather than walking back up for it
Every widget that reads its box in pixels was making `Moves` compose its
slot's chain again, a mean of 2.8 levels, about eight hundred times a frame.
A draw already descends past every one of those entries on its way in, so
`DrawInfo` carries what the slot composes to and `draw_at` steps it one box
further -- which is a select where it was a walk. `Moves::size_of` and
`compose` are left for `redraw`, which starts mid-tree with nothing above it
in flight.

Measured on the fixed-shape fixture, seed 1 depth 8, 500 frames of `many`,
medians of 25 runs, twenty-five work counters identical throughout:

| | instructions | cycles |
| --- | ---: | ---: |
| `d21a215`, before exact composition | 1,908M | 760M |
| `45a7176`, composing on the fine grid | 1,880M | 755M |
| this | **1,840M** | **735M** |

So exact composition ends up 3.6% fewer instructions and 3.3% fewer cycles
than the rounding-per-level walk it replaced, and the widening it needed was
paid for twice over by not doing the walk.

`Holds::through`'s allowance does not move: two half steps is where shrinker
seed 220 pins it, not where the arithmetic does. `Painter` still composes a
child's region into its own on the grid before asking for it in pixels, which
is the last narrow step in that path; taking it out needs the child's region
as its parent stated it, which `draw_inner` is not handed.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text`, `random` and the tab
replay byte-identical at 1920x1200 against `45a7176`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:49:25 -04:00
iris-aiandClaude Opus 5 45a717695b Compose a box down its chain once, not once a level
Bryan's call, 2026-09-16, for correctness. `Moves` walked the move chain in
`Len`, so every level's four multiplies landed back on the grid before the
next started and the residue grew with the depth of the tree. `WideLen`
carries a length through the walk on a grid twenty-four bits of a box and
twenty-two of a pixel finer, and rounds once at the end.

What it buys, measured rather than argued: `Holds::through`'s allowance for
the two routes to a length drops from three half steps to two, and the whole
of a box now maps back to a range one step wide rather than one step per
level of nesting. One half step further is arithmetically available -- the
`Holds` assertion is quiet there and the whole-box case becomes an exact
identity -- and it is **not taken**, because shrinker seed 220 then lays out
differently warm than cold. Too narrow is meant to cost a redraw and no more;
there it re-breaks a wrapping text, whose reported width moves a `Branch`
onto its other subtree. That is the unsettled-text family, and closing it is
what would let this go lower. The note is in `through`.

`Moves` now answers three questions instead of one, and they are different
questions: `size_of` for how long a box is, which is what reads a box in
pixels; `compose` for where both of its ends are, which is what compares two
boxes; and `resolve`, unchanged, for the `Len` walk the vertex shader does
again in floats. A length composes on its own in two multiplies a level
rather than four, since where the parent sits falls out of the difference --
which is most of why this is not slower.

Measured on the fixed-shape fixture, seed 1 depth 8, 500 frames of `many`,
medians of 25 runs with all twenty-five work counters identical between the
two: 1,880M instructions and 755M cycles against 1,908M and 760M. So it is
free, and a little better on instructions. Three things were tried on the way
and two kept: composing the length alone rather than both ends (-111M
instructions), taking the pixel term's fraction on the ordinary grid so it
stays in an `i64` (-2M instructions, -8M cycles), and skipping a parent that
spans its own box, which **cost** 18M instructions and is not here -- the
same verdict a short-circuit got in `UiSpan::within`.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text` and `random`
byte-identical at 1920x1200 against `d21a215`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:30:45 -04:00
iris-aiandClaude Opus 5 d21a21524f Rename WidgetPtr to Wrapper and give it a builder
Bryan's call, 2026-09-16: a length and an alignment are properties of one
widget, so a widget cannot both be 100 wide and take two shares of a row --
that needs two widgets, and the second one should do as little as possible.
`WidgetPtr` already was that widget: it draws its child in the whole of its
box and reports what the child said. It only lacked a name that says so and
a way to make one around an existing widget.

`Wrapper` rather than `Wrap` so it cannot be read as the text setting, and
`.wrapper()` rather than `.wrapped()` for the same reason. Its child stays
optional, since being a swappable slot is what it was written for and what
the tab bar still uses it as.

`set_ptr` is deleted rather than renamed. It had no caller, and putting a
widget into an existing wrapper is what `Wrapper::set` already does.

`tabs` draws its centred square again: `.sized((100, 100)).center()
.wrapper().width(leftover(2))` is two widgets where the chain without
`.wrapper()` was one, and `.width` was overwriting what `.sized` set. That
was the last of the three ways `tabs` had drifted from canonical `main`
unnoticed; what is left between them is the truncated multiply's antialiased
edges and the widget count itself.

`widget_trait!` takes no attributes, so `.wrapper()` carries an ordinary
comment and the explanation lives on `Wrapper`.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds, and `tabs` rendered at 1920x1200 against `main`'s own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:48:07 -04:00
iris-aiandClaude Opus 5 e166e005dc Pin that a length in pixels is that many pixels
Asked of the `tabs` render: does a gap come out the same number of pixels
wherever it appears? For a length in pixels it does, and structurally rather
than by luck -- `Len::within` adds a part's own pixels rather than scaling
them, and both ends of a gap carry the same fraction, so the multiply that
rounds is the same on each and cancels. The test buries a row of five under
three containers that are each a fraction of their parent, so nothing
reaches the window without being composed and rounded, and checks every gap
and every declared width at five box widths. Swept over 2,100 widths when it
was written and exact at every one.

For a share it does not, and the second test pins by how much rather than
pretending otherwise: one or two steps between children that asked for the
same fraction, 0.001 to 0.002 px. A position is the quantity that gets
rounded so the row fills exactly and no two children leave a seam, and that
is what costs it. Exact composition would shrink the spread, not remove it:
five equal lengths cannot fill a row whose step count is not a multiple of
five.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:16:18 -04:00
iris-aiandClaude Opus 5 38eba543f6 Tighten a validity range to what the arithmetic needs
`Holds::through`'s allowance for the two routes to a length was four half
steps either side, from a derivation that said each rounding now drops a
whole step where it used to drop half of one. That overshot: three is the
floor, two fires the `Holds` assertion in `draw_at` on eleven generated
cases, and four was never measured as necessary. Tightening both ends did
not move one of the rig's twenty-five work counters, so the extra half step
was not buying any reuse either.

It cannot go to zero. The range has to contain the box a drawing was made
in, which the assertion checks, and it must not contain a box the drawing
does not hold for, which the warm-against-cold oracle checks -- and those
two only coincide where a length reached two ways is the same number. It is
not, yet; composing in `i64` and narrowing once is the queued change that
would make it so, and shrinking this allowance is how to tell whether that
worked.

Checked: fmt, clippy, 81 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text`, `random` and the tab
replay byte-identical at 1920x1200 against `2bc6bdf`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:15:44 -04:00
iris-aiandClaude Opus 5 2bc6bdfc77 Let a child with room to move use its own alignment
`Stack` and `Pad` forced the near edge on every child. That override exists
so a container that reports a child's size and then hands it the box derived
from that report does not place its content twice -- and it is owed only
where the box really is the child's own answer.

`Stack` gives every child the box its sizing child defines. That box is
`box_of(child.size())`, so the sizing child has no room in it and needs the
override; every other child is handed a box that owes nothing to it, and
where it sits in one bigger than itself is its own business. With the
override it could not be aligned at all.

`Pad` reports its inner's size plus the padding, so where its box is that
answer the inset box is exactly the inner and alignment has nowhere to move
it. Where the box is bigger -- a share of a row, a rule over the pad -- the
slack belongs to the inner, and the override pinned it to a corner.

The `tabs` example is the visible case both ways: its counters asked for
`Align::RIGHT` inside a stack and sat at the left, and `text`'s narrow panel
filled a row it had asked to sit at the top of. Both match canonical `main`
again. Neither was noticed when `d3b0ebf` made alignment a property, and the
handoff's claim that `tabs` then "differs only in the widget count it prints
about itself" was wrong -- it was checked at `8220a78` and not re-checked
after the next commit.

Checked: fmt, clippy, 81 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, and all fifteen shrinker cases at
400 seeds of depth 5. `tabs`, `text` and `random` change exactly where a
child now honours its own alignment; `view` and `minimal` are unchanged.

`tabs` is still not `main`'s render: `.sized((100, 100)).center().width(
leftover(2))` on one widget no longer means a square centred in a two-share
box, because one widget carries one length per axis and `.width` overwrites
what `.sized` set. That one is an API question, not a bug, and is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:03:09 -04:00
iris-aiandClaude Opus 5 d8ae9c3bdd Place a locally redrawn widget once, in the box already chosen for it
`redraw` asks a dirty widget at its offer, and then again in the final box
its parent chose from that answer. The second ask handed that box over as if
it were an offer, so `draw_inner` ran `placed_box` on it and applied the
widget's own alignment to a box that had already been placed -- a second
placement on every local redraw of a widget that is not near-aligned. It
only showed where the widget's alignment was its own to apply: a container
override makes `draw_inner` take the box as given, and `Stack`, `Pad` and
`Scroll` override every child they hand a box to.

It is the fix for both of the handoff's standing warm-against-cold failures.
Shrinker seed 288 on `region-node` was an 8.8px inset at each end of a `Text`
under a `Span(Y-)` under two `Stack`s; oracle seed 326 at depth 6 was 88px on
a `Text` under two `Branch`es. Neither reduced below 11 and 43 widgets, and
both are this.

Checked: fmt, clippy, 80 suite tests, 17 core unit tests, the release oracle
at 100 seeds, **all fifteen shrinker cases at 400 seeds of depth 5**, and
**1000 seeds of depth 6** -- the last two for the first time. `tabs`, `text`,
`random` and the tab replay render byte-identical at 1920x1200 against
`08c9d5a`, since nothing about a cold layout changes.

Generated seed 20 at depth 4 catches it and joins the ordinary set, so
`cargo test` fails without this rather than only the ignored long run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:58:07 -04:00
iris-aiandClaude Opus 5 08c9d5aa32 Drop a multiply to the step below rather than rounding it
Bryan's call, 2026-09-16, taken for the cycles: a share now lands a
thousandth of a pixel short of its row instead of on it, which is less than
an even number of pixels draws.

`Fixed::mul` is a widening multiply and a shift, with the sign branch and the
half-step add gone. The two short-circuits priced against the old multiply go
with it: `UiSpan::within`'s test for a span that is the whole of its parent,
and `Fixed::scaled`'s test for nothing scaled by something, which was the
whole of `scaled` -- both cases come out of the truncating multiply unchanged,
and the bodies the comparisons cost were what kept the inliner from taking
`within` at all. `nm` is the check: `<UiSpan>::within` is a symbol in the
rounding head and in neither the float head nor this one.

`Holds::through` inverts the multiply, so its widening is re-derived: each
rounding now drops a whole step where it dropped half of one, which doubles
the allowance for the two routes to a length, and the multiply on the way in
drops only downward, so its own step goes at the top of the range alone. The
derived allowance for one truncation either side is measurably too narrow --
it excludes boxes drawings were made in, in eleven generated cases -- because
each route is a chain of multiplies rather than one.

Measured on the fixed-shape fixture (`Edits::fixed_branches`), seed 1 depth 8,
500 frames of `many`, medians of 25 runs of uninstrumented release binaries
with this VM's garbage `perf` readings dropped:

| | instructions | cycles | IPC |
| --- | ---: | ---: | ---: |
| `5ed9e87`, the float head | 1,761M | 688M | 2.561 |
| `60367d8`, rounding | 1,915M | 777M | 2.465 |
| this | 1,800M | 715M | 2.516 |

-6.0% instructions and -8.0% cycles against `60367d8`, whose twenty-five work
counters are identical to this one's, so that pair is the same work at a
different speed. It leaves +2.2% and +3.9% against the float head, from
+8.7% and +12.9% -- but the float head draws 100 widgets to this one's 97 and
writes 4,272 primitives to 3,951, so that pair is not, and the remainder is
not all arithmetic.

Checked: fmt, clippy, 80 suite tests and 18 core unit tests, the release
oracle at 100 seeds, all fifteen shrinker cases at 400 seeds of depth 5 (seed
288 on `region-node` still failing, unchanged), and depth-6 oracle seeds 18
and 190 passing with 326 still failing. `view`, `minimal`, `text`, `random`
and the tab replay render byte-identical at 1920x1200; `tabs` differs on
4,664 of 2,304,000 pixels, single-pixel-wide runs along 80 columns of one
band of rounded rects, which is an antialiased edge moved less than a pixel.

Three tests say what changed rather than being relaxed: a multiply drops on
both sides of zero, a division cannot put back what it dropped, and an
unevenly nested row's shares stay contiguous and end at its edge with each
edge on the even division or one step below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:03:45 -04:00
iris-aiandClaude Opus 5 60367d806e Do not widen a validity range where nothing rounded
`Holds::through` inverts `px + rel * box`, and allowed three half steps
either side: one for that multiply's rounding and two for the difference
between a length composed down the chain and the same length measured
against the window. The whole of a box has no multiply in it -- `rel` is one
and taking the pixels off again is exact -- so the first half step was being
allowed for a rounding that did not happen, and it compounded: a chain of
widgets each taking the whole of its parent grew the interval half a step a
level. Traced while making the multiply truncate, where the same compounding
moved the interval off the box the drawing was made in and fired the
`Holds` assertion in eleven generated cases.

A range wider than what a drawing holds for is one that admits reusing it
where it does not hold, so this is the unsound direction to be loose in.

Checked: fmt, clippy, 80 suite tests and 16 core unit tests, the release
oracle at 100 seeds, all fifteen shrinker cases at 400 seeds of depth 5
(seed 288 on `region-node` still failing and unchanged by this), and `tabs`,
`view`, `minimal`, `text`, `random` plus the tab replay byte-identical at
1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:46:44 -04:00
iris-aiandClaude Opus 5 aea878d141 Place a locally redrawn widget in its box, not just at its length
A dirty widget is asked again in the box its parent asked it in, and then
again in the box its parent chose from that answer. The second ask was
skipped whenever the two boxes were the same *length*, which is not the same
question: an offer as long as the final box but somewhere else is a different
box. `d3b0ebf` already compared whole boxes for `parent_must_place` and left
this one a length comparison, so the two halves of one decision disagreed.

It shows on a region node, which draws the box it drew in into its own move
entry. A scroll inside a scrolled span is offered the outer scroll's whole
viewport and placed 24px above it, the height of the sized child the outer
scroll snaps to the end of; redrawing only its text left it at the offer and
24px too low. `tests/cases/unsettled.rs` had that five-widget tree ignored as
a known defect and now runs it.

`px_region` names the walk both comparisons and `window_region` were writing
out.

Checked: fmt, clippy, 80 tests, the release oracle at 100 seeds, all fifteen
shrinker cases at 400 seeds of depth 5, and `tabs`, `view`, `minimal`,
`text`, `random` plus the tab replay byte-identical at 1920x1200 against
`98d4e98`. The `many` fixture's twenty-five counters are unchanged.

Fixed with it, from the handoff's unreduced leads: shrinker seeds 174 and 175
on `repaint-some` and seed 2 on `region-node`, and oracle seeds 18 and 190 at
depth 6. Still failing: shrinker seed 288 on `region-node`, and oracle seed
326 at depth 6, which reduces to 43 widgets around two `Branch`es and is not
this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:25:26 -04:00
iris-aiandClaude Opus 5 98d4e98a29 Describe a tree before building it, so a failing seed can be reduced
The oracle grew its trees from a seed and the shrinker grew its own, with
every scenario written out on each side. So a failure the oracle found could
not be handed to the shrinker: there was no tree to pass it, only a seed, and
a seed cannot be made smaller. The shrinker could only grow its own trees and
hope to meet the same shape, which it does not -- 20,000 of its trees never
reproduced what the oracle's seed 18 shows at depth 6.

`iris::random` now answers with a `Plan`: `plan(seed, depth, &edits)` draws one
out of the random stream and `build(rsc, &plan)` makes the widgets, where
`grow` did both at once. Every draw happens in the order it always has, so a
seed still means the tree it meant -- checked by running the oracle at 1000
seeds of depth 6 before and after and getting the same three failures with the
same boxes. `Plan::smaller` reduces one, `Plan::edited` applies an `Edits` to a
tree that already exists, and `tests/scenario/` holds the fifteen cases both
rigs now run over the same trees.

A span keeps the order it holds its children in apart from the children
themselves, so detaching, attaching and reordering leave the widgets made in
the same order and two builds still line up index for index. `Tree::detached`
is gone: `Spanned::spares` is everything made for a span that it does not
hold, which is what both of those were.

`tests/cases/plan.rs` pins the three properties the rest rests on: editing a
plan is growing one with those edits, every simplification is smaller than
what it came from, and reducing ends. The second caught this change's own
defect, where dropping a side of a `Branch` duplicated another and grew the
tree by four widgets.

What it found, on its first run: `SHRINK_SEED=18 SHRINK_DEPTH=6
SHRINK_CASE=repaint-some` reduces 277 widgets to 5. A scroll inside a scroll,
the inner one owning a movable region, and only the text at the bottom marked
for redraw -- and the span lands 24px out, which is exactly the sized child's
height. `git bisect` names `95fb4f9`, where `Masked` began reporting its box
rather than its inner's size, so what the outer scroll is told its content
measures now depends on whether the inner subtree was redrawn this frame.
`tests/cases/unsettled.rs` has it written out, ignored until it is fixed.

Checked: fmt, clippy over all targets with -D warnings, the workspace tests
(79 + 11 + 15, one ignored for the defect above), and the 100-seed oracle over
all fifteen cases at depth 4. The shrinker at 400 seeds of depth 5 now fails,
which it did not before running the oracle's trees and cases: seeds 2 and 288
on region-node and 174 and 175 on repaint-some are unreduced leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:58:31 -04:00
iris-aiandClaude Fable 5.1 4febabfd2e Wrap rather than saturate: nothing draws two million pixels out
A saturating add is five instructions where a wrapping one is one, and
it has no i32 vector form. Measured on the fixed-shape fixture, seed 1,
depth 8, 500 frames of `many`: 2,098M instructions and ~826M cycles down
to 1,918M and ~771M, with `random`, `tabs` and `text` byte-identical at
1920x1200 and the 100-seed oracle passing.

What saturating bought was ordering past the end of the range, where a
layout is already a defect; wrapping makes that defect obvious instead
of plausible. `from_f32` still clamps, since a float has the range to
come from anywhere, and `narrow` stays for `Holds`, whose range past
i32 really does mean unbounded. MIN and MAX remain unbounded ends only
where they are compared and never added to, which is every use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:22:48 -04:00
iris-aiandClaude Opus 5 394d5149a5 Measure a cost on a tree that does not move when layout does
`Branch` picks which of two subtrees to draw by comparing a measured pixel
length with a threshold. That is exactly what the oracle wants -- it is how a
widget believing a measurement a cold start would not have given it becomes a
different tree -- and exactly what a rig measuring cost must not have: the
fixture's shape moves with the thing being measured.

It has been moving. Seed 1 at depth 8 draws 88 widgets and writes 2,298
primitives a frame at `5ed9e87`, and 115 and 8,209 at `bd6de71` -- three and
a half times the work -- so the handoff's "fixed point cost 3x" compared two
different workloads and is withdrawn. Measured on one tree instead, with
`Edits::fixed_branches`, `5ed9e87` is 1,761M instructions and ~699M cycles
against this head's 2,093M and ~819M, while drawing 100 widgets against 97
and writing 4,272 primitives against 3,951. Fixed point costs something like
a fifth to a quarter, not three times.

The oracle keeps measured branches: `fixed_branches` is false by default and
only the rig sets it. A branch consumes its randomness either way, so both
grow the same ids.

**Check the work counters before comparing two commits' times.** The rig
prints drawn widgets, widget draws and primitive writes for this reason;
an undrawn `leftover` child still moves them, which no flag can remove.

Checked: fmt, clippy, 105 tests, the 100-seed generated oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:06:44 -04:00
iris-aiandClaude Opus 5 4cbb242a5d Do not multiply by a part of nothing
`lerp` is `a + (b - a) * f`, and `b - a` is nothing often enough to be worth
asking: a box with the same pixels at both ends of an axis, a span with no
fraction of one, a part of a subtree whose box did not move on that axis.
`Fixed::scaled` is `mul` that answers a zero receiver without widening to
`i64`, rounding and narrowing back, and `lerp` uses it -- so every lerp in
layout gets it rather than the two places that were about to grow their own
comparison.

`many` over 500 frames: 1,705,786,553 instructions to 1,657,571,216, and
638.9M cycles against 657.9M, averaged over four runs each.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, and
`tabs`, `text`, `random`, `minimal` and `view` byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:01:52 -04:00
iris-aiandClaude Opus 5 d75a1e2129 Do not multiply a box through the whole of its parent
Composing a box within another is four multiplies an axis, and two of the
shapes it is asked for compose to nothing: a part that is the whole box is
the box, and a box composed through the whole of its parent is itself. Both
are exact -- multiplying by one on the grid rounds to what it started as --
so four comparisons answer what four multiplies would have.

`many` over 500 frames: 1,742,553,104 instructions to 1,705,786,553, 2.1%
fewer, and 660M cycles to 658M. The cycles are the honest number and they
say this is worth little here; it is kept because instructions are what a
phone pays for and the check is four comparisons.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, the
100-seed generated oracle, and `tabs`, `text` and `random` byte-identical at
1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:55:30 -04:00
iris-aiandClaude Opus 5 1940e85c70 Move a whole box at once, since that is what a move does
Profiling a move by cycles rather than by instructions says the cost is not
where the last session recorded it. In `apply_scalar` the `i64` division is
**0.00%** of cycles and the multiply 1.5%: the time is in `saturating_add`,
which is five instructions and no vector form for an `i32`, and a box that
only moved does eight of them. Asking for them one scalar at a time, each
behind a match on which kind of move this is, gives the compiler four short
sequences where it had four adds in a row to pair up.

So a translation is now asked for once for the whole region -- which is what
a translation is -- and the match happens once above it rather than per
scalar. `many` over 500 frames: 684M cycles to 660M, and 1,815,666,327
instructions to 1,742,553,104.

Cycle counts are worth trusting here, which is the other thing to keep: three
runs of one binary varied 0.23%. It is wall time that varies 2x on this
machine, not the counters, and instructions alone cannot see a stall.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, and
`tabs`, `text` and `random` byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:50:58 -04:00
iris-aiandClaude Opus 5 cb1bba4682 Work a move out once for the subtree, not once for each part
`RegionRemap` re-derived the same things for every scalar of every part of a
moving subtree: the extent it divides by, whether the box only moved, whether
it spans the whole of its parent's, and the two ends of each `lerp`. All of
them are the same for the whole walk, because the walk is one box moving into
one other box. They are worked out once in `RegionRemap::new` now, as an
`AxisRemap` per axis that is either a translation or a scale.

Identical arithmetic in the same order, so the answers are unchanged: 500
frames of the `many` phase went from 1,886,328,855 instructions to
1,815,666,327, 3.8% fewer, and `tabs`, `text` and `random` are byte-identical
at 1920x1200.

Cycles moved 0.8%, which is the finding worth keeping: the surrounding
arithmetic was never the cost. The `i64` division is, and it is still there.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:46:23 -04:00
31 changed files with 2767 additions and 1779 deletions

No files matched your search

+98 -36
View File
@@ -10,23 +10,30 @@ use std::{
/// chain, and the same box summed from what its children asked for -- and has
/// to decide whether the two are the same place. In floats they land a few
/// bits apart, which is a defect wherever the answer changes what is drawn
/// rather than where. Here adding and subtracting are exact and only a
/// multiply or a conversion rounds, back onto the same steps, so two routes
/// that come within half a step land on one number and everything downstream
/// compares for equality instead of for nearness.
/// rather than where. Here adding and subtracting are exact, a multiply
/// drops to the step below, and a conversion between grids takes the nearest
/// one, so two routes to one place land on one number and everything
/// downstream compares for equality instead of for nearness.
///
/// `SHIFT` is the number of fractional bits, which is what makes the steps
/// divide a whole number: a power of two also converts to `f32` without
/// rounding while the value fits in its mantissa.
///
/// Arithmetic wraps at the ends of the range, the way the `i32` underneath
/// does. Saturating instead was measured at a twelfth of layout's
/// instructions -- five per add against one -- to keep the ordering of
/// coordinates two million pixels out, where nothing draws anyway. A value
/// off the end is a defect either way; wrapping makes it an obvious one.
/// Only [`Self::from_f32`] clamps, since a float has further to come from.
#[repr(transparent)]
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, bytemuck::Pod, bytemuck::Zeroable,
)]
pub struct Fixed<const SHIFT: u32>(i32);
/// A length or a coordinate in pixels, to a sixty-fourth. Finer than anything
/// a display can show, and exact in `f32` up to 262,144 px, which is what lets
/// the same number reach the GPU.
/// A length or a coordinate in pixels, in steps of `1/1024`. Finer than
/// anything a display can show, and exact in `f32` up to 16,384 px, which is
/// what lets the same number reach the GPU.
pub type Px = Fixed<PX_SHIFT>;
/// How many bits of a pixel a [`Px`] keeps. One place, because [`PxVec2`]
@@ -56,8 +63,8 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
/// The gap between neighbouring values, which is also how far apart two
/// numbers can be and still mean the same place.
pub const STEP: Self = Self(1);
/// Also what stands in for an unbounded end, since arithmetic saturates
/// here rather than wrapping past it.
/// Also what stands in for an unbounded end: compared against, never
/// added to, since arithmetic wraps past it.
pub const MIN: Self = Self(i32::MIN);
pub const MAX: Self = Self(i32::MAX);
@@ -77,12 +84,13 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
}
pub const fn from_int(v: i32) -> Self {
Self(v.saturating_mul(Self::one().0))
Self(v.wrapping_mul(Self::one().0))
}
/// Rounds to the nearest step, and saturates rather than wrapping. A NaN
/// has no nearest step and becomes zero, which is a caller's mistake
/// rather than a value worth carrying.
/// Rounds to the nearest step, and clamps to the ends of the grid rather
/// than wrapping: this is where a number from outside arrives, and a float
/// has the range to be anywhere. A NaN has no nearest step and becomes
/// zero, which is a caller's mistake rather than a value worth carrying.
///
/// Half-away is written out rather than called through `f32::round`,
/// which is not `const`: a layout constant has to stay a constant.
@@ -101,6 +109,18 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
})
}
/// The first step at or above `v`, where [`Self::from_f32`] takes the
/// nearest one and is below it half the time. For a bound that has to
/// admit the value it came from: a measurement rounded down is a bound
/// that leaves out the thing it was measured from.
pub const fn ceil_from_f32(v: f32) -> Self {
let nearest = Self::from_f32(v);
match nearest.to_f32() < v {
true => nearest.next_up(),
false => nearest,
}
}
/// From a number as it is written in source -- `16`, `1.5` -- which is
/// the other place a value enters the grid.
pub fn from_num(v: impl UiNum) -> Self {
@@ -114,33 +134,41 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
/// The same value on another grid, rounded where the new one is coarser.
pub const fn to_scale<const TO: u32>(self) -> Fixed<TO> {
Fixed(match TO >= SHIFT {
true => narrow((self.0 as i64) << (TO - SHIFT)),
false => narrow(shift_round(self.0 as i64, SHIFT - TO)),
true => self.0 << (TO - SHIFT),
false => shift_round(self.0 as i64, SHIFT - TO) as i32,
})
}
pub const fn add(self, rhs: Self) -> Self {
Self(self.0.saturating_add(rhs.0))
Self(self.0.wrapping_add(rhs.0))
}
pub const fn sub(self, rhs: Self) -> Self {
Self(self.0.saturating_sub(rhs.0))
Self(self.0.wrapping_sub(rhs.0))
}
pub const fn neg(self) -> Self {
Self(self.0.saturating_neg())
Self(self.0.wrapping_neg())
}
/// Scaled by a number on any grid, which is how a length takes a fraction
/// of itself and keeps being a length: the product is measured in the
/// receiver's steps.
///
/// Dropped to the step below rather than taken to the nearest one
/// (Bryan, 2026-09-16), which costs a share a thousandth of a pixel of
/// its row -- less than an even number of pixels draws. Toward negative
/// infinity on both sides of zero, since that is a shift and nothing
/// else: a value and its negation therefore land different distances
/// from where they came, so a flipped span can sit a step from its
/// mirror image.
pub const fn mul<const BY: u32>(self, by: Fixed<BY>) -> Self {
Self(narrow(shift_round(self.0 as i64 * by.0 as i64, BY)))
Self(((self.0 as i64 * by.0 as i64) >> BY) as i32)
}
/// Repeated a whole number of times, which no grid rounds.
pub const fn mul_int(self, by: i32) -> Self {
Self(narrow(self.0 as i64 * by as i64))
Self(self.0.wrapping_mul(by))
}
/// Divided into a whole number of parts, rounded to the nearest step.
@@ -149,12 +177,13 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
if by == 0 {
return Self::ZERO;
}
Self(narrow(div_round(self.0 as i64, by as i64)))
Self(div_round(self.0 as i64, by as i64) as i32)
}
/// Divided by a number on any grid. A zero divisor is a caller bug -- a
/// box of no length has no fraction of itself -- and saturates so that a
/// release build lays out something absurd rather than dying.
/// box of no length has no fraction of itself -- and answers with the end
/// of the range so that a release build lays out something absurd rather
/// than dying.
pub const fn div<const BY: u32>(self, by: Fixed<BY>) -> Self {
debug_assert!(by.0 != 0, "dividing by a length of zero");
if by.0 == 0 {
@@ -163,7 +192,7 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
false => Self::MAX,
};
}
Self(narrow(div_round((self.0 as i64) << BY, by.0 as i64)))
Self(div_round((self.0 as i64) << BY, by.0 as i64) as i32)
}
/// `num / den` on *this* grid rather than on theirs, for weights coarser
@@ -173,7 +202,7 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
if den.0 == 0 {
return Self::ZERO;
}
Self(narrow(div_round((num.0 as i64) << SHIFT, den.0 as i64)))
Self(div_round((num.0 as i64) << SHIFT, den.0 as i64) as i32)
}
/// `from` and `to` a fraction of the way apart, the fraction being the
@@ -197,7 +226,7 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
}
pub const fn abs(self) -> Self {
Self(self.0.saturating_abs())
Self(self.0.wrapping_abs())
}
pub const fn clamp(self, lo: Self, hi: Self) -> Self {
@@ -209,11 +238,11 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
/// boundary. The step is the whole gap, so there is nothing to exclude
/// between this and the boundary itself.
pub const fn next_up(self) -> Self {
Self(self.0.saturating_add(1))
Self(self.0.wrapping_add(1))
}
pub const fn next_down(self) -> Self {
Self(self.0.saturating_sub(1))
Self(self.0.wrapping_sub(1))
}
}
@@ -250,6 +279,8 @@ pub(crate) const fn div_toward(num: i64, den: i64, up: bool) -> i64 {
}
}
/// Clamped to the ends, unlike a [`Fixed`]'s own arithmetic: a range of box
/// lengths that runs past `i32` really is unbounded.
pub(crate) const fn narrow(v: i64) -> i32 {
if v > i32::MAX as i64 {
return i32::MAX;
@@ -353,6 +384,12 @@ impl<const SHIFT: u32> FixedVec2<SHIFT> {
Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y))
}
/// The first step at or above each part, for a measurement reported as a
/// box: what it occupies is not less than what was measured.
pub fn ceil_from_f32(v: Vec2) -> Self {
Self::new(Fixed::ceil_from_f32(v.x), Fixed::ceil_from_f32(v.y))
}
pub fn to_f32(self) -> Vec2 {
Vec2::new(self.x.to_f32(), self.y.to_f32())
}
@@ -444,29 +481,54 @@ mod tests {
assert_eq!(Px::from_int(100) * Rel::ZERO, Px::ZERO);
}
/// Toward negative infinity on both sides of zero, which is what makes
/// it a shift rather than a shift and a sign branch -- and what makes a
/// value and its negation land different distances from where they came,
/// so a flipped span can sit a step from its mirror image.
#[test]
fn halves_round_away_from_zero_either_side() {
fn a_multiply_drops_to_the_step_below_on_both_sides_of_zero() {
// A step and a half of one, which has no step of its own.
let step_and_a_half = Rel::from_f32(1.5).div_int(Px::ONE.raw());
assert_eq!(Px::ONE * step_and_a_half, Px::from_raw(2));
assert_eq!(Px::ONE * step_and_a_half, Px::from_raw(1));
assert_eq!(Px::ONE.neg() * step_and_a_half, Px::from_raw(-2));
}
/// A division rounds to the nearest step, so it cannot put back the
/// steps a truncating multiply dropped: a round trip comes back short,
/// never long, and by the few steps the two operations gave up.
#[test]
fn dividing_by_a_fraction_undoes_multiplying_by_it() {
fn dividing_by_a_fraction_cannot_undo_a_truncating_multiply() {
let third = Rel::ONE / Rel::from_int(3);
let len = Px::from_int(300);
assert_eq!(len * third / third, len);
let back = len * third / third;
assert!(back <= len, "{back:?} is longer than {len:?}");
assert!(len - back <= Px::from_raw(3), "{back:?} against {len:?}");
assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200));
}
/// The bound a greedy line break needs: the width it was measured at is
/// not on the grid, and the narrowest box the break still holds for is
/// the step at or above it, never the one below.
#[test]
fn arithmetic_saturates_rather_than_wrapping() {
assert_eq!(Px::MAX + Px::ONE, Px::MAX);
assert_eq!(Px::MIN - Px::ONE, Px::MIN);
fn a_ceiling_never_lands_below_the_number_it_came_from() {
let step = 1.0 / (1 << PX_SHIFT) as f32;
for n in 0..64 {
let v = 189.0 + n as f32 * step / 3.0;
let up = Px::ceil_from_f32(v);
assert!(up.to_f32() >= v, "{up:?} is below {v}");
assert!(
up.to_f32() - v < step,
"{up:?} is more than a step above {v}"
);
}
// An exact step is its own ceiling.
assert_eq!(Px::ceil_from_f32(189.5), Px::from_f32(189.5));
}
#[test]
fn a_number_from_outside_is_clamped_to_the_grid() {
assert_eq!(Px::from_f32(1e12), Px::MAX);
assert_eq!(Px::from_f32(-1e12), Px::MIN);
assert_eq!(Px::from_int(i32::MAX), Px::MAX);
}
#[test]
+1 -2
View File
@@ -84,8 +84,7 @@ pub struct RegionAlign {
}
impl RegionAlign {
/// Both axes at the near edge. What a container passes as an override for
/// a child it is going to position itself.
/// Both axes at the near edge: the start of a box in its own orientation.
pub const NEAR: Self = Self {
x: AxisAlign::NEG,
y: AxisAlign::NEG,
+19
View File
@@ -125,6 +125,13 @@ impl Size {
Axis::Y => self.y,
}
}
pub fn axis_mut(&mut self, axis: Axis) -> &mut LayoutLen {
match axis {
Axis::X => &mut self.x,
Axis::Y => &mut self.y,
}
}
}
impl LayoutLen {
@@ -151,6 +158,18 @@ impl LayoutLen {
Len::from_parts(self.rel.add(share), self.px)
}
/// This length, given as a part of a box `len` long, as a part of the
/// box `len` is itself a part of. The share is untouched: it is a claim
/// on whoever divides the room, not a fraction of anything.
pub const fn within_len(self, len: Len) -> Self {
let part = Len::from_parts(self.rel, self.px).within_len(len);
Self {
px: part.px,
rel: part.rel,
leftover: self.leftover,
}
}
pub fn px(px: impl UiNum) -> Self {
Self {
px: Px::from_num(px),
+26 -1
View File
@@ -219,7 +219,7 @@ impl Len {
}
}
pub fn within_len(&self, len: Len) -> Self {
pub const fn within_len(&self, len: Len) -> Self {
self.within(&UiSpan {
start: Len::ZERO,
end: len,
@@ -282,6 +282,11 @@ impl UiSpan {
self.end += offset;
}
/// Composing a box through the one it sits in, and the hottest line in
/// layout. It used to skip the multiplies where a span was the whole of
/// its parent or the parent the whole of its own; both come out of the
/// multiply unchanged anyway, and the body those comparisons cost was
/// what kept the inliner from taking this at all.
pub const fn within(&self, parent: &Self) -> Self {
Self {
start: self.start.within(parent),
@@ -292,6 +297,15 @@ impl UiSpan {
pub const fn len(&self) -> Len {
self.end - self.start
}
/// Both ends by the same amount, which is what moving a box without
/// changing its length does to every part of it.
pub const fn translated(self, by: Len) -> Self {
Self {
start: self.start + by,
end: self.end + by,
}
}
}
#[repr(C)]
@@ -302,6 +316,17 @@ pub struct UiRegion {
}
impl UiRegion {
/// Every part of the box by the same amount on each axis. Done to the
/// whole region rather than an end at a time, because that is what it is
/// -- and because four adds in a row are four adds, where four asked for
/// separately are four sequences.
pub const fn translated(self, x: Len, y: Len) -> Self {
Self {
x: self.x.translated(x),
y: self.y.translated(y),
}
}
pub const FULL: Self = Self {
x: UiSpan::FULL,
y: UiSpan::FULL,
+9 -12
View File
@@ -107,13 +107,6 @@ impl Default for TextAttrs {
}
}
/// How far below the longest line a width may fall and still be answered by
/// the break in hand. A parent that offers a child the length it reported
/// composes that length back through the box chain, so the two differ in the
/// last bits -- and at exactly the longest line, that decides whether a line
/// fits. Sub-pixel, so no break it admits is one a reader could see.
const BREAK_EPSILON_PX: f32 = 0.05;
/// Keeps text and its corresponding layout from getting out of sync.
pub struct TextBuffer {
text: String,
@@ -200,15 +193,19 @@ impl TextBuffer {
// A greedy break at one width is the same break at every width down
// to the longest line it produced: each line still fits, and none can
// take a word that would not fit in the wider box. So the layout in
// hand already answers, and re-breaking would only be a chance to
// disagree with itself -- which is what happens when a parent offers
// a child the length that child just reported, and the two land
// either side of a float.
// hand already answers, and re-breaking would only be work.
//
// At the longest line exactly, with no margin below it. A narrower
// width really does break differently, so answering one from the
// break in hand is how a warm tree keeps lines a cold tree would
// never produce. The margin was here because a text reports the
// width it used and a parent hands that back; the report is the step
// at or above its longest line now, so what comes back fits.
if let Some(key) = &self.layout_key
&& key.attrs == *attrs
&& let (Some(broke_at), Some(want)) = (key.max_width, width)
&& want <= broke_at
&& want + BREAK_EPSILON_PX >= self.layout.width()
&& want >= self.layout.width()
{
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);
+8 -4
View File
@@ -35,6 +35,10 @@ struct MoveOffset {
// belongs to the pixel above it. Flooring the product instead drops a pixel
// wherever a fraction divides a window exactly: a fifth of 1920 comes out of
// `REL_STEP` as 383.99998, and five tabs each lose their last column.
//
// Taken over the whole coordinate, fraction and pixels summed, since a floor
// does not distribute over a sum: floored apart, a half of one and a half of
// the other lose the pixel the two together make.
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + PX_STEP * 0.5);
}
@@ -147,8 +151,8 @@ fn vs_main(
let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel);
let bot_right_px = vec2(r.x.end.px, r.y.end.px);
let top_left = snap_floor(top_left_rel * window.dim) + snap_floor(top_left_px);
let bot_right = snap_floor(bot_right_rel * window.dim) + snap_floor(bot_right_px);
let top_left = snap_floor(top_left_rel * window.dim + top_left_px);
let bot_right = snap_floor(bot_right_rel * window.dim + bot_right_px);
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -179,8 +183,8 @@ fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
let br = vec2(m.x.end.rel, m.y.end.rel);
let br_px = vec2(m.x.end.px, m.y.end.px);
let top_left = snap_floor(tl * window.dim) + snap_floor(tl_px);
let bot_right = snap_floor(br * window.dim) + snap_floor(br_px);
let top_left = snap_floor(tl * window.dim + tl_px);
let bot_right = snap_floor(br * window.dim + br_px);
let pos = in.clip_position.xy;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
return color * 0.0;
+27 -14
View File
@@ -1,6 +1,6 @@
use crate::{
Holds, LayerId, LayoutLen, MaskIdx, MoveIdx, PrimitiveHandle, RegionAlign, Size, TextureHandle,
UiRegion, WidgetId,
UiRegion, UiVec2, WidgetId,
};
/// What is kept of a widget its parent has asked about. `drawn` says whether
@@ -11,11 +11,20 @@ pub struct ActiveData {
pub id: WidgetId,
/// The box its drawing is in, in `parent_move`'s coordinates.
pub region: UiRegion,
/// The box its parent first asked about it in, as a part of the box the
/// parent was itself asked in. Any later box it was given was decided
/// knowing its answer, so this is where a question about it is asked
/// again -- and it is kept relative so that it follows the parent's.
pub offer: UiRegion,
/// The box its parent gave it, in the same coordinates: what it was
/// asked about, before its own answer placed its drawing inside it.
/// `region` is that placement, and a local redraw asks here.
pub given: UiRegion,
/// The same box as lengths of its parent's box, which is the one route
/// to a box in pixels: a draw threads these down a level at a time, and
/// [`crate::UiRenderState::redraw`] takes the same steps back up.
pub given_len: UiVec2,
/// The lengths of the box its parent first asked about it in, as
/// lengths of the box the parent was itself offered. Any later box it
/// was given was decided knowing its answer, so this is the question
/// asked again -- and a chain of fractions has no frame in it, which is
/// why a region node between two widgets cannot break it.
pub offer_len: UiVec2,
/// What it answered there: the size and what that held for.
pub answer: (Size, [Holds; 2]),
/// What the widget said it used of its box, the last time it drew.
@@ -41,18 +50,22 @@ pub struct ActiveData {
/// A change to one moves a box this widget cannot fix by drawing again,
/// and comparing them is what says so.
pub declared: [Option<LayoutLen>; 2],
/// The alignment its parent asked it with. A local redraw repeats that
/// question, including an override chosen by a container.
pub align: RegionAlign,
/// Whether that alignment was the parent's override rather than the
/// widget's own property.
pub align_override: bool,
/// Its own alignment when it was last drawn. A change to the property is
/// found against this even when its parent overrode the alignment.
/// The axes along which its parent chose its box from its own answer,
/// so a local redraw asks the question its parent asked.
pub decided: [bool; 2],
/// Its alignment when it was last drawn, which a change to the property
/// is found against.
pub own_align: RegionAlign,
/// The movable region whose coordinates `region` uses.
pub parent_move: MoveIdx,
/// The mask its drawing is clipped to: one it set itself, or the one it
/// inherited from whoever drew it.
pub mask: MaskIdx,
/// That inherited one. The two differ exactly where the widget set a
/// mask of its own, which is the one it owns and the one a move rewrites
/// -- and the one a redraw of it must not be handed back, since setting
/// a mask asserts there is none.
pub parent_mask: MaskIdx,
pub layer: LayerId,
}
+61 -22
View File
@@ -10,9 +10,9 @@ use std::ops::RangeInclusive;
///
/// The ends are lengths on the grid rather than floats with a tolerance
/// around them: a box offered back at the length a widget reported comes back
/// as the same number, so a range means what it says. What widening there is
/// belongs to [`Self::through`], which has a rounding to undo, and is derived
/// from that rounding rather than chosen.
/// as the same number, so a range means what it says. The one place a range
/// is wider than the length it came from is [`Self::through`], and what it is
/// wider by is the floor that inverting a fraction undoes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Holds {
pub lo: Px,
@@ -41,32 +41,29 @@ impl Holds {
}
/// What a box has to be for a part of it, `len` of the box long, to stay
/// in this range. A part with no relative extent is a fixed length: it
/// was drawn at that length and any box keeps it there.
/// in this range: the exact preimage of `px + floor(rel * box)`, which is
/// the one way a box in pixels is reached. A part with no relative extent
/// is a fixed length -- it was drawn at that length and any box keeps it
/// there.
///
/// The way in is `px + rel * box` taken to the nearest step, so a part
/// of exactly `lo` came from anything within half a step of it and the
/// answer is an interval even where this range is one length. Inverting
/// the length alone instead gives a point that need not even contain the
/// box the part was drawn in, which is a range excluding the drawing it
/// was made for.
/// The answer is an interval even where this range is a single length,
/// because the multiply on the way in drops to the step below and many
/// boxes therefore give one length. That is a floor rather than an
/// allowance: inverting it is two divisions and nothing else, and the
/// whole of a box maps back to itself.
pub const fn through(self, len: Len) -> Self {
let rel = len.rel.raw() as i64;
if rel == 0 {
return Self::ANY;
}
// Three half steps either side -- one for the rounding on the way
// in, two for the difference between a length composed down the
// chain and the same length measured against the window -- and half
// of what a `Rel` counts in, to divide by the fraction. Exact until
// the division takes it back to the grid.
let px = len.px.raw() as i64;
let half_rel = REL_SHIFT - 1;
let lo = ((self.lo.raw() as i64 - px) * 2 - 3) << half_rel;
let hi = ((self.hi.raw() as i64 - px) * 2 + 3) << half_rel;
// Dividing by a negative turns the ends around, so which end each
// bound comes from is decided before dividing rather than by taking
// the min and max of four divisions.
// `floor(rel * box) >= lo - px` is `rel * box >= (lo - px) << REL`, and
// `floor(rel * box) <= hi - px` is `rel * box < (hi - px + 1) << REL`.
let lo = (self.lo.raw() as i64 - px) << REL_SHIFT;
let hi = (((self.hi.raw() as i64 - px) + 1) << REL_SHIFT) - 1;
// Dividing by a negative fraction turns the ends around, so which
// bound each comes from is decided before dividing rather than by
// taking the min and max of four divisions.
match rel > 0 {
true => Self::raws(div_toward(lo, rel, true), div_toward(hi, rel, false)),
false => Self::raws(div_toward(hi, rel, true), div_toward(lo, rel, false)),
@@ -116,6 +113,48 @@ mod tests {
}
}
/// A widget handed the whole of its parent's box, with or without pixels
/// taken off it, has no fraction to invert: multiplying by one is exact
/// and taking the pixels off again is too, so the box maps back to
/// itself. Allowing for anything here compounded a step a level down a
/// chain of widgets each taking the whole of its parent.
#[test]
fn the_whole_of_a_box_maps_back_to_itself() {
let at = Px::from_int(956);
assert_eq!(Holds::at(at).through(Len::FULL), Holds::at(at));
let less_eight = Len::from_parts(Rel::ONE, Px::from_int(-8));
assert_eq!(
Holds::at(at).through(less_eight),
Holds::at(at + Px::from_int(8))
);
}
/// The range is the exact preimage at both ends, so a box one step
/// outside it really does give a length outside this range. What a wider
/// range costs is a drawing reused where it does not hold.
#[test]
fn a_box_one_step_outside_the_range_is_outside_it() {
let part = Len::from_parts(Rel::from_f32(1.0 / 3.0), Px::from_int(-146));
let at = Px::from_int(300);
let holds = Holds::at(at).through(part);
for inside in [holds.lo, holds.hi] {
assert_eq!(part.to_px(inside), at, "{inside:?} left out of {holds:?}");
}
for outside in [holds.lo.next_down(), holds.hi.next_up()] {
assert_ne!(part.to_px(outside), at, "{outside:?} admitted by {holds:?}");
}
}
/// A truncating multiply only ever drops, so the step it needs allowing
/// for on the way in belongs at the top of the range and not the bottom.
#[test]
fn a_fraction_widens_further_up_than_down() {
let half = Len::from_parts(Rel::from_f32(0.5), Px::ZERO);
let holds = Holds::at(Px::from_int(100)).through(half);
let box_len = Px::from_int(200);
assert!(holds.hi - box_len > box_len - holds.lo, "{holds:?}");
}
#[test]
fn a_boundary_the_next_step_along_does_not_admit_it() {
let boundary = Px::from_int(10);
+12 -6
View File
@@ -68,17 +68,24 @@ impl Moves {
}
}
/// Composes a region held in `idx`'s coordinates down the chain, which is
/// the same walk the vertex shader does.
/// The same walk the vertex shader does, in the same `Len` the shader is
/// handed, for asking where a drawing will actually land -- hit testing,
/// and nothing layout decides on. Layout threads its lengths down the
/// draw instead, so no box it compares is composed back up this chain.
pub fn resolve(&self, idx: MoveIdx, local: UiRegion) -> UiRegion {
let mut region = local;
self.walk(idx, |entry| region = region.within(entry));
region
}
fn walk(&self, idx: MoveIdx, mut step: impl FnMut(&UiRegion)) {
let mut at = idx;
for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE {
return region;
return;
}
let entry = self.arena[at.idx()];
region = region.within(&entry.region);
let entry = &self.arena[at.idx()];
step(&entry.region);
at = entry.parent;
}
debug_assert!(
@@ -86,7 +93,6 @@ impl Moves {
"a move chain longer than {CHAIN_LIMIT} resolves to the wrong place, \
and the shader stops at the same depth"
);
region
}
/// How many slots a region in `idx` is composed through, which is what
+128 -71
View File
@@ -1,7 +1,7 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Holds, LayoutLen, Len, Px, PxVec2, RegionAlign, Rel, RenderedText, Size, StrongWidget,
Axis, Holds, LayoutLen, Len, Px, PxVec2, RegionAlign, RenderedText, Size, StrongWidget,
TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiVec2, Weight,
WidgetId, Widgets,
render::{
@@ -19,6 +19,11 @@ pub struct Painter<'a> {
/// This widget's box, in the coordinates of `move_idx`.
pub(super) region: UiRegion,
/// That box in pixels, which its children's are a length of: threaded
/// down from the box this widget was given rather than composed back up
/// the chain, so every length in layout is one multiply from its
/// parent's and [`Holds::through`] inverts exactly that.
pub(super) px: PxVec2,
pub(super) mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
@@ -26,10 +31,12 @@ pub struct Painter<'a> {
/// The children asked about so far, so the first box each was asked in
/// is the one recorded as its offer.
pub(super) offered: Vec<WidgetId>,
/// The box this widget was first asked about in, in pixels.
/// The lengths of the box this widget was first asked about in, in
/// pixels. Its children's offers are a fraction of it.
pub(super) offered_px: PxVec2,
/// Whether this draw is in that box, which makes the questions it asks
/// the ones a cold layout asks and their answers the ones to keep.
/// Whether this draw is in a box of those lengths, which makes the
/// questions it asks the ones a cold layout asks and their answers the
/// ones to keep.
pub(super) at_offer: bool,
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
@@ -132,34 +139,49 @@ impl<'a> Painter<'a> {
id: &'s StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'s, 'a, W> {
self.widget_at(id, region, None)
self.widget_at(id, region, region.size(), [false; 2])
}
/// Draws a widget with an alignment chosen by its container rather than
/// the widget's property. Containers use this when the box they hand down
/// already expresses the size they report around the child.
pub fn widget_aligned<'s, W: ?Sized>(
/// Draws a widget in `region`, saying what the answer means.
///
/// `reports_of` is what a fraction the child reports is a fraction of, as
/// lengths of this widget's own box. It is the box the child was given
/// wherever that box is the child's whole area -- a pad's inset, a stack
/// child, a scroll's content -- and a span passes its own extent along
/// the row instead: it offers each child the room left from its cursor,
/// because a text has to wrap at the width actually there, while
/// `rel(0.5)` still means half the span wherever the child sits in it.
///
/// A `decided` axis is one where this box was chosen from the widget's
/// own answer. On those the answer is not placed inside the box again: it
/// already is the box, and a fraction taken of it a second time would
/// shrink it twice. A container uses that where it hands back exactly
/// what a child asked for -- a span placing a child at the length it
/// reported, a scroll giving its content the content's own length.
pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
align: RegionAlign,
) -> DrawResult<'s, 'a, W> {
self.widget_at(id, region, Some(align))
}
fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
align_override: Option<RegionAlign>,
reports_of: UiVec2,
decided: [bool; 2],
) -> DrawResult<'s, 'a, W> {
let region_node = self.rsc.widgets().is_region_node(id.id());
let declared = self.declared_lens(id);
let align = align_override.unwrap_or_else(|| self.rsc.widgets().alignment(id.id()));
let align = self.rsc.widgets().alignment(id.id());
// A rule this box was already chosen from is not resolved into it a
// second time. The box is that rule's length already, so resolving
// it again takes the fraction twice -- a widget declaring half of a
// stack, in the stack its own answer made half a row, is a quarter
// of the row. Pixels survive it, being the same length wherever they
// are taken from, which is why only a share ever shrank.
let resolve = AXES.map(|axis| match decided[axis as usize] {
true => None,
false => declared[axis as usize],
});
// Composing `FULL` through a box is not quite the identity in f32,
// so a child with nothing declared keeps the box it would have had.
let local = match declared.iter().any(Option::is_some) {
true => declared_box(region, declared, align),
let local = match resolve.iter().any(Option::is_some) {
true => declared_box(region, resolve, align),
false => region,
};
let within = match local == UiRegion::FULL {
@@ -176,11 +198,20 @@ impl<'a> Painter<'a> {
self.children.push(id.id());
}
let first_ask = self.offer(id.id());
let offer = match first_ask {
true => local,
false => self.state.active.get(&id.id()).map_or(local, |a| a.offer),
let given_len = local.size();
let offer_len = match first_ask {
true => given_len,
false => self
.state
.active
.get(&id.id())
.map_or(given_len, |a| a.offer_len),
};
let answers_offer = self.at_offer && local == offer;
let px = given_len.to_px(self.px);
let offered_px = offer_len.to_px(self.offered_px);
// Whether this ask is the child's offer question, which is a question
// about lengths: the same lengths somewhere else is the same question.
let answers_offer = self.at_offer && px == offered_px;
// The answer and what it holds for, both about the box asked in. The
// child's record may say something else once its drawing has been
// placed: a drawing made again in its placed box holds for that box.
@@ -194,9 +225,11 @@ impl<'a> Painter<'a> {
parent_move: self.move_idx,
region_node,
mask: self.mask,
offer,
offered_px: self.px_within_offer(offer),
align: align_override,
given_len,
offer_len,
px,
offered_px,
decided,
},
None,
self.rsc,
@@ -212,7 +245,7 @@ impl<'a> Painter<'a> {
DrawResult {
child: id,
painter: self,
size,
size: in_parent_frame(size, reports_of, declared),
}
}
@@ -246,25 +279,26 @@ impl<'a> Painter<'a> {
/// A child's length in the box it is about to be offered, if it can be
/// had without drawing it: from its hint, or from a drawing it already
/// has that holds for that box.
/// has that holds for that box. `reports_of` is what a fraction in the
/// answer is a fraction of, as it is for [`Self::widget_at`].
pub fn known_len<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
axis: Axis,
region: UiRegion,
reports_of: UiVec2,
) -> Option<LayoutLen> {
let declared = self.declared_lens(child);
let align = self.rsc.widgets().alignment(child.id());
let local = declared_box(region, declared, align);
let within = local.within(&self.region);
let first_ask = self.offer(child.id());
if first_ask && let Some(active) = self.state.active.get_mut(&child.id()) {
active.offer = local;
active.offer_len = local.size();
}
if let Some(hint) = self.size_hint(child, axis) {
return Some(hint);
}
let px = self.state.px_of(self.move_idx, within);
let px = local.size().to_px(self.px);
let (size, holds) =
self.state
.retained_size(child.id(), px, self.move_idx, self.rsc.widgets())?;
@@ -278,7 +312,7 @@ impl<'a> Painter<'a> {
for (axis, under) in AXES.into_iter().zip(self.under.iter_mut()) {
*under = under.and(holds[axis as usize].through(local.axis(axis).len()));
}
Some(size.axis(axis))
Some(in_parent_frame(size, reports_of, declared).axis(axis))
}
/// Whether this is the first box a child is asked about in during a draw
@@ -292,15 +326,6 @@ impl<'a> Painter<'a> {
true
}
/// The pixel size of a part of the box this widget was asked in.
fn px_within_offer(&self, local: UiRegion) -> PxVec2 {
let size = local.size();
PxVec2::new(
size.x.to_px(self.offered_px.x),
size.y.to_px(self.offered_px.y),
)
}
fn depend_on<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
if !self.size_deps.contains(&child.id()) {
self.size_deps.push(child.id());
@@ -378,29 +403,21 @@ impl<'a> Painter<'a> {
.is_some()
}
/// The part of this widget's box that something of `size` takes, at the
/// near edge. A container that reports one child's size gives every child
/// this, so what it draws is inside what it says it occupies.
pub fn box_of(&self, size: Size) -> UiRegion {
placed_box(UiRegion::FULL, size, RegionAlign::NEAR, [None; 2])
}
/// This widget's box in pixels. Reading it makes the drawing one that
/// holds for this box only, until `holds` says how far it goes.
pub fn px_size(&mut self) -> PxVec2 {
let px = self.state.px_of(self.move_idx, self.region);
for (own, len) in self.own.iter_mut().zip([px.x, px.y]) {
for (own, len) in self.own.iter_mut().zip([self.px.x, self.px.y]) {
if *own == Holds::ANY {
*own = Holds::at(len);
}
}
px
self.px
}
/// One axis of this widget's box in pixels. Prefer this to
/// [`Self::px_size`] when the other axis cannot affect the drawing.
pub fn px_len(&mut self, axis: Axis) -> Px {
let len = self.state.px_of(self.move_idx, self.region).axis(axis);
let len = self.px.axis(axis);
let own = &mut self.own[axis as usize];
if *own == Holds::ANY {
*own = Holds::at(len);
@@ -415,7 +432,7 @@ impl<'a> Painter<'a> {
pub fn holds(&mut self, axis: Axis, holds: impl Into<Holds>) {
let holds = holds.into();
debug_assert!(
holds.contains(self.state.px_of(self.move_idx, self.region).axis(axis)),
holds.contains(self.px.axis(axis)),
"'{}' ({:?}) says its drawing holds for lengths that leave out its own box",
self.label(),
self.id
@@ -506,6 +523,24 @@ impl PrimitiveLike for &TextureHandle {
}
}
/// A child's answer as lengths of the parent's own box. A widget reports a
/// fraction, and `reports_of` is the length that fraction is of: the box the
/// child was given wherever that is the child's whole area, and the parent's
/// own extent wherever the box is a positional remainder, as a span's is
/// after an earlier child. Pixels come through untouched either way, being
/// that many pixels wherever they end up. A declared axis is already the
/// parent's: it resolved the rule in its own box, and the rule is what the
/// report says.
fn in_parent_frame(size: Size, reports_of: UiVec2, declared: [Option<LayoutLen>; 2]) -> Size {
let mut size = size;
for (axis, declared) in AXES.into_iter().zip(declared) {
if declared.is_none() {
*size.axis_mut(axis) = size.axis(axis).within_len(reports_of.axis(axis));
}
}
size
}
/// What a widget declares a length of its box to be. `leftover` is not one: a
/// share of what is left over is only a length to the widget dividing one,
/// so it passes up in the size instead.
@@ -526,31 +561,53 @@ pub(crate) fn declared_lens(widgets: &Widgets, id: WidgetId) -> [Option<LayoutLe
})
}
/// The box a drawing occupies: the size the widget reported, on the side of
/// the box it was asked in that its alignment says. An axis reported as a
/// share fills, because a share is a length only to whoever divides one, and
/// whoever did is the one that handed down this box. A declared axis is
/// left alone too: `declared_box` already placed it, in the parent's box,
/// and the rule's length is what the widget reports there.
/// Whether what a widget reported along an axis is the whole of the box it
/// is in rather than a part to be placed inside it. A share fills, because a
/// share is a length only to whoever divides one, and whoever did is the one
/// that handed down this box. A declared axis does too: `declared_box`
/// already placed it, in the parent's box, and the rule's length is what the
/// widget reports there. And an axis the parent decided from the answer is
/// the answer already.
pub(crate) fn fills(reported: LayoutLen, declared: Option<LayoutLen>, decided: bool) -> bool {
reported.leftover != Weight::ZERO || declared.is_some() || decided
}
/// What of the box it was given a widget's drawing occupies, as lengths of
/// that box: the size it reported wherever that is a part to be placed, and
/// the whole of the box wherever the answer fills it.
///
/// A reported fraction is a fraction of the box the widget drew in, where a
/// declared one is a fraction of the box its parent handed down -- a span
/// reporting `rel(1.0)` means all of what it was given, whatever that was a
/// fraction of. So this scales by the box rather than composing into it.
pub(crate) fn placed_box(
region: UiRegion,
/// fraction of. So this is a length of the box rather than a length composed
/// into it, and a box in pixels is this step from the given box's pixels.
pub(crate) fn placed_lens(
size: Size,
align: RegionAlign,
declared: [Option<LayoutLen>; 2],
) -> UiRegion {
let mut placed = region;
for (axis, declared) in AXES.into_iter().zip(declared) {
decided: [bool; 2],
) -> UiVec2 {
let mut lens = UiVec2::FULL_SIZE;
for (axis, (declared, decided)) in AXES.into_iter().zip(declared.into_iter().zip(decided)) {
let reported = size.axis(axis);
if reported.leftover != Weight::ZERO || declared.is_some() {
if !fills(reported, declared, decided) {
*lens.axis_mut(axis) = Len::from_parts(reported.rel, reported.px);
}
}
lens
}
/// Where that drawing sits: those lengths taken of the box the widget was
/// asked in, on the side of it that the widget's alignment says.
pub(crate) fn placed_box(region: UiRegion, lens: UiVec2, align: RegionAlign) -> UiRegion {
let mut placed = region;
for axis in AXES {
// The whole of the box is already where it sits, and the arithmetic
// below is the identity for it.
if lens.axis(axis) == Len::FULL {
continue;
}
let span = placed.axis_mut(axis);
let len = span.len().scale(reported.rel) + Len::from_parts(Rel::ZERO, reported.px);
let len = lens.axis(axis).within_len(span.len());
span.start += (span.len() - len).scale(align.axis(axis).rel());
span.end = span.start + len;
}
+335 -273
View File
@@ -1,9 +1,9 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::ui::painter::{declared_box, declared_lens, placed_box};
use crate::ui::painter::{declared_box, declared_lens, placed_box, placed_lens};
use crate::{
ActiveData, Axis, DrawLayers, Holds, IdLike, LayoutLen, Len, MaskIdx, MoveIdx, Moves, Painter,
PixelRegion, PxVec2, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiSpan, Weight,
PixelRegion, Px, PxVec2, Rel, Size, StrongWidget, UiRegion, UiRsc, UiSpan, UiVec2, Weight,
WidgetId, Widgets,
util::{HashMap, Vec2},
};
@@ -20,13 +20,20 @@ pub(super) struct DrawInfo {
pub parent_move: MoveIdx,
pub region_node: bool,
pub mask: MaskIdx,
/// The box it was first asked about in, as a part of its parent's, and
/// that box in pixels.
pub offer: UiRegion,
/// The box its parent gave it, as lengths of the parent's own box, and
/// the lengths of the box it was first asked about in the same form.
/// Both describe the box the *parent* stated, so the second, placing ask
/// carries them unchanged while its own region is the placement inside.
pub given_len: UiVec2,
pub offer_len: UiVec2,
/// This ask's box in pixels, and the offer's: one multiply from the
/// parent's own, which is where every pixel length in layout comes from.
pub px: PxVec2,
pub offered_px: PxVec2,
/// A container's answer for where the widget sits. `None` uses the
/// widget's own property.
pub align: Option<RegionAlign>,
/// The axes along which the parent chose this box from the widget's own
/// answer, so the answer is not placed inside it again. See
/// [`Painter::widget_at`].
pub decided: [bool; 2],
}
pub struct UiRenderState {
@@ -35,8 +42,6 @@ pub struct UiRenderState {
pub(super) output_size: PxVec2,
old_root: Option<WidgetId>,
/// The slot every chain bottoms out in, holding the output as a box.
root_move: MoveIdx,
/// Whether the output has changed since the last update. A frame is
/// owed for that whether or not anything has to be drawn again.
resized: bool,
@@ -50,6 +55,9 @@ pub struct UiRenderState {
/// Whether this frame contains a declared-length change, so any dirty
/// dependent replaces its answer too.
replace_answers: bool,
/// Widgets waiting for an ancestor to draw them, so the walk down the
/// depths does not pick one up again at its own depth.
deferred: crate::util::HashSet<WidgetId>,
pub moves: Moves,
}
@@ -63,50 +71,46 @@ impl UiRenderState {
slots: Default::default(),
answer_invalid: Default::default(),
replace_answers: false,
deferred: Default::default(),
moves: Default::default(),
root_move: MoveIdx::NONE,
resized: false,
}
}
/// The window as a box, so a chain bottoms out in one rather than in a
/// multiplication applied after it. Composing through a box held in
/// pixels leaves everything below it in pixels, which is why nothing
/// downstream has to know the output's size to resolve a position.
fn write_root(&mut self) {
let region = UiRegion::new(
UiSpan::new(Len::ZERO, Len::from_parts(Rel::ZERO, self.output_size.x)),
UiSpan::new(Len::ZERO, Len::from_parts(Rel::ZERO, self.output_size.y)),
);
match self.root_move == MoveIdx::NONE {
true => self.root_move = self.moves.push(MoveIdx::NONE, region),
false => self.moves.set(self.root_move, region),
}
}
/// The window, in whatever the platform measures it in, onto the grid
/// everything below it is decided on.
/// everything below it is decided on. No move entry holds it: a chain
/// bottoms out in `MoveIdx::NONE`, which is the window, and the window's
/// size is applied where a fraction becomes pixels -- here in `to_px`,
/// and in the shader by its uniform. A resize therefore rewrites no
/// retained entry at all.
pub fn resize(&mut self, size: impl Into<Vec2>) {
let size = PxVec2::from_f32(size.into());
if size == self.output_size {
return;
}
self.output_size = size;
self.write_root();
self.resized = true;
}
fn root_info(&self) -> DrawInfo {
/// The root is asked about in the output: the window is where a fraction
/// becomes pixels rather than a box of its own, so the root's box is the
/// first length threaded down. Its own rules narrow that box, and where
/// they do the narrowed box is also the offer -- nothing above it chose
/// anything else.
fn root_info(&self, region: UiRegion) -> DrawInfo {
let px = region.size().to_px(self.output_size);
DrawInfo {
layer: 0,
parent: None,
depth: 1,
parent_move: self.root_move,
parent_move: MoveIdx::NONE,
region_node: false,
mask: MaskIdx::NONE,
offer: UiRegion::FULL,
offered_px: self.output_size,
align: None,
given_len: region.size(),
offer_len: UiVec2::FULL_SIZE,
px,
offered_px: px,
decided: [false; 2],
}
}
@@ -146,8 +150,8 @@ impl UiRenderState {
// length, found the way every other box change is found. Before
// anything dirty settles, so that whatever a new output draws
// again is drawn once, in the box it will have.
let info = self.root_info();
let region = Self::root_region(root.id(), rsc.widgets());
let info = self.root_info(region);
let answer = self.draw_inner(root.id(), region, info, None, rsc);
self.active.get_mut(&root.id()).unwrap().answer = answer;
}
@@ -163,11 +167,9 @@ impl UiRenderState {
#[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::FullLayout);
self.clear(rsc);
// free all resources & cache
self.write_root();
if let Some(id) = root {
let info = self.root_info();
let region = Self::root_region(id.id(), rsc.widgets());
let info = self.root_info(region);
self.draw_inner(id.id(), region, info, None, rsc);
}
}
@@ -191,88 +193,87 @@ impl UiRenderState {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::DrawRequests);
diag::draw_request(
id,
info.parent,
region,
self.px_of(info.parent_move, region),
info.region_node,
);
diag::draw_request(id, info.parent, region, info.px, info.region_node);
}
let own_align = rsc.widgets().alignment(id);
let align = info.align.unwrap_or(own_align);
let replace_answer = self.answer_invalid.remove(&id)
|| (self.replace_answers
&& (rsc.widgets().needs_redraw.contains(&id)
|| self.dirty_size_under(id, rsc.widgets())));
let retained = match replace_answer {
let align = rsc.widgets().alignment(id);
// Nothing this widget has is an answer while something it measured
// is dirty: settling that changes what it would report, and a widget
// settled inside its parent's draw tells nobody -- the comparison
// that marks a reader is in `redraw`, which is not what asked here.
// Both retained routes are an answer, so the question is asked once
// rather than by each of them.
let stale =
rsc.widgets().needs_redraw.contains(&id) || self.dirty_size_under(id, rsc.widgets());
let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && stale);
let retained = match replace_answer || stale {
true => None,
false => self
.retained_answer(id, region, info, rsc.widgets())
.retained_answer(id, info)
.or_else(|| self.try_reuse(id, region, info, rsc)),
};
let answer = retained.unwrap_or_else(|| {
if old.is_none() {
old = self.remove(id, false, rsc);
}
self.draw_at(id, region, info, align, old.take(), rsc)
self.draw_at(id, region, info, old.take(), rsc)
});
let declared = declared_lens(rsc.widgets(), id);
// A near-edge override means the caller already chose this box from
// the child's answer. Applying the answer again would compound the
// placement; it is also how the second, final ask terminates.
let placed = match info.align == Some(RegionAlign::NEAR) {
true => region,
false => placed_box(region, answer.0, align, declared),
};
// The second, final ask is in a box chosen from the answer on both
// axes, which is also what makes it terminate.
let lens = placed_lens(answer.0, declared, info.decided);
let placed = placed_box(region, lens, align);
let placed_info = DrawInfo {
align: Some(RegionAlign::NEAR),
px: lens.to_px(info.px),
decided: [true; 2],
..info
};
// The symbolic box can be unchanged while its parent slot changed
// pixel size. Reuse checks the resolved box even in that case.
if self.try_reuse(id, placed, placed_info, rsc).is_none() {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PlaceRedraws);
let old = self.remove(id, false, rsc);
self.draw_at(id, placed, placed_info, RegionAlign::NEAR, old, rsc);
}
self.place(id, placed, placed_info, rsc);
// The answer is only reusable while both parts of the operation are:
// what the widget reported in the offered box, and what it drew in
// the box its report selected. Express the latter's contract back in
// terms of the offered box before handing it to the parent.
// what the widget reported in the box it was asked in, and what it
// drew in the box its report selected. Express the latter's contract
// back in terms of the box asked in before handing it to the parent.
let drawing_holds = self.active[&id].holds;
let mut settled = answer;
for axis in AXES {
let reported = answer.0.axis(axis);
let placed_len =
match reported.leftover != Weight::ZERO || declared[axis as usize].is_some() {
true => Len::FULL,
false => Len::from_parts(reported.rel, reported.px),
};
settled.1[axis as usize] =
settled.1[axis as usize].and(drawing_holds[axis as usize].through(placed_len));
settled.1[axis as usize].and(drawing_holds[axis as usize].through(lens.axis(axis)));
}
let active = self.active.get_mut(&id).unwrap();
active.offer = info.offer;
// Whoever asked owns how the box was reached: the box it stated, and
// what of that box the answer then took. A local redraw asks the
// same question again from these.
active.given = region;
active.given_len = info.given_len;
active.offer_len = info.offer_len;
active.answer = settled;
active.align = align;
active.align_override = info.align.is_some();
active.own_align = own_align;
active.decided = info.decided;
active.own_align = align;
active.depth = info.depth;
settled
}
/// Draws a widget in the final box its answer chose, reusing the drawing
/// already there where its retained contract holds for that box. The
/// symbolic box can be unchanged while the box it sits in changed pixel
/// length, so what reuse checks is the box in pixels.
fn place(&mut self, id: WidgetId, placed: UiRegion, info: DrawInfo, rsc: &mut dyn UiRsc) {
if self.try_reuse(id, placed, info, rsc).is_none() {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PlaceRedraws);
let old = self.remove(id, false, rsc);
self.draw_at(id, placed, info, old, rsc);
}
}
/// Calls a widget's `draw` and keeps what it drew in `region`.
fn draw_at(
&mut self,
id: WidgetId,
region: UiRegion,
info: DrawInfo,
align: RegionAlign,
old: Option<ActiveData>,
rsc: &mut dyn UiRsc,
) -> (Size, [Holds; 2]) {
@@ -293,12 +294,17 @@ impl UiRenderState {
None => (Vec::new(), None),
};
rsc.widgets_mut().needs_redraw.remove(&id);
let px = self.px_of(move_idx, local);
let at_offer = same_px(px, info.offered_px);
// A box of the offered lengths asks the offer's question wherever it
// sits, since what a drawing depends on is its lengths -- and
// equality is the comparison, these being counts of a step rather
// than floats to be compared for nearness.
let px = info.px;
let at_offer = px == info.offered_px;
let mut painter = Painter {
state: self,
region: local,
px,
mask: info.mask,
layer: info.layer,
own_layer: info.layer,
@@ -332,6 +338,7 @@ impl UiRenderState {
state: _,
rsc: _,
region: _,
px: _,
mask,
textures,
primitives,
@@ -401,9 +408,11 @@ impl UiRenderState {
parent_move: move_idx,
region_node: false,
mask,
offer: UiRegion::FULL,
given_len: UiVec2::FULL_SIZE,
offer_len: UiVec2::FULL_SIZE,
px,
offered_px: px,
align: None,
decided: [false; 2],
},
rsc,
);
@@ -414,7 +423,12 @@ impl UiRenderState {
let active = ActiveData {
id,
region,
offer: info.offer,
// The box a placing ask draws in is a part of the one its parent
// gave, which `draw_inner` writes back over these once the
// placement is done.
given: region,
given_len: info.given_len,
offer_len: info.offer_len,
// Whoever asked writes the answer, if this was the asking.
answer: old_answer.unwrap_or((size, holds)),
size,
@@ -427,12 +441,12 @@ impl UiRenderState {
children,
size_deps,
declared: declared_lens(rsc.widgets(), id),
align,
align_override: info.align.is_some(),
decided: info.decided,
own_align: rsc.widgets().alignment(id),
move_idx,
parent_move: info.parent_move,
mask,
parent_mask: info.mask,
layer: info.layer,
};
rsc.on_draw(&active);
@@ -460,14 +474,6 @@ impl UiRenderState {
}
}
/// The pixel size of a region held in `slot`'s coordinates.
pub(super) fn px_of(&self, slot: MoveIdx, region: UiRegion) -> PxVec2 {
self.moves
.resolve(slot, region)
.size()
.to_px(self.output_size)
}
/// A clean widget's retained answer, if that answer holds for a box of
/// `px`. This does not move its drawing, which may already be in the box
/// that answer placed it in.
@@ -492,17 +498,9 @@ impl UiRenderState {
/// The answer to an ask can be retained independently of where its
/// drawing ended up. Alignment is exactly that case: the first box is the
/// question and the smaller placed box holds the drawing.
fn retained_answer(
&self,
id: WidgetId,
region: UiRegion,
info: DrawInfo,
widgets: &Widgets,
) -> Option<(Size, [Holds; 2])> {
if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) {
return None;
}
/// question and the smaller placed box holds the drawing. Whether the
/// answer is stale at all is its caller's question, asked once there.
fn retained_answer(&self, id: WidgetId, info: DrawInfo) -> Option<(Size, [Holds; 2])> {
let active = self.active.get(&id)?;
let has_region_node = active.move_idx != active.parent_move;
if !active.drawn
@@ -511,15 +509,16 @@ impl UiRenderState {
{
return None;
}
let px = self.px_of(info.parent_move, region);
let (size, holds) = active.answer;
(holds[0].contains(px.x) && holds[1].contains(px.y)).then_some((size, holds))
(holds[0].contains(info.px.x) && holds[1].contains(info.px.y)).then_some((size, holds))
}
/// Whether anything whose size this widget's own size was read from is
/// dirty. Not needed for the answer to come right -- a changed size
/// reaches its reader in any order -- but a reader that asks first
/// lays out once rather than twice.
/// dirty, which makes what it would answer not yet known. It also keeps
/// a reader that asks first from laying out twice, which is all it was
/// here for while a changed size was thought to reach its reader in any
/// order; it does not, where the change settles inside the reader's own
/// draw.
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| {
@@ -528,31 +527,39 @@ impl UiRenderState {
})
}
/// The first box a widget was asked about, re-expressed in the coordinate
/// space its drawing uses. Keeping the relative box and composing it
/// again avoids rebuilding a shifted box from rounded pixel lengths.
fn offered_region(&self, id: WidgetId) -> UiRegion {
/// The pixel lengths of the box a widget was given and of the box it was
/// first asked about, which is what a local redraw needs to ask the
/// question its parent asked.
///
/// Both are threaded down from the window a length of a box at a time,
/// and this takes the same steps back up: a widget's box is a length of
/// the box its parent drew in, and its offer a length of the box its
/// parent was itself offered. Neither chain has a coordinate frame in it,
/// so neither breaks at a region node -- and both land on the numbers a
/// cold layout computes, rather than near them.
fn asked_px(&self, id: WidgetId) -> (PxVec2, PxVec2) {
let active = &self.active[&id];
let parent_region = match active.parent.and_then(|id| self.active.get(&id)) {
Some(parent) if parent.move_idx == active.parent_move => {
if parent.move_idx == parent.parent_move {
self.offered_region(parent.id)
} else {
UiRegion::FULL
}
// Nothing above the root: the window is where a fraction becomes
// pixels, which is also the whole of the box the root is given.
let (parent_px, parent_offer) = match active.parent.and_then(|p| self.active.get(&p)) {
Some(parent) => {
let (given, offer) = self.asked_px(parent.id);
let lens = placed_lens(parent.answer.0, parent.declared, parent.decided);
(lens.to_px(given), offer)
}
_ => UiRegion::FULL,
};
let mut offered = match active.offer == UiRegion::FULL {
true => parent_region,
false => active.offer.within(&parent_region),
None => (self.output_size, self.output_size),
};
let px = active.given_len.to_px(parent_px);
let mut offered = active.offer_len.to_px(parent_offer);
for axis in AXES {
// A declared length is resolved by whoever drew the widget, in
// the box that widget drew in, so the box it has is the box it
// was asked about however the offer above it moved.
if active.declared[axis as usize].is_some() {
*offered.axis_mut(axis) = *active.region.axis(axis);
*offered.axis_mut(axis) = px.axis(axis);
}
}
offered
(px, offered)
}
/// Reuses the actual drawing in a new box if its retained contract holds
@@ -609,10 +616,10 @@ impl UiRenderState {
}
return None;
}
// In pixels, because `region` is a fraction of a slot's box and that
// box may be what changed -- an unchanged fraction of a box half the
// size is half the widget.
if !active.holds_at(self.px_of(info.parent_move, region)) {
// In pixels, because `region` is a fraction of the box its parent
// drew in and that box may be what changed -- an unchanged fraction
// of a box half the size is half the widget.
if !active.holds_at(info.px) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseOutside);
@@ -621,23 +628,21 @@ impl UiRenderState {
return None;
}
let moved = active.region != region;
let (answer, old_region, slot, mask) = (
(active.size, active.holds),
active.region,
active.move_idx,
info.mask,
);
let (answer, old_region, slot) =
((active.size, active.holds), active.region, active.move_idx);
if moved {
if has_region_node {
self.moves.set(slot, region);
} else {
let remap = RegionRemap::new(old_region, region)?;
self.remap_subtree(id, remap, info.parent_move, mask, rsc);
self.remap_subtree(id, &remap, info.parent_move, rsc);
}
}
let active = self.active.get_mut(&id).unwrap();
active.region = region;
active.offer = info.offer;
active.given = region;
active.given_len = info.given_len;
active.offer_len = info.offer_len;
active.depth = info.depth;
#[cfg(feature = "layout-diagnostics")]
{
@@ -668,12 +673,12 @@ impl UiRenderState {
fn remap_subtree(
&mut self,
id: WidgetId,
remap: RegionRemap,
remap: &RegionRemap,
parent_move: MoveIdx,
inherited_mask: MaskIdx,
rsc: &mut dyn UiRsc,
) {
let active = self.active.get_mut(&id).unwrap();
active.given = remap.apply(active.given);
if active.move_idx != parent_move {
let region = remap.apply(active.region);
active.region = region;
@@ -685,16 +690,18 @@ impl UiRenderState {
*region = remap.apply(*region);
}
active.region = remap.apply(active.region);
let mask = active.mask;
let own_mask = (active.mask != active.parent_mask).then_some(active.mask);
let children = active.children.len();
if mask != inherited_mask && mask != MaskIdx::NONE {
let mask = rsc.ui_mut().masks.get_mut(mask);
// A mask the widget set itself moves with it; one it inherited
// belongs to the widget that set it, and moves there or not at all.
if let Some(idx) = own_mask {
let mask = rsc.ui_mut().masks.get_mut(idx);
debug_assert_eq!(mask.move_idx, parent_move);
mask.region = remap.apply(mask.region);
}
for index in 0..children {
let child = self.active[&id].children[index];
self.remap_subtree(child, remap, parent_move, mask, rsc);
self.remap_subtree(child, remap, parent_move, rsc);
}
}
@@ -769,7 +776,9 @@ impl UiRenderState {
ActiveData {
id,
region: UiRegion::FULL,
offer: UiRegion::FULL,
given: UiRegion::FULL,
given_len: UiVec2::FULL_SIZE,
offer_len: UiVec2::FULL_SIZE,
answer: (size, [Holds::ANY; 2]),
size,
holds: [Holds::ANY; 2],
@@ -782,11 +791,11 @@ impl UiRenderState {
size_deps: Vec::new(),
move_idx: info.parent_move,
declared: [None; 2],
align: RegionAlign::default(),
align_override: false,
decided: [false; 2],
own_align: rsc.widgets().alignment(id),
parent_move: info.parent_move,
mask: info.mask,
parent_mask: info.mask,
layer: info.layer,
},
);
@@ -802,7 +811,6 @@ impl UiRenderState {
self.answer_invalid.clear();
self.replace_answers = false;
self.moves.clear();
self.root_move = MoveIdx::NONE;
self.layers.clear();
rsc.widgets_mut().needs_redraw.clear();
self.free(rsc);
@@ -823,18 +831,33 @@ impl UiRenderState {
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::IncrementalLayout);
// Deepest first: a reader whose children have all settled asks each
// once, where any other order has it lay out again for whatever
// settles under it afterwards. Equal-depth widgets are independent,
// so their order does not matter.
while let Some(id) = {
let dirty = rsc.widgets().needs_redraw.iter().copied();
dirty.max_by_key(|&id| self.depth(id))
} {
// Deepest first, and strictly: a widget that cannot settle where it
// is defers to its parent rather than drawing the parent from
// inside itself. It marks the parent, stays marked, and waits here
// until the walk reaches its parent's depth.
//
// What that buys is that nothing shallower is ever drawn while
// anything deeper is still dirty. A parent drawing can therefore
// trust every answer it reads without descending to check whether
// something below is about to change it -- which is the whole class
// of defect where a widget settles inside its parent's draw, clears
// its mark there, and tells nobody its answer moved.
loop {
let next = rsc
.widgets()
.needs_redraw
.iter()
.copied()
.filter(|id| !self.deferred.contains(id))
.max_by_key(|&id| self.depth(id));
let Some(id) = next else { break };
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::QueuePops);
self.redraw(id, rsc);
if !self.redraw(id, rsc) {
self.deferred.insert(id);
}
}
self.deferred.clear();
}
fn depth(&self, id: WidgetId) -> usize {
@@ -903,23 +926,29 @@ impl UiRenderState {
}
/// Where a widget is on screen: its box composed through the boxes it
/// sits within, which is the walk the vertex shader does. `None` for one
/// that is not drawn.
/// sits within, the same walk the vertex shader does. `None` for one that
/// is not drawn.
///
/// This is for asking where a drawing landed: hit testing, and a test
/// reading a box back. Layout decides on the lengths threaded down the
/// draw instead, and a position is not one of its inputs.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let active = self.active.get(&id.id())?;
if !active.drawn {
return None;
}
let region = self.moves.resolve(active.parent_move, active.region);
Some(region.to_px(self.output_size))
active.drawn.then(|| {
self.moves
.resolve(active.parent_move, active.region)
.to_px(self.output_size)
})
}
/// Settles a dirty widget: asks it again where its parent asked, and
/// tells the parent if the answer changed.
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
/// tells the parent if the answer changed. `false` where the question is
/// its parent's rather than its own, which leaves it marked for the
/// parent to draw when the walk reaches that depth.
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> bool {
rsc.widgets_mut().needs_redraw.remove(&id);
let Some(active) = self.active.get(&id) else {
return;
return true;
};
// Its parent resolved its declared lengths into its box and decided
// whether to draw it at all, so a change to either is the parent's
@@ -939,86 +968,74 @@ impl UiRenderState {
at = self.active[&next].parent;
}
}
// Both stay marked: the parent because it has this to draw, and
// this because the parent must draw it rather than keep what it
// has. The mark comes off in `draw_at`, where the parent draws.
rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc);
// Whatever the parent did not draw again is nothing it holds now.
rsc.widgets_mut().needs_redraw.remove(&id);
return;
rsc.widgets_mut().needs_redraw.insert(parent);
return false;
}
if !active.drawn {
return;
return true;
}
let region = active.region;
let region_node = active.parent.is_some() && rsc.widgets().is_region_node(id);
let asked_in = match active.parent {
Some(_) => self.offered_region(id),
None => Self::root_region(id, rsc.widgets()),
// Nothing above the root resolved its rules or its alignment, so its
// box is its own to work out again against the output. Every other
// widget was given one.
let Some(parent) = active.parent else {
let region = Self::root_region(id, rsc.widgets());
let info = DrawInfo {
mask: active.parent_mask,
..self.root_info(region)
};
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::LocalRedraws);
let old = self.remove(id, false, rsc);
self.draw_inner(id, region, info, old, rsc);
return true;
};
let offered_px = self.px_of(active.parent_move, asked_in);
let at_offer = same_px(self.px_of(active.parent_move, region), offered_px);
let parent_must_place = active.parent.is_some()
&& (!region_node || active.align_override)
&& !same_pixel_region(
self.moves
.resolve(active.parent_move, region)
.to_px(self.output_size),
self.moves
.resolve(active.parent_move, asked_in)
.to_px(self.output_size),
);
// An independently positioned region node can redraw at its offer
// and move its slot to its own placement. Every other widget needs
// its parent to reproduce a different final position.
if let Some(parent) = active.parent
&& parent_must_place
{
let (given_px, offered_px) = self.asked_px(id);
// Asked again in the box its parent gave it, which is the question
// its parent asked only while that box is as long as the offer. Any
// other box is a different question, so the parent asks it, with the
// mark left on. Lengths and not whole boxes: what a drawing depends
// on is its lengths, so the same lengths elsewhere is one question.
if given_px != offered_px {
rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc);
rsc.widgets_mut().needs_redraw.remove(&id);
return;
rsc.widgets_mut().needs_redraw.insert(parent);
return false;
}
let info = DrawInfo {
layer: active.layer,
parent: active.parent,
depth: active.depth,
parent_move: active.parent_move,
region_node,
mask: active.mask,
offer: active.offer,
region_node: rsc.widgets().is_region_node(id),
mask: active.parent_mask,
given_len: active.given_len,
offer_len: active.offer_len,
px: given_px,
offered_px,
align: active.align_override.then_some(active.align),
decided: active.decided,
};
let (was_answer, was) = (active.answer, (active.size, active.holds));
let (given, was_answer) = (active.given, active.answer);
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::LocalRedraws);
let old = self.remove(id, false, rsc);
let answer = self.draw_inner(id, asked_in, info, old, rsc);
self.active.get_mut(&id).unwrap().answer = answer;
let Some(parent) = info.parent else {
return;
};
// `draw_inner` places the answer inside that box itself, which is the
// ask that leaves the widget where its parent put it.
let answer = self.draw_inner(id, given, info, old, rsc);
if answer != was_answer {
// Left where it was asked: the parent lays out again and chooses
// its final box.
// Its parent chose its box knowing the old answer, so it lays out
// again and chooses the box the new one asks for.
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::SizeChanges);
diag::bump(Counter::ReaderEdges);
}
rsc.widgets_mut().needs_redraw.insert(parent);
return;
}
if at_offer {
return;
}
// Then in the final box its parent chose from that answer. It is kept
// if it holds there; otherwise its result is the parent's business.
self.draw_inner(id, region, info, None, rsc);
let active = &self.active[&id];
if (active.size, active.holds) != was {
rsc.widgets_mut().needs_redraw.insert(parent);
}
true
}
}
@@ -1031,69 +1048,114 @@ fn within_box(size: Size, px: PxVec2, axis: Axis) -> bool {
len.leftover != Weight::ZERO || box_len.mul(len.rel) + len.px <= box_len
}
/// The same box is the same number of steps, both of these being lengths on
/// the grid rather than floats to be compared for nearness.
fn same_px(a: PxVec2, b: PxVec2) -> bool {
a == b
}
fn same_pixel_region(a: PixelRegion, b: PixelRegion) -> bool {
same_px(a.top_left, b.top_left) && same_px(a.bot_right, b.bot_right)
}
/// A retained region rewritten from one parent box into another. A fixed
/// source extent can be translated but cannot recover fractions for a resize.
#[derive(Clone, Copy)]
struct RegionRemap {
from: UiRegion,
to: UiRegion,
axes: [AxisRemap; 2],
}
/// Moving one axis of a box into another, worked out once for the whole
/// subtree that moves with it. Every part of that subtree is divided by the
/// same extent and placed between the same two ends, so the ends and the
/// divisor belong here rather than in each part's arithmetic.
#[derive(Clone, Copy)]
enum AxisRemap {
/// A box that kept its length carries its parts by moving them, which is
/// exact. Dividing to find the fraction each sits at and multiplying to
/// place it again are two roundings, and they land a step from where
/// growing the tree that way does.
Translate(Len),
/// A box that changed length has to re-express each part as a fraction of
/// the new one, which is what a part of a box means.
Scale(AxisScale),
}
#[derive(Clone, Copy)]
struct AxisScale {
/// What the fraction is measured from, and what divides it. `whole` is
/// the common case of a box spanning the whole of its parent's, where
/// dividing by one is the expensive way to write a subtraction.
start_rel: Rel,
extent: Rel,
whole: bool,
/// `lerp` is `a + (b - a) * fraction`, and both ends are the same for
/// every part, so each is kept as its near end and its span.
from_px: Px,
from_px_span: Px,
to_rel: Rel,
to_rel_span: Rel,
to_px: Px,
to_px_span: Px,
}
impl RegionRemap {
fn new(from: UiRegion, to: UiRegion) -> Option<Self> {
AXES.into_iter()
.all(|axis| {
let from = from.axis(axis);
from.start.rel != from.end.rel || from.len() == to.axis(axis).len()
})
.then_some(Self { from, to })
Some(Self {
axes: [AxisRemap::new(from.x, to.x)?, AxisRemap::new(from.y, to.y)?],
})
}
fn apply(self, region: UiRegion) -> UiRegion {
fn apply(&self, region: UiRegion) -> UiRegion {
// A box that only moved carries every part of itself by the same two
// amounts, and that is the common move. Asking it once for the whole
// region is what lets it be eight adds in a row rather than four
// sequences with a branch each -- measured, it is where the time in a
// move goes.
if let [AxisRemap::Translate(x), AxisRemap::Translate(y)] = self.axes {
return region.translated(x, y);
}
UiRegion {
x: self.apply_span(region.x, self.from.x, self.to.x),
y: self.apply_span(region.y, self.from.y, self.to.y),
x: self.axes[0].apply_span(region.x),
y: self.axes[1].apply_span(region.y),
}
}
}
fn apply_span(self, span: UiSpan, from: UiSpan, to: UiSpan) -> UiSpan {
UiSpan {
start: self.apply_scalar(span.start, from, to),
end: self.apply_scalar(span.end, from, to),
impl AxisRemap {
fn new(from: UiSpan, to: UiSpan) -> Option<Self> {
if from.len() == to.len() {
return Some(Self::Translate(to.start - from.start));
}
}
fn apply_scalar(self, scalar: Len, from: UiSpan, to: UiSpan) -> Len {
let extent = from.end.rel - from.start.rel;
// A box that only moved, or that has no relative extent to divide,
// carries its parts by moving them, which is exact. Dividing to find
// the fraction each sits at and multiplying to place it again are two
// roundings, and they land a step from where growing the tree that
// way does. Where the box changed length there is nothing else to do,
// and the fraction is what a part means.
if from.len() == to.len() || extent == Rel::ZERO {
return scalar + to.start - from.start;
// Without a relative extent there is no fraction to re-express: a box
// of fixed length cannot say where its parts sit in a different one.
if extent == Rel::ZERO {
return None;
}
// A box that spans the whole of its parent's is the common one, and
// dividing by one is the expensive way to write a subtraction.
let offset = scalar.rel - from.start.rel;
let fraction = match extent == Rel::ONE {
true => offset,
false => offset / extent,
Some(Self::Scale(AxisScale {
start_rel: from.start.rel,
extent,
whole: extent == Rel::ONE,
from_px: from.start.px,
from_px_span: from.end.px - from.start.px,
to_rel: to.start.rel,
to_rel_span: to.end.rel - to.start.rel,
to_px: to.start.px,
to_px_span: to.end.px - to.start.px,
}))
}
fn apply_span(&self, span: UiSpan) -> UiSpan {
UiSpan {
start: self.apply_scalar(span.start),
end: self.apply_scalar(span.end),
}
}
fn apply_scalar(&self, scalar: Len) -> Len {
let scale = match self {
Self::Translate(by) => return scalar + *by,
Self::Scale(scale) => scale,
};
let from_px = fraction.lerp(from.start.px, from.end.px);
let to_rel = fraction.lerp(to.start.rel, to.end.rel);
let to_px = fraction.lerp(to.start.px, to.end.px);
let offset = scalar.rel - scale.start_rel;
let fraction = match scale.whole {
true => offset,
false => offset / scale.extent,
};
let from_px = scale.from_px + scale.from_px_span.mul(fraction);
let to_rel = scale.to_rel + scale.to_rel_span.mul(fraction);
let to_px = scale.to_px + scale.to_px_span.mul(fraction);
Len::from_parts(to_rel, scalar.px - from_px + to_px)
}
}
+5 -1
View File
@@ -20,10 +20,14 @@ impl DefaultAppState for Client {
let pad_test = (
rrect.color(Color::BLUE),
(
// The square is one widget and the two shares of the row it
// sits centred in are another: a length is a property of a
// widget, so `.width` here would overwrite the `.sized`.
rrect
.color(Color::RED)
.sized((100, 100))
.center()
.wrapper()
.width(leftover(2)),
(
rrect.color(Color::ORANGE),
@@ -143,7 +147,7 @@ impl DefaultAppState for Client {
.span(Dir::DOWN)
.add(rsc);
let main = WidgetPtr::new().add(rsc);
let main = Wrapper::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |color, to: WeakWidget, label| {
+8 -3
View File
@@ -26,12 +26,17 @@ impl DefaultAppState for State {
.wrap(true)
.text_align(Align::LEFT)
.pad(16)
.width(rel(1.0))
.background(panel());
// Each one takes the whole width, because `text_align` puts the
// glyphs somewhere in the box the text is given and a text that
// reports the width of its own glyphs is given exactly that.
let label = |text: &str, align| wtext(text).size(24).text_align(align).width(rel(1.0));
let aligned = (
wtext("left").size(24).text_align(Align::LEFT),
wtext("centred").size(24).text_align(Align::CENTER),
wtext("right").size(24).text_align(Align::RIGHT),
label("left", Align::LEFT),
label("centred", Align::H_CENTER),
label("right", Align::RIGHT),
)
.span(Dir::DOWN)
.gap(8)
+697 -158
View File
@@ -29,6 +29,17 @@ pub struct Edits {
/// one. Region nodes change what a move writes and how deep a primitive's
/// chain is, so a tree that never grows one leaves both untested.
pub nodes: HashMap<usize, bool>,
/// Whether a [`Branch`] takes the side it would take at any measurement,
/// rather than the side the one it made says. The oracle wants the
/// measured side -- that is the whole point of a branch, and how a widget
/// believing a measurement a cold start would not have given it becomes a
/// different tree. A rig measuring cost wants this instead: a fixture
/// whose shape moves with the thing being measured cannot be compared
/// with itself across a change to it, and seed 1 at depth 8 went from 88
/// drawn widgets and 2,298 primitive writes a frame to 115 and 8,209
/// across fixed point, which is three and a half times the work behind a
/// number read as three and a half times the cost.
pub fixed_branches: bool,
}
#[derive(Default, Clone)]
@@ -90,10 +101,6 @@ pub struct Tree {
pub nodes: Vec<WidgetId>,
pub spans: Vec<Spanned>,
pub scrolls: Vec<WeakWidget<Scroll>>,
/// Children a `SpanEdit` took out, held so that dropping the last share
/// of one does not free its id for the next widget to be given -- which
/// would put the two trees' `ids` out of step.
pub detached: Vec<StrongWidget>,
}
/// Branches on a child's measured length. Comparing boxes catches a widget
@@ -127,15 +134,463 @@ impl Widget for Branch {
pub struct Spanned {
pub id: WeakWidget<Span>,
/// Leaves grown with the span whether or not they end up in it, so both
/// trees make the same widgets in the same order either way. Attaching
/// one moves it out of here: a widget belongs to one parent, and one that
/// belongs to nobody still has to be held or it reads as a leak.
/// Everything made for this span that it does not hold -- spares never
/// attached and children detached alike. A widget belongs to one parent,
/// and one that belongs to nobody still has to be held here: dropping
/// the last share of it frees its id for the next widget to be given,
/// which puts two trees out of step.
pub spares: Vec<StrongWidget>,
/// How many children it was grown with, before any edit.
pub grown: usize,
}
/// A tree described rather than built: [`plan`] turns a seed into one of
/// these and [`build`] turns it into widgets, where growing did both at once.
///
/// The split is what makes a counterexample readable. A failing seed used to
/// be the entire record of one, because a grower that makes widgets as it
/// draws leaves nothing to take apart -- a shrinker could only grow its own
/// trees and hope to meet the same shape, which in practice it does not. A
/// plan is reduced by [`Plan::smaller`] and built again, so any seed that
/// fails can be cut down until what is left is small enough to read.
#[derive(Clone, Debug, PartialEq)]
pub struct Plan {
pub kind: Kind,
/// The declared size this widget carries. Whoever grows a widget offers
/// it one and the offer is taken or declined; a second offer to the same
/// widget is dropped, because two rules on one widget would settle in the
/// order they were applied rather than in grow order.
pub size: Option<Lens>,
/// The alignment it carries, under the same one-offer rule.
pub align: Option<Aligns>,
/// Whether it was offered a movable region of its own and what it
/// answered. `Some(false)` is an offer declined, which still uses up the
/// one offer, where `None` is an offer never made.
pub region_node: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Kind {
/// Wrapped and unwrapped text, because only one of them reads the width
/// it is given and so only one has to be drawn again for a new one.
Wrapped,
OneLine,
Rect {
color: usize,
alpha: u8,
},
/// Scrolling reads the pixel length of its box, which nothing else here
/// does, and gives its child a box longer than its own.
Scroll {
axis: Axis,
inner: Box<Plan>,
},
/// All three sides are grown either way, so a tree that draws one has the
/// same ids as a tree that draws another.
Branch {
probe: Box<Plan>,
wide: Box<Plan>,
narrow: Box<Plan>,
threshold: f32,
},
/// Each side its own, since a padding that is the same all round hides
/// anything that treats one edge differently from another.
Pad {
padding: [i32; 4],
inner: Box<Plan>,
},
Stack {
children: Vec<Plan>,
},
Span {
dir: usize,
gap: i32,
/// Grown for this span, in the order they are made.
children: Vec<Plan>,
/// Grown beside it whether or not they end up in it, so the widget
/// after them has the same id in a tree that leaves them out as in
/// one that puts them in.
spares: Vec<Plan>,
/// Which of `children` then `spares` are actually in the span, and
/// in what order -- kept apart from the two lists above so that a
/// tree which detaches, attaches or reorders its children still
/// makes the same widgets in the same order, and two builds line up
/// index for index. Anything not named here is built and held
/// rather than dropped, since freeing an id hands it to the next
/// widget and puts two trees out of step.
order: Vec<usize>,
},
}
impl Plan {
/// A widget carrying nothing anybody has offered it yet.
fn bare(kind: Kind) -> Self {
Self {
kind,
size: None,
align: None,
region_node: None,
}
}
/// How many widgets building it makes, spares and detached children
/// included, since those are made either way.
pub fn size(&self) -> usize {
1 + match &self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => inner.size(),
Kind::Branch {
probe,
wide,
narrow,
..
} => probe.size() + wide.size() + narrow.size(),
Kind::Stack { children } => children.iter().map(Plan::size).sum(),
Kind::Span {
children, spares, ..
} => children.iter().chain(spares).map(Plan::size).sum(),
_ => 0,
}
}
/// The trees to try instead of this one when reducing a counterexample,
/// biggest cut first: a shrinker takes the first that still fails, so
/// offering "this subtree alone" before "this subtree with one child
/// fewer" is what gets from six hundred widgets to six rather than to
/// five hundred and ninety.
///
/// Every one of these is a tree the generator could have grown, so a
/// reduced plan is a counterexample in its own right rather than a
/// special case only the shrinker can make.
pub fn smaller(&self) -> Vec<Plan> {
let mut out = Vec::new();
// Standing in for the whole of it, which is the largest cut there is.
for kid in self.kids() {
out.push(kid.clone());
}
// Then what it carries, which costs nothing to put back if it was
// not the thing that mattered.
for dropped in [
self.region_node.map(|_| Plan {
region_node: None,
..self.clone()
}),
self.align.map(|_| Plan {
align: None,
..self.clone()
}),
self.size.map(|_| Plan {
size: None,
..self.clone()
}),
]
.into_iter()
.flatten()
{
out.push(dropped);
}
out.extend(self.kind.smaller().into_iter().map(|kind| Plan {
kind,
..self.clone()
}));
out
}
/// Visits every widget in the order [`build`] makes them, so a count
/// kept by the visitor indexes the same widget as the matching [`Tree`]
/// vector does.
pub fn walk_mut(&mut self, at: &mut impl FnMut(&mut Plan)) {
match &mut self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => inner.walk_mut(at),
Kind::Branch {
probe,
wide,
narrow,
..
} => {
probe.walk_mut(at);
wide.walk_mut(at);
narrow.walk_mut(at);
}
Kind::Stack { children } => {
for child in children {
child.walk_mut(at);
}
}
Kind::Span {
children, spares, ..
} => {
for child in children.iter_mut().chain(spares) {
child.walk_mut(at);
}
}
_ => {}
}
at(self);
}
/// The same tree with `edits` applied, by the indices the generator would
/// have used for them.
///
/// [`plan`] resolves edits while drawing, which needs a seed. A scenario
/// needs them applied to a tree that already exists -- one it has built,
/// and one a shrinker may already have cut down, where no seed grows it
/// any more. Both routes take the same [`Edits`], so a case written
/// against one reads the same against the other.
pub fn edited(&self, edits: &Edits) -> Plan {
let mut out = self.clone();
let (mut sized, mut aligned, mut nodes, mut spans) = (0, 0, 0, 0);
out.walk_mut(&mut |plan| {
if let Kind::Span {
children,
spares,
order,
..
} = &mut plan.kind
{
if let Some(edit) = edits.spans.get(&spans) {
*order = span_edited(order, children.len(), spares.len(), edit);
}
spans += 1;
}
if let Kind::Branch { threshold, .. } = &mut plan.kind
&& edits.fixed_branches
{
*threshold = f32::MIN;
}
if plan.size.is_some() {
if let Some(lens) = edits.sizes.get(&sized) {
plan.size = Some(*lens);
}
sized += 1;
}
if plan.align.is_some() {
if let Some(align) = edits.aligns.get(&aligned) {
plan.align = Some(*align);
}
aligned += 1;
}
if plan.region_node.is_some() {
if let Some(take) = edits.nodes.get(&nodes) {
plan.region_node = Some(*take);
}
nodes += 1;
}
});
out
}
fn kids(&self) -> Vec<&Plan> {
match &self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => vec![inner],
Kind::Branch {
probe,
wide,
narrow,
..
} => vec![probe, wide, narrow],
Kind::Stack { children } => children.iter().collect(),
Kind::Span { children, .. } => children.iter().collect(),
_ => Vec::new(),
}
}
}
impl Kind {
/// Simplifications of the shape alone, leaving what the widget carries to
/// [`Plan::smaller`]. Replacing a node with one of its children is there
/// rather than here, since it answers with a whole `Plan`.
fn smaller(&self) -> Vec<Kind> {
let mut out = Vec::new();
/// One child reduced at a time, rebuilt into the same shape. Every
/// answer has the same number of children as it was given, so it is
/// for the shapes whose child count is part of what they are.
fn reduced(kids: &[Plan], rebuild: &dyn Fn(Vec<Plan>) -> Kind) -> Vec<Kind> {
let mut out = Vec::new();
for (i, kid) in kids.iter().enumerate() {
for small in kid.smaller() {
let mut next = kids.to_vec();
next[i] = small;
out.push(rebuild(next));
}
}
out
}
/// One child dropped, then [`reduced`]. For the shapes that hold any
/// number of children, where dropping one is the cut that matters.
fn each(kids: &[Plan], rebuild: &dyn Fn(Vec<Plan>) -> Kind) -> Vec<Kind> {
let mut out = Vec::new();
for i in 0..kids.len() {
if kids.len() > 1 {
let mut less = kids.to_vec();
less.remove(i);
out.push(rebuild(less));
}
}
out.extend(reduced(kids, rebuild));
out
}
match self {
// The one leaf that reads the width it is given, then the one
// that does not, then the one that measures nothing at all.
Kind::Wrapped => out.push(Kind::OneLine),
Kind::OneLine => out.push(Kind::Rect {
color: 0,
alpha: 255,
}),
Kind::Rect { .. } => {}
Kind::Scroll { axis, inner } => {
let axis = *axis;
out.extend(each(std::slice::from_ref(inner), &|mut k| Kind::Scroll {
axis,
inner: Box::new(k.remove(0)),
}));
}
Kind::Branch {
probe,
wide,
narrow,
threshold,
} => {
let threshold = *threshold;
// All three sides stay: a branch is the widget that draws
// one of two on a measurement, and one with a side missing
// is a different widget rather than a smaller one. Dropping
// the branch for a side is offered by `Plan::smaller`.
let sides = [(**probe).clone(), (**wide).clone(), (**narrow).clone()];
out.extend(reduced(&sides, &|k| Kind::Branch {
probe: Box::new(k[0].clone()),
wide: Box::new(k[1].clone()),
narrow: Box::new(k[2].clone()),
threshold,
}));
}
Kind::Pad { padding, inner } => {
let padding = *padding;
if padding != [0; 4] {
out.push(Kind::Pad {
padding: [0; 4],
inner: inner.clone(),
});
}
out.extend(each(std::slice::from_ref(inner), &|mut k| Kind::Pad {
padding,
inner: Box::new(k.remove(0)),
}));
}
Kind::Stack { children } => {
out.extend(each(children, &|children| Kind::Stack { children }))
}
Kind::Span {
dir,
gap,
children,
spares,
order,
} => {
let (dir, gap, n) = (*dir, *gap, children.len());
let span = |children: Vec<Plan>, spares: Vec<Plan>, order: Vec<usize>| Kind::Span {
dir,
gap,
children,
spares,
order,
};
let identity: Vec<usize> = (0..n).collect();
// An order the generator did not choose is part of the tree,
// so take that off before taking the tree apart.
if *order != identity {
out.push(span(children.clone(), spares.clone(), identity));
}
// Spares exist to be attached; with none attached they are
// widgets the span never holds.
if !spares.is_empty() && order.iter().all(|&i| i < n) {
out.push(span(children.clone(), Vec::new(), order.clone()));
}
if gap != 0 {
out.push(Kind::Span {
dir,
gap: 0,
children: children.clone(),
spares: spares.clone(),
order: order.clone(),
});
}
for k in 0..n {
if n > 1 {
let mut less = children.clone();
less.remove(k);
// Everything after it shifts down, spares included,
// since they are indexed past the children.
let order = order
.iter()
.filter(|&&i| i != k)
.map(|&i| if i > k { i - 1 } else { i })
.collect();
out.push(span(less, spares.clone(), order));
}
}
for (i, kid) in children.iter().enumerate() {
for small in kid.smaller() {
let mut next = children.clone();
next[i] = small;
out.push(span(next, spares.clone(), order.clone()));
}
}
}
}
out
}
}
/// A [`SpanEdit`] applied to the order a span already holds its children in.
///
/// `detach` names positions in that order and `attach` takes from the front
/// of what the span is not holding, both of which is what a test changing a
/// live span does -- so an edit means the same thing said to a tree and said
/// to the plan it was built from. On a span nobody has edited the order is
/// the children in the order they were grown, and this is then "leave these
/// out and put that many spares on the end".
fn span_edited(order: &[usize], children: usize, spares: usize, edit: &SpanEdit) -> Vec<usize> {
let mut detach = edit.detach.clone();
detach.sort_unstable();
detach.dedup();
let mut next: Vec<usize> = order
.iter()
.enumerate()
.filter(|(at, _)| !detach.contains(at))
.map(|(_, &which)| which)
.collect();
// What the span is not holding, in the order it hands them back: what it
// was already not holding first, in the order the widgets were made, and
// what this edit takes out after that, highest position first. A child
// just detached goes to the back rather than straight back in, which is
// what makes detaching one and attaching one a trade.
let mut free: Vec<usize> = (0..children + spares)
.filter(|i| !order.contains(i))
.collect();
free.extend(detach.iter().rev().filter_map(|&at| order.get(at).copied()));
next.extend(free.into_iter().take(edit.attach));
next
}
/// Plans the tree `seed` describes, `edits` replacing what it would otherwise
/// have given the widgets that carry them.
///
/// The edits are resolved here rather than at build time, so that a plan is
/// the whole of what a tree is and building one has nothing left to decide.
pub fn plan(seed: u64, depth: usize, edits: &Edits) -> Plan {
let mut sow = Sow {
rng: Rng::new(seed),
edits,
sized: 0,
aligned: 0,
nodes: 0,
spans: 0,
};
sow.node(depth)
}
/// Grows the tree `seed` describes, `edits` replacing the declared sizes it
/// would otherwise have given those wrappers.
pub fn grow<Rsc: UiRsc + 'static>(
@@ -144,41 +599,32 @@ pub fn grow<Rsc: UiRsc + 'static>(
depth: usize,
edits: &Edits,
) -> (StrongWidget, Tree) {
let mut grow = Grow {
rsc,
rng: Rng::new(seed),
tree: Tree::default(),
edits,
};
let root = grow.node(depth);
(root, grow.tree)
build(rsc, &plan(seed, depth, edits))
}
struct Grow<'a, Rsc> {
rsc: &'a mut Rsc,
/// Draws a plan out of the random stream. Every draw happens in the order it
/// always has and before the decision it feeds, including the decisions that
/// are then dropped, because a seed has to keep meaning the same tree.
struct Sow<'a> {
rng: Rng,
tree: Tree,
edits: &'a Edits,
sized: usize,
aligned: usize,
nodes: usize,
spans: usize,
}
impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
fn leaf(&mut self) -> StrongWidget {
let id: StrongWidget = match self.rng.below(4) {
// Wrapped and unwrapped, because only one of them reads the width
// it is given and so only one has to be drawn again for a new one.
0 => wtext(WORDS).size(16).wrap(true).add_strong(self.rsc),
1 => wtext("one line, overflowing whatever it is given")
.size(16)
.wrap(false)
.add_strong(self.rsc),
impl Sow<'_> {
fn leaf(&mut self) -> Plan {
Plan::bare(match self.rng.below(4) {
0 => Kind::Wrapped,
1 => Kind::OneLine,
_ => {
let color = COLORS[self.rng.below(COLORS.len())];
let color = self.rng.below(COLORS.len());
let alpha = (self.rng.below(5) * 63) as u8;
rect(color.alpha(alpha)).add_strong(self.rsc)
Kind::Rect { color, alpha }
}
};
self.tree.ids.push(id.id());
id
})
}
fn len(&mut self) -> Option<LayoutLen> {
@@ -189,188 +635,281 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
}
}
fn align(&mut self) -> Align {
let mut axis = || match self.rng.below(4) {
fn align(&mut self) -> Aligns {
let axis = |s: &mut Self| match s.rng.below(4) {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
};
let (mut x, y) = (axis(), axis());
let (x, y) = (axis(self), axis(self));
// Aligning on neither axis leaves the branch unexercised.
if x.is_none() && y.is_none() {
x = Some(AxisAlign::CENTER);
match x.is_none() && y.is_none() {
true => [Some(AxisAlign::CENTER), y],
false => [x, y],
}
Align { x, y }
}
/// A declared size over half the tree, kept where a test can change it.
fn sized(&mut self, inner: StrongWidget) -> StrongWidget {
// A rule is a property now, so a node already carrying one would take
// a second entry in `sized` -- and two edits naming one widget settle
// in the order they are applied, which is grow order cold and edit
// order warm. One entry per widget instead. Both draws are taken
// whatever is decided, and the decision is grow order alone, so the
// two trees consume the same random stream.
fn sized(&mut self, inner: &mut Plan) {
let take = self.rng.chance();
let lens = [self.len(), self.len()];
if !take || self.tree.sized.contains(&inner.id()) {
return inner;
if !take || inner.size.is_some() {
return;
}
let idx = self.tree.sized.len();
let lens = self.edits.sizes.get(&idx).copied().unwrap_or(lens);
let id = inner.id();
self.rsc
.ui_mut()
.widgets
.set_size_rules(id, lens[0], lens[1]);
self.tree.sized.push(id);
inner
let idx = self.sized;
self.sized += 1;
inner.size = Some(self.edits.sizes.get(&idx).copied().unwrap_or(lens));
}
/// An alignment over some of the tree, kept where a test can change it.
/// One entry per widget for the reason `sized` gives.
fn aligned(&mut self, inner: StrongWidget) -> StrongWidget {
fn aligned(&mut self, inner: &mut Plan) {
let align = self.align();
let align = [align.x, align.y];
if self.tree.aligned.contains(&inner.id()) {
return inner;
if inner.align.is_some() {
return;
}
let idx = self.tree.aligned.len();
let align = self.edits.aligns.get(&idx).copied().unwrap_or(align);
let id = inner.id();
let widgets = &mut self.rsc.ui_mut().widgets;
for (axis, align) in [Axis::X, Axis::Y].into_iter().zip(align) {
widgets.set_alignment(id, axis, align.unwrap_or_default());
}
self.tree.aligned.push(id);
inner
let idx = self.aligned;
self.aligned += 1;
inner.align = Some(self.edits.aligns.get(&idx).copied().unwrap_or(align));
}
/// A movable region of its own over some of the tree. What it changes is
/// how a move is written and how long a primitive's chain is, neither of
/// which any other branch here varies.
fn noded(&mut self, inner: StrongWidget) -> StrongWidget {
fn noded(&mut self, inner: &mut Plan) {
let take = self.rng.below(4) == 0;
if self.tree.nodes.contains(&inner.id()) {
return inner;
if inner.region_node.is_some() {
return;
}
let idx = self.tree.nodes.len();
let take = self.edits.nodes.get(&idx).copied().unwrap_or(take);
let id = inner.id();
self.rsc.ui_mut().widgets.set_region_node(id, take);
self.tree.nodes.push(id);
inner
let idx = self.nodes;
self.nodes += 1;
inner.region_node = Some(self.edits.nodes.get(&idx).copied().unwrap_or(take));
}
fn node(&mut self, depth: usize) -> StrongWidget {
fn offered(&mut self, inner: &mut Plan) {
self.sized(inner);
self.noded(inner);
}
fn node(&mut self, depth: usize) -> Plan {
if depth == 0 {
return self.leaf();
}
let positioned = self.rng.below(6);
if positioned == 0 {
// Scrolling reads the pixel length of its box, which nothing
// else here does, and gives its child a box longer than its own.
let inner = self.node(depth - 1);
let inner = self.sized(inner);
let inner = self.noded(inner);
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
let axis = if self.rng.chance() { Axis::X } else { Axis::Y };
let id = Scroll::new(inner, axis).add(self.rsc);
self.tree.scrolls.push(id);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
return Plan::bare(Kind::Scroll {
axis,
inner: Box::new(inner),
});
}
if positioned == 2 {
// Both sides are grown either way, so a tree that draws one has
// the same ids as a tree that draws the other.
let probe = self.node(depth - 1);
let wide = self.node(depth - 1);
let narrow = self.node(depth - 1);
let threshold = self.rng.below(500) as f32;
let id = Branch {
probe,
wide,
narrow,
// Drawn either way, so the side a fixed branch takes is still a
// side the generator chose -- and it consumes the same randomness
// as a measured one, so the two grow the same ids.
let measured = self.rng.below(500) as f32;
let threshold = match self.edits.fixed_branches {
true => f32::MIN,
false => measured,
};
return Plan::bare(Kind::Branch {
probe: Box::new(probe),
wide: Box::new(wide),
narrow: Box::new(narrow),
threshold,
}
.add(self.rsc);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
});
}
if positioned == 1 {
let inner = self.node(depth - 1);
let inner = self.sized(inner);
let inner = self.noded(inner);
return self.aligned(inner);
// Carries an alignment and makes no widget of its own, so the
// plan for it is the child it aligned.
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
self.aligned(&mut inner);
return inner;
}
if self.rng.below(4) == 0 {
let inner = self.node(depth - 1);
let inner = self.sized(inner);
let inner = self.noded(inner);
// Each side its own, since a padding that is the same all round
// hides anything that treats one edge differently from another.
let mut side = || Px::from_int(self.rng.below(24) as i32);
let padding = Padding {
left: side(),
right: side(),
top: side(),
bottom: side(),
};
let id = Pad { padding, inner }.add_strong(self.rsc);
self.tree.ids.push(id.id());
return id;
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
let side = |s: &mut Self| s.rng.below(24) as i32;
let padding = [side(self), side(self), side(self), side(self)];
return Plan::bare(Kind::Pad {
padding,
inner: Box::new(inner),
});
}
let grown = 2 + self.rng.below(3);
let mut children = Vec::with_capacity(grown);
for _ in 0..grown {
let child = self.node(depth - 1);
let child = self.sized(child);
let child = self.noded(child);
let mut child = self.node(depth - 1);
self.offered(&mut child);
children.push(child);
}
if self.rng.chance() {
let id = Stack {
children,
size: StackSize::Child(0),
}
.add_strong(self.rsc);
self.tree.ids.push(id.id());
return id;
return Plan::bare(Kind::Stack { children });
}
// Grown either way, so the widget after them has the same id in a
// tree that leaves them out as in one that puts them in.
let mut spares: Vec<StrongWidget> = (0..SPARES).map(|_| self.leaf()).collect();
let idx = self.tree.spans.len();
let spares: Vec<Plan> = (0..SPARES).map(|_| self.leaf()).collect();
let idx = self.spans;
self.spans += 1;
let edit = self.edits.spans.get(&idx).cloned().unwrap_or_default();
// Highest first, so an index means the same child however many of its
// neighbours are going too.
let mut detach = edit.detach.clone();
detach.sort_unstable();
for j in detach.into_iter().rev() {
if j < children.len() {
self.tree.detached.push(children.remove(j));
}
}
let attach = edit.attach.min(spares.len());
children.extend(spares.drain(..attach));
let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][self.rng.below(4)];
let id = Span {
children,
dir,
gap: Px::from_int(self.rng.below(3) as i32 * 4),
}
.add(self.rsc);
let dir = self.rng.below(4);
// A row takes the height it is given rather than its tallest child,
// which is a rule beside it. Derived from an existing choice and
// consuming no randomness: a seed must keep growing the same tree
// when the generator gains another configuration.
if dir.axis == Axis::X {
let gap = self.rng.below(3) as i32 * 4;
let grown: Vec<usize> = (0..children.len()).collect();
let order = span_edited(&grown, children.len(), spares.len(), &edit);
Plan::bare(Kind::Span {
dir,
gap,
children,
spares,
order,
})
}
}
/// Builds a plan's widgets in the order it describes them, so two builds of
/// one plan line up index for index and their boxes can be compared.
pub fn build<Rsc: UiRsc + 'static>(rsc: &mut Rsc, plan: &Plan) -> (StrongWidget, Tree) {
let mut build = Build {
rsc,
tree: Tree::default(),
};
let root = build.node(plan);
(root, build.tree)
}
struct Build<'a, Rsc> {
rsc: &'a mut Rsc,
tree: Tree,
}
impl<Rsc: UiRsc + 'static> Build<'_, Rsc> {
fn node(&mut self, plan: &Plan) -> StrongWidget {
let built = self.kind(&plan.kind);
let id = built.id();
if let Some(lens) = plan.size {
self.rsc
.widgets_mut()
.set_size_rules(id, None, Some(LayoutLen::rel(1.0)));
.ui_mut()
.widgets
.set_size_rules(id, lens[0], lens[1]);
self.tree.sized.push(id);
}
if let Some(align) = plan.align {
let widgets = &mut self.rsc.ui_mut().widgets;
for (axis, align) in [Axis::X, Axis::Y].into_iter().zip(align) {
widgets.set_alignment(id, axis, align.unwrap_or_default());
}
self.tree.aligned.push(id);
}
if let Some(take) = plan.region_node {
self.rsc.ui_mut().widgets.set_region_node(id, take);
self.tree.nodes.push(id);
}
built
}
fn kind(&mut self, kind: &Kind) -> StrongWidget {
let id: StrongWidget = match kind {
Kind::Wrapped => wtext(WORDS).size(16).wrap(true).add_strong(self.rsc),
Kind::OneLine => wtext("one line, overflowing whatever it is given")
.size(16)
.wrap(false)
.add_strong(self.rsc),
Kind::Rect { color, alpha } => rect(COLORS[*color].alpha(*alpha)).add_strong(self.rsc),
Kind::Scroll { axis, inner } => {
let inner = self.node(inner);
let id = Scroll::new(inner, *axis).add(self.rsc);
self.tree.scrolls.push(id);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
}
Kind::Branch {
probe,
wide,
narrow,
threshold,
} => {
let probe = self.node(probe);
let wide = self.node(wide);
let narrow = self.node(narrow);
let id = Branch {
probe,
wide,
narrow,
threshold: *threshold,
}
.add(self.rsc);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
}
Kind::Pad { padding, inner } => {
let inner = self.node(inner);
let [left, right, top, bottom] = padding.map(Px::from_int);
let padding = Padding {
left,
right,
top,
bottom,
};
Pad { padding, inner }.add_strong(self.rsc)
}
Kind::Stack { children } => {
let children = children.iter().map(|c| self.node(c)).collect();
Stack {
children,
size: StackSize::Child(0),
}
.add_strong(self.rsc)
}
Kind::Span {
dir,
gap,
children,
spares,
order,
} => {
let grown = children.len();
// Every one of them is made, in this order, whether or not
// the span ends up holding it.
let made: Vec<StrongWidget> = children
.iter()
.chain(spares)
.map(|c| self.node(c))
.collect();
let mut left: Vec<Option<StrongWidget>> = made.into_iter().map(Some).collect();
let children: Vec<StrongWidget> = order
.iter()
.filter_map(|&i| left.get_mut(i).and_then(Option::take))
.collect();
// What the span does not hold is still held here: dropping
// the last share of a widget frees its id for the next one
// to be given, which puts two trees out of step.
let spares: Vec<StrongWidget> = left.into_iter().flatten().collect();
let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][*dir % 4];
let id = Span {
children,
dir,
gap: Px::from_int(*gap),
}
.add(self.rsc);
if dir.axis == Axis::X {
self.rsc
.widgets_mut()
.set_size_rules(id, None, Some(LayoutLen::rel(1.0)));
}
self.tree.ids.push(id.id());
self.tree.spans.push(Spanned { id, spares, grown });
return id.add_strong(self.rsc);
}
};
self.tree.ids.push(id.id());
self.tree.spans.push(Spanned { id, spares, grown });
id.add_strong(self.rsc)
id
}
}
+2 -2
View File
@@ -1,15 +1,15 @@
mod image;
mod mask;
mod position;
mod ptr;
mod rect;
mod text;
mod trait_fns;
mod wrapper;
pub use image::*;
pub use mask::*;
pub use position::*;
pub use ptr::*;
pub use rect::*;
pub use text::*;
pub use trait_fns::*;
pub use wrapper::*;
+7 -1
View File
@@ -7,8 +7,14 @@ pub struct Pad {
impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) -> Size {
// The inner's own alignment, not the near edge. This reports the
// inner's size plus the padding, so where the box is that answer the
// inset box is exactly the inner and alignment has no room to move
// it; where the box is bigger -- a share of a row, a rule over this
// widget -- the slack is the inner's to sit in, and forcing the near
// edge pinned it to a corner it had not asked for.
let inner = painter
.widget_aligned(&self.inner, self.padding.region(), RegionAlign::NEAR)
.widget_within(&self.inner, self.padding.region())
.size();
Size {
x: LayoutLen {
+3 -2
View File
@@ -14,7 +14,8 @@ impl Widget for Scroll {
let container_len = painter.px_len(self.axis);
// Draw in the whole container only when its scrolling-axis length is
// not already known, then draw it at the scrolled offset.
let answer_len = match painter.known_len(&self.inner, self.axis, UiRegion::FULL) {
let whole = UiRegion::FULL;
let answer_len = match painter.known_len(&self.inner, self.axis, whole, whole.size()) {
Some(len) => len,
None => painter.widget(&self.inner).size().axis(self.axis),
};
@@ -63,7 +64,7 @@ impl Widget for Scroll {
region = region.offset(offset);
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
}
painter.widget_aligned(&self.inner, region, RegionAlign::NEAR);
painter.widget_at(&self.inner, region, region.size(), [true; 2]);
// What it occupies is its box, on both axes: it clips its content to
// that box, so it can neither take less of one nor honestly ask for
// more. The content's length is what it scrolls through, not what it
+17 -3
View File
@@ -20,9 +20,15 @@ impl Widget for Span {
span.flip();
}
let region = UiRegion::from_axis(axis, span, UiSpan::FULL);
let len = match painter.known_len(child, axis, region) {
// Offered the room left from the cursor, because a text has to
// wrap at the width actually there, but reporting a fraction of
// the whole row: `rel(0.5)` is half the span whatever else is in
// it and wherever this child sits among them.
let len = match painter.known_len(child, axis, region, UiVec2::FULL_SIZE) {
Some(len) => len,
None => painter.widget_within(child, region).len(axis),
None => painter
.widget_at(child, region, UiVec2::FULL_SIZE, [false; 2])
.len(axis),
};
cursor.px += len.px + self.gap;
cursor.rel += len.rel;
@@ -119,7 +125,15 @@ impl Widget for Span {
if self.dir.sign == Sign::Neg {
region.flip(axis);
}
let placed = painter.widget_within(child, region);
// Along the row this box is the child's own answer, so the answer
// is not placed in it again; across it the child sits where its
// alignment says.
let placed = painter.widget_at(
child,
region,
UiVec2::FULL_SIZE,
[axis == Axis::X, axis == Axis::Y],
);
if shrinks {
let used = placed.len(!axis);
// Choosing between a fixed and a relative length from the
+28 -9
View File
@@ -13,23 +13,42 @@ impl Widget for Stack {
StackSize::Default => None,
StackSize::Child(i) => Some(i),
};
// Whichever child sizes the stack decides the box every child gets.
// The stack reports that size, so a child given a longer box would
// draw outside what the stack says it occupies.
// Every child gets the whole of this stack's box, the sizing one
// included, and the stack is then handed a box of the length that
// child asked for. Not the part of the box that length takes: the
// stack's own box becomes that length, and taking the fraction of it
// again is the fraction twice -- a child asking for half of a stack
// that is already half a row would have a quarter of the row.
//
// It cannot be told apart by asking whether this box is the answer
// yet, either. A drawing has to be a function of the box alone, since
// moving the stack into the box it asked for reuses the drawing by
// scaling it, and a drawing made a fraction of one box is right in
// any other. So: fractions of this box throughout, and the move is
// the whole of the difference.
let region = UiRegion::FULL;
// Whichever child sizes the stack is asked here and not again below,
// on the layer it ends up on: a retained drawing belongs to the layer
// it was made on, so measuring it anywhere else costs a second
// drawing of it. Its box is its own answer, so the answer is not
// placed inside it again.
let size = match sizing.and_then(|i| self.children.get(i).map(|c| (i, c))) {
// On the layer that child ends up on, so the ask below is a reuse
// rather than a second drawing of it somewhere else: a retained
// drawing belongs to the layer it was made on.
Some((i, child)) => {
painter.child_layer_at(i);
painter.widget(child).size()
painter
.widget_at(child, region, region.size(), [true; 2])
.size()
}
None => Size::LEFTOVER,
};
let region = painter.box_of(size);
for (i, child) in self.children.iter().enumerate() {
if sizing == Some(i) {
continue;
}
painter.child_layer_at(i);
painter.widget_aligned(child, region, RegionAlign::NEAR);
// A box that owes nothing to this child's own answer: where it
// sits in one bigger than itself is its own business.
painter.widget_within(child, region);
}
size
}
+12 -2
View File
@@ -55,8 +55,13 @@ impl TextView {
// line up to the one it was made at: each line still fits, and none
// could take a word that did not fit in the wider box. A line too
// long to fit at all says nothing about narrower boxes.
//
// The step at or above that longest line rather than the nearest
// one, since the shaper measures in floats: the nearest step is
// under the line half the time, and a range starting there admits a
// box the line does not fit in, where the break is not this one.
if let Some(width) = width {
painter.holds(Axis::X, Px::from_f32(text.size.x).min(width)..=width);
painter.holds(Axis::X, Px::ceil_from_f32(text.size.x).min(width)..=width);
}
text
}
@@ -78,7 +83,12 @@ impl TextView {
let tex = self.render(painter);
let region = tex.size.align(align);
let size = Size::px(tex.size);
// The step at or above what the shaper measured, so a parent that
// hands back the length this reports hands back a box the longest
// line fits in. Rounded to the nearest step it is half the time a
// hair under that line, and the break made in it is not the break a
// cold layout makes there.
let size = Size::from_px(PxVec2::ceil_from_f32(tex.size));
let within = region.within(&painter.region());
painter.glyphs(tex, within);
(region, size)
+7 -3
View File
@@ -134,9 +134,13 @@ widget_trait! {
|state| self.add(state)
}
fn set_ptr(self, ptr: WeakWidget<WidgetPtr>, state: &mut Rsc) {
let id = self.add_strong(state);
state.ui_mut().widgets[ptr].inner = Some(id);
// Named for the type it makes rather than as `wrapped`, which would read
// as the text setting. `widget_trait!` takes no attributes, so what it is
// for is on `Wrapper` itself.
fn wrapper(self) -> impl WidgetFn<Rsc, Wrapper> {
|state| Wrapper {
inner: Some(self.add_strong(state)),
}
}
}
+12 -4
View File
@@ -1,11 +1,19 @@
use crate::prelude::*;
use std::marker::Unsize;
pub struct WidgetPtr {
/// One widget in a box of its own, doing as little as possible on the way:
/// it draws its child in the whole of its box and reports back what the child
/// said. It exists because a length and an alignment are properties of one
/// widget, so a widget cannot both be 100 wide and take two shares of a row
/// -- the two lengths need two widgets, and this is the smaller one.
///
/// Its child is optional so it can also be the swappable slot a tab bar
/// needs, which is what it was written for.
pub struct Wrapper {
pub inner: Option<StrongWidget>,
}
impl Widget for WidgetPtr {
impl Widget for Wrapper {
fn draw(&mut self, painter: &mut Painter) -> Size {
match &self.inner {
Some(id) => painter.widget(id).size(),
@@ -14,7 +22,7 @@ impl Widget for WidgetPtr {
}
}
impl WidgetPtr {
impl Wrapper {
pub fn new() -> Self {
Self::default()
}
@@ -35,7 +43,7 @@ impl WidgetPtr {
}
}
impl Default for WidgetPtr {
impl Default for Wrapper {
fn default() -> Self {
Self::empty()
}
+254 -11
View File
@@ -20,6 +20,110 @@ fn a_span_gives_each_child_the_width_it_asked_for() {
assert_corners!(h, right, (100, 0), (400, 200));
}
/// A span offers each child the room left after the one before, because a
/// text has to wrap at the width actually there, but reads what the child
/// reports as a fraction of the whole row. So two children asking for half
/// each take the whole row between them, however much of it was left when
/// each was asked, and a third overflows.
#[test]
fn a_span_reads_a_child_report_as_a_fraction_of_the_row() {
let mut h = Harness::new((400, 100));
let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc);
let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut h.rsc);
let nested = (inner,).span(Dir::RIGHT).add(&mut h.rsc);
let tail = rect(Color::BLUE).width(100).add(&mut h.rsc);
h.set_root((half, nested, tail).span(Dir::RIGHT).width(rel(1.0)));
// The nested span is placed at the length it reported and drawn there
// once more; half of that final box is what its own child takes.
assert_corners!(h, nested, (200, 0), (400, 100));
assert_corners!(h, inner, (200, 0), (300, 100));
assert_corners!(h, tail, (400, 0), (500, 100));
}
/// The same fraction either way round: after a 100 px child in a 400 px row,
/// `rel(0.5)` is 100 to 300 whether the child's own rule says so or the child
/// drew half of what it was offered and reported that. Half the row, not half
/// of the 300 px left of it.
#[test]
fn a_reported_fraction_is_of_the_row_like_a_declared_one() {
let mut declaring = Harness::new((400, 100));
let head = rect(Color::RED).width(100).add(&mut declaring.rsc);
let declared = rect(Color::GREEN).width(rel(0.5)).add(&mut declaring.rsc);
declaring.set_root((head, declared).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(declaring, declared, (100, 0), (300, 100));
let mut reporting = Harness::new((400, 100));
let head = rect(Color::RED).width(100).add(&mut reporting.rsc);
let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut reporting.rsc);
let reported = (inner,).span(Dir::RIGHT).add(&mut reporting.rsc);
reporting.set_root((head, reported).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(reporting, reported, (100, 0), (300, 100));
}
/// What the fraction a child reports is of and what box it is offered are
/// two different lengths, and only the first is the whole row: a text still
/// wraps at the room actually left after its neighbour, so the same
/// paragraph is taller where less of the row is left for it.
#[test]
fn a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row() {
let paragraph = "Wrapping shapes one source into as many lines as the box \
leaves room for, so a paragraph's height is an answer.";
let height_after = |head_width: i32| {
let mut h = Harness::new((400, 400));
let head = rect(Color::RED).width(head_width).add(&mut h.rsc);
let text = wtext(paragraph).size(16).wrap(true).add(&mut h.rsc);
h.set_root((head, text).span(Dir::RIGHT).width(rel(1.0)));
let region = h.region(&text).unwrap();
(region.bot_right.y - region.top_left.y).to_f32()
};
let (crowded, whole_row) = (height_after(300), height_after(0));
assert!(crowded > whole_row, "{crowded} against {whole_row}");
}
/// A stack takes its size from one child and gives every child that size, so
/// a child asking for half of it is asking for half of what it is itself the
/// size of. Once the stack has been placed at the length it reported that
/// length is the box, and taking the fraction of it again takes it twice:
/// half a row became a quarter, and a further stack around it a further half.
/// Nothing pinned it because a pixel is the same length wherever it is taken
/// from, so only a share ever shrank -- and warm and cold shrink alike, so no
/// oracle saw it either.
#[test]
fn a_stack_sized_by_a_child_does_not_take_that_childs_fraction_twice() {
let mut h = Harness::new((400, 200));
let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc);
let behind = rect(Color::BLUE).add(&mut h.rsc);
let stack = Stack {
children: vec![behind.add_strong(&mut h.rsc), half.add_strong(&mut h.rsc)],
size: StackSize::Child(1),
}
.add(&mut h.rsc);
h.set_root((stack,).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(h, stack, (0, 0), (200, 200));
assert_corners!(h, half, (0, 0), (200, 200));
assert_corners!(h, behind, (0, 0), (200, 200));
}
/// The same reading through a pad: its inset is the whole box less the
/// padding, so half of the inset plus the padding is half the box plus one
/// padding, not two.
#[test]
fn a_pad_reports_a_fraction_of_its_inset_as_a_fraction_of_its_box() {
let mut h = Harness::new((400, 100));
let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut h.rsc);
let padded = (inner,).span(Dir::RIGHT).pad(10).add(&mut h.rsc);
let tail = rect(Color::BLUE).width(100).add(&mut h.rsc);
// Ruled to the window: a root reporting a fraction of it is otherwise
// placed inside it by its own alignment, which is not what is under test.
h.set_root((padded, tail).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(h, padded, (0, 0), (210, 100));
assert_corners!(h, tail, (210, 0), (310, 100));
}
#[test]
fn a_span_ruled_across_itself_does_not_measure_its_children_there() {
let mut h = Harness::new((400, 200));
@@ -225,21 +329,21 @@ fn only_a_region_node_lengthens_the_chain_and_it_can_be_removed() {
h.set_root((bar, buried).span(Dir::RIGHT));
let move_idx = h.render.active[&leaf.id()].parent_move;
assert_eq!(h.render.moves.depth(move_idx), 1, "only the root region");
assert_eq!(h.render.moves.depth(move_idx), 0, "the window is no entry");
h.rsc.widgets_mut().set_region_node(buried, true);
h.frame();
let move_idx = h.render.active[&leaf.id()].parent_move;
assert_eq!(
h.render.moves.depth(move_idx),
2,
"the opted-in widget's region and the root region"
1,
"the opted-in widget's region alone"
);
h.rsc.widgets_mut().set_region_node(buried, false);
h.frame();
let move_idx = h.render.active[&leaf.id()].parent_move;
assert_eq!(h.render.moves.depth(move_idx), 1);
assert_eq!(h.render.moves.depth(move_idx), 0);
}
/// A span that sizes from its children passes their `leftover` weight up
@@ -266,6 +370,11 @@ fn nested_spans_divide_the_space_once_however_deep_the_nesting_is() {
/// The same space, unevenly nested: weights carried up mean a share is a
/// share of the whole, not of whatever branch a widget happens to sit in.
///
/// Each edge lands on the even division or one step below it, since a share
/// is a fraction of the room and a truncating multiply gives up what that
/// fraction does not divide. What stays exact is that each share starts
/// where the last one ended and the row ends at its own edge.
#[test]
fn an_uneven_nesting_still_gives_every_share_the_same_length() {
let mut h = Harness::new((400, 200));
@@ -279,10 +388,24 @@ fn an_uneven_nesting_still_gives_every_share_the_same_length() {
let three = (b, c, d).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((one, three).span(Dir::RIGHT));
let mut start = Px::ZERO;
for (i, id) in [a, b, c, d].into_iter().enumerate() {
let x = i as f32 * 100.0;
assert_corners!(h, id, (x, 0), (x + 100.0, 200));
let got = h.region(&id).expect("widget drew nothing");
let even = Px::from_int((i as i32 + 1) * 100);
assert_eq!(got.top_left, PxVec2::new(start, Px::ZERO), "share {i}");
assert_eq!(got.bot_right.y, Px::from_int(200), "share {i}");
assert!(
got.bot_right.x == even || got.bot_right.x == even.next_down(),
"share {i} ends at {:?}, not {even:?}",
got.bot_right.x
);
start = got.bot_right.x;
}
assert_eq!(
start,
Px::from_int(400),
"the row stopped short of its edge"
);
}
/// However many ways a row is divided, the shares add up to the row: each
@@ -322,16 +445,15 @@ fn a_row_of_equal_shares_fills_it_exactly() {
}
}
/// Where the shader puts an edge: the two parts of a scalar are floored
/// apart, so a fraction and a pixel offset snap independently, and each is
/// taken to the boundary it composes to within half a step of. Kept in step
/// with `snap_floor` in `prelude.wgsl`.
/// Where the shader puts an edge: the fraction resolved against the window
/// plus the pixel offset, taken to the boundary it composes to within half
/// a step of. Kept in step with `snap_floor` in `prelude.wgsl`.
fn drawn_edges(h: &Harness, id: WidgetId, axis: Axis) -> (f32, f32) {
let active = &h.render.active[&id];
let region = h.render.moves.resolve(active.parent_move, active.region);
let dim = h.size().axis(axis);
let snap = |v: f32| (v + Px::STEP.to_f32() * 0.5).floor();
let edge = |s: Len| snap(s.rel.to_f32() * dim) + snap(s.px.to_f32());
let edge = |s: Len| snap(s.rel.to_f32() * dim + s.px.to_f32());
let span = region.axis(axis);
(edge(span.start), edge(span.end))
}
@@ -480,3 +602,124 @@ fn leftover_children_disappear_at_the_exact_fixed_content_boundary() {
assert!(h.region(&a).is_none());
assert!(h.region(&b).is_none());
}
/// **A stack child smaller than the stack sits where its own alignment
/// says.** `Stack` gives every child the box its sizing child defines and
/// used to force the near edge on all of them; that override is owed only to
/// the sizing child, which has already placed its own content in the box the
/// stack derived from its answer. Every other child is handed a box that owes
/// nothing to it, so where it sits in one bigger than itself is its own
/// business -- and with the override it could not be aligned at all, which is
/// what moved the `tabs` example's counters to the wrong corner.
#[test]
fn a_stack_child_smaller_than_the_stack_keeps_its_own_alignment() {
let mut h = Harness::new((400, 200));
let big = rect(Color::BLUE).add(&mut h.rsc);
let small = rect(Color::RED).sized((50, 50)).add(&mut h.rsc);
h.rsc
.widgets_mut()
.set_alignment(small.id(), Axis::X, AxisAlign::POS);
let (a, b) = (big.add_strong(&mut h.rsc), small.add_strong(&mut h.rsc));
let children: Vec<StrongWidget> = vec![a, b];
h.set_root(Stack {
children,
size: StackSize::Default,
});
assert_corners!(h, big, (0, 0), (400, 200));
// The far edge on X because it asked for it, the middle on Y because
// that is the default.
assert_corners!(h, small, (350, 75), (400, 125));
}
/// Five children of one span, buried under three containers that are each a
/// fraction of their parent so no length reaches the window without being
/// composed and rounded on the way. Returns each child's drawn width and
/// each gap between them, in pixels.
fn row_under_fractions(kid: Option<LayoutLen>, gap: f32, box_w: f32) -> (Vec<Px>, Vec<Px>) {
let mut h = Harness::new((box_w, 400.0));
let mut ids = Vec::new();
let mut kids: Vec<StrongWidget> = Vec::new();
for _ in 0..5 {
let r = rect(Color::RED).add(&mut h.rsc);
if let Some(len) = kid {
h.rsc
.widgets_mut()
.set_size_rule(r.id(), Axis::X, SizeRule::Exact(len));
}
ids.push(r.id());
kids.push(r.add_strong(&mut h.rsc));
}
let span = Span {
children: kids,
dir: Dir::RIGHT,
gap: Px::from_f32(gap),
}
.add(&mut h.rsc);
let a = (span.width(rel(0.9)),).span(Dir::RIGHT).add(&mut h.rsc);
let b = (a.width(rel(0.8)),).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((b.width(rel(0.7)),).span(Dir::RIGHT));
let boxes: Vec<_> = ids
.iter()
.map(|id| h.region(id).expect("a child drew nothing"))
.collect();
(
boxes.iter().map(|b| b.bot_right.x - b.top_left.x).collect(),
boxes
.windows(2)
.map(|p| p[1].top_left.x - p[0].bot_right.x)
.collect(),
)
}
/// **A length given in pixels is that many pixels, wherever it ends up.** A
/// gap and a declared width compose additively -- `Len::within` adds a part's
/// own pixels rather than scaling them, and both ends of a gap carry the same
/// fraction, so the multiply that rounds is the same on each -- which is why
/// nesting the row inside fractions of fractions cannot move them. Swept over
/// 2,100 box widths when this was written and exact at every one; five here,
/// including widths that divide badly by five.
#[test]
fn a_length_in_pixels_is_that_many_pixels_however_it_is_nested() {
for box_w in [300.0, 1000.0, 1001.0, 1003.0, 1920.0] {
let want = Px::from_int(7);
let (_, gaps) = row_under_fractions(None, 7.0, box_w);
assert!(
gaps.iter().all(|g| *g == want),
"box {box_w}: gaps between leftover children are {gaps:?}"
);
let (widths, gaps) = row_under_fractions(Some(LayoutLen::px(100.0)), 7.0, box_w);
assert!(
gaps.iter().all(|g| *g == want),
"box {box_w}: gaps between fixed children are {gaps:?}"
);
assert!(
widths.iter().all(|w| *w == Px::from_int(100)),
"box {box_w}: declared widths came out {widths:?}"
);
}
}
/// **Children asking for the same share of a row are not the same length**,
/// and this pins by how much rather than claiming they are equal. A position
/// is the quantity that gets rounded, so the row fills exactly and no two
/// children leave a seam; what that costs is a step or two between lengths
/// that were asked for identically. Exact composition would shrink the
/// spread, not remove it: five equal lengths cannot fill a row whose step
/// count is not a multiple of five.
#[test]
fn equal_shares_differ_by_at_most_two_steps_and_fill_the_row() {
for kid in [None, Some(LayoutLen::rel(0.2))] {
for box_w in [300.0, 1000.0, 1001.0, 1003.0, 1920.0] {
let (widths, gaps) = row_under_fractions(kid, 0.0, box_w);
let spread = *widths.iter().max().unwrap() - *widths.iter().min().unwrap();
assert!(
spread <= Px::from_raw(2),
"box {box_w}, {kid:?}: widths {widths:?} spread {spread:?}"
);
assert!(
gaps.iter().all(|g| *g == Px::ZERO),
"box {box_w}, {kid:?}: children left seams {gaps:?}"
);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
//! The tree a seed describes, as a value rather than as widgets.
//!
//! Two things have to hold for a plan to be worth having. Editing a plan has
//! to mean what growing with those edits means, or a scenario reads one thing
//! and the oracle another. And reducing a plan has to end, or a shrinker
//! searching for the smallest counterexample never returns.
use iris::random::{Edits, Kind, Plan, Rng, SpanEdit, plan};
use std::collections::HashMap;
fn some_edits(seed: u64, of: &Plan) -> Edits {
let mut rng = Rng::new(seed);
let (mut sized, mut aligned, mut nodes, mut spans) = (0, 0, 0, 0);
let mut of = of.clone();
of.walk_mut(&mut |p| {
if matches!(p.kind, Kind::Span { .. }) {
spans += 1;
}
sized += p.size.is_some() as usize;
aligned += p.align.is_some() as usize;
nodes += p.region_node.is_some() as usize;
});
let pick =
|n: usize, rng: &mut Rng| -> Vec<usize> { (0..n).filter(|_| rng.chance()).collect() };
Edits {
sizes: pick(sized, &mut rng)
.into_iter()
.map(|i| (i, [Some(LayoutLen::LEFTOVER), None]))
.collect(),
aligns: pick(aligned, &mut rng)
.into_iter()
.map(|i| (i, [Some(AxisAlign::POS), None]))
.collect(),
nodes: pick(nodes, &mut rng)
.into_iter()
.map(|i| (i, true))
.collect(),
spans: pick(spans, &mut rng)
.into_iter()
.map(|i| {
(
i,
SpanEdit {
detach: vec![0],
attach: 2,
},
)
})
.collect::<HashMap<_, _>>(),
fixed_branches: false,
}
}
use iris::prelude::*;
/// The two routes to an edited tree are one tree. `plan` resolves edits out
/// of the random stream as it draws; `edited` puts them on a tree that
/// already exists, which is the only route a shrunk plan has, since no seed
/// grows one. A scenario written against either has to read the same.
#[test]
fn editing_a_plan_is_growing_one_with_those_edits() {
for seed in 1..=60 {
let bare = plan(seed, 5, &Edits::default());
let edits = some_edits(seed, &bare);
assert_eq!(
bare.edited(&edits),
plan(seed, 5, &edits),
"seed {seed}: edited and grown-with-edits disagree"
);
}
}
/// Every simplification is strictly smaller, so taking them in turn reaches a
/// fixed point instead of circling. A shrinker that can return to a tree it
/// has already tried does not stop.
#[test]
fn every_simplification_of_a_plan_is_smaller_than_it() {
for seed in 1..=60 {
let tree = plan(seed, 4, &Edits::default());
let mut queue = vec![tree];
let mut seen = 0;
while let Some(node) = queue.pop() {
seen += 1;
if seen > 400 {
break;
}
for small in node.smaller() {
assert!(
small.size() <= node.size(),
"seed {seed}: a simplification grew from {} to {}",
node.size(),
small.size()
);
if small.size() < node.size() {
queue.push(small);
}
}
}
}
}
/// Reducing until nothing reduces ends, and ends at something small enough to
/// read rather than at the tree it started from.
#[test]
fn reducing_a_plan_all_the_way_ends() {
for seed in 1..=30 {
let mut node = plan(seed, 5, &Edits::default());
let grown = node.size();
let mut steps = 0;
while let Some(next) = node.smaller().into_iter().next() {
node = next;
steps += 1;
assert!(steps < 10_000, "seed {seed}: reducing did not end");
}
assert!(
node.size() < grown.max(2),
"seed {seed}: reduced {grown} widgets to {}",
node.size()
);
}
}
+15
View File
@@ -613,3 +613,18 @@ fn a_stacks_sizing_child_is_drawn_once_where_it_belongs() {
assert_ne!(layer(front.id()), layer(background.id()));
assert_eq!(draws.get(), 1);
}
/// A widget's own mask is not the one it inherited, and a redraw of it
/// inherits the second: handing back the first is handing it its own mask to
/// set a second time, which `set_mask` asserts against.
#[test]
fn a_masked_widget_redrawn_on_its_own_sets_its_mask_again() {
let mut h = Harness::new((400, 200));
let inner = rect(Color::BLUE).add(&mut h.rsc);
let masked = inner.masked().add(&mut h.rsc);
let other = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((other, masked).span(Dir::RIGHT));
h.rsc.widgets_mut().get_dyn_mut(masked.id());
h.frame();
assert_corners!(h, inner, (100, 0), (400, 200));
}
+219 -3
View File
@@ -3,12 +3,16 @@
//! frame that had not settled: a wrapping text shaped at a width it was
//! measured in rather than the one it was given. The rest are a widget
//! measured again in a box its own answer had decided, where the old answer
//! is a fixed point whatever the content now says. The last is neither: one
//! box length, composed two ways, landing either side of the boundary that
//! decided whether a child was drawn at all.
//! is a fixed point whatever the content now says. The last three are
//! neither: one box length, composed two ways, landing either side of the
//! boundary that decided whether a child was drawn at all, and two boxes
//! reached through a region node's own entry rather than through the offer
//! that node was given. The last is a wrapping text handed back the width
//! it measured, rounded to a step below the line it measured there.
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::Branch;
/// Six widgets, shrunk from a 402-widget tree the fuzzer found. Nothing about
/// the tree changes -- every widget is marked for redraw and the frame is
@@ -398,3 +402,215 @@ fn a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over() {
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
/// Five widgets, shrunk by `tests/shrink.rs` from the 277 the oracle's seed
/// 18 grows at depth 6. A scroll inside a scroll, the inner one owning a
/// movable region of its own, and only its text marked for redraw. Nothing
/// about the tree changes, so no box may.
fn plant_nested_scrolls(h: &mut Harness) -> Vec<WidgetId> {
let text = wtext("one line, overflowing whatever it is given")
.size(16)
.wrap(false)
.add(&mut h.rsc);
let inner = Scroll::new(text.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
h.rsc.widgets_mut().set_region_node(inner.id(), true);
let filler = rect(Color::RED).add(&mut h.rsc);
h.rsc.widgets_mut().set_size_rules(
filler.id(),
Some(LayoutLen::px(87.0)),
Some(LayoutLen::px(24.0)),
);
let span = Span {
children: vec![inner.add_strong(&mut h.rsc), filler.add_strong(&mut h.rsc)],
dir: Dir::DOWN,
gap: Px::ZERO,
}
.add(&mut h.rsc);
let root = Scroll::new(span.add_strong(&mut h.rsc), Axis::Y).add(&mut h.rsc);
h.set_root(root);
vec![text.id(), inner.id(), filler.id(), span.id(), root.id()]
}
/// A local redraw asks a dirty widget in the box its parent gave it, and only
/// where that box is as long as the one it was offered; anything else is a
/// question its parent has to ask. This inner scroll's offer is the outer
/// scroll's whole viewport and the box it was given is 24px shorter -- the
/// height of the sized child the outer scroll snaps to the end of -- so what
/// it must not do is settle itself. It was drawn at its offer once, and the
/// inner scroll and its text stayed 24px too low.
#[test]
fn redrawing_one_widget_does_not_move_what_scrolls_around_it() {
let mut warm = Harness::new((900, 1200));
let ids = plant_nested_scrolls(&mut warm);
warm.rsc.widgets_mut().get_dyn_mut(ids[0]);
warm.frame();
let mut cold = Harness::new((900, 1200));
let cold_ids = plant_nested_scrolls(&mut cold);
let mut wrong = Vec::new();
for (i, (&w, &c)) in ids.iter().zip(&cold_ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
if got != want {
wrong.push(format!("widget {i}: warm {got:?} cold {want:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
/// Ten widgets, of the shape `tests/shrink.rs` reduces the oracle's seed 220
/// to. The pad owns a movable region and is the scroll's content, so the box
/// the scroll places it in is as long as that content while the box it was
/// offered is the viewport -- and with no padding to tell those two apart,
/// the span inside it looked like it was still at its offer. So everything
/// under the pad was asked again in the *placed* box, the offer resolving
/// against the node's own entry, which holds that box: the texts kept the
/// widths they had, the content stayed the length those widths make, and the
/// old answer confirmed itself. What the branch adds is a tree that differs
/// rather than a box that moved, since a probe measured at the wrong width
/// takes the other side.
fn plant_under_a_node(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget<Span>; 2]) {
let probe = rect(Color::RED).add(&mut h.rsc);
let wide = rect(Color::GREEN).add(&mut h.rsc);
let narrow = rect(Color::BLUE).add(&mut h.rsc);
let branch = Branch {
probe: probe.add_strong(&mut h.rsc),
wide: wide.add_strong(&mut h.rsc),
narrow: narrow.add_strong(&mut h.rsc),
threshold: 213.0,
}
.add(&mut h.rsc);
let wrapped = wtext(
"Wrapping shapes one source into as many lines as the box \
leaves room for, so a paragraph's height is an answer and not a setting.",
)
.size(16)
.wrap(true)
.add(&mut h.rsc);
let plain = wtext("one line, overflowing whatever it is given")
.size(16)
.wrap(false)
.add(&mut h.rsc);
let row = |h: &mut Harness, mut children: Vec<StrongWidget>| {
if swapped {
children.rotate_left(1);
}
Span {
children,
dir: Dir::RIGHT,
gap: Px::ZERO,
}
.add(&mut h.rsc)
};
let texts: Vec<StrongWidget> =
vec![wrapped.add_strong(&mut h.rsc), plain.add_strong(&mut h.rsc)];
let inner = row(h, texts);
let pair: Vec<StrongWidget> = vec![branch.add_strong(&mut h.rsc), inner.add_strong(&mut h.rsc)];
let outer = row(h, pair);
let pad = Pad {
padding: Padding::ZERO,
inner: outer.add_strong(&mut h.rsc),
}
.add(&mut h.rsc);
h.rsc.widgets_mut().set_region_node(pad.id(), true);
let root = Scroll::new(pad.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
h.set_root(root);
(
vec![
probe.id(),
wide.id(),
narrow.id(),
branch.id(),
wrapped.id(),
plain.id(),
inner.id(),
outer.id(),
pad.id(),
root.id(),
],
[outer, inner],
)
}
#[test]
fn a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered() {
let mut warm = Harness::new((900, 1200));
let (ids, spans) = plant_under_a_node(&mut warm, false);
warm.frame();
for span in spans {
warm.rsc[span].children.rotate_left(1);
}
warm.frame();
let mut cold = Harness::new((900, 1200));
let (cold_ids, _) = plant_under_a_node(&mut cold, true);
cold.frame();
let mut wrong = Vec::new();
for (i, (&w, &c)) in ids.iter().zip(&cold_ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
if got != want {
wrong.push(format!("widget {i}: warm {got:?} cold {want:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
const PARAGRAPH: &str = "Wrapping shapes one source into as many lines as the \
box leaves room for, so a paragraph's height is an answer and not a setting.";
/// Eight widgets, shrunk from a 118-widget tree (seed 1121, depth 4,
/// `shuffle-swap-for-three`). The stack takes its size from the span above,
/// the span takes its width from the longest line of the texts in it, and
/// the text below the span is then wrapped at that width -- so a width the
/// shaper measured comes back to it as the box to break in.
fn plant_a_measured_width(h: &mut Harness, swapped: bool) -> (WeakWidget<Span>, WidgetId) {
let first: StrongWidget = rect(Color::YELLOW).add_strong(&mut h.rsc);
let mut inner = Span::empty(Dir::UP);
inner.children = match swapped {
true => swapped_in(h),
false => vec![first],
};
let inner = inner.height(142).add(&mut h.rsc);
let text = wtext(PARAGRAPH).size(16).wrap(true).add(&mut h.rsc);
let stack = Stack {
children: vec![inner.add_strong(&mut h.rsc), text.add_strong(&mut h.rsc)],
size: StackSize::Child(0),
}
.add(&mut h.rsc);
h.set_root((stack,).span(Dir::DOWN).width(195));
(inner, text.id())
}
/// What the span holds once its children have been swapped, which is what
/// the warm tree is changed to and what the cold one is grown with.
fn swapped_in(h: &mut Harness) -> Vec<StrongWidget> {
let paragraph = |h: &mut Harness| -> StrongWidget {
wtext(PARAGRAPH).size(16).wrap(true).add_strong(&mut h.rsc)
};
vec![
paragraph(h),
rect(Color::YELLOW).add_strong(&mut h.rsc),
paragraph(h),
]
}
/// A text handed back the width it measured breaks there the way it broke
/// when it measured it. The width the shaper answers is not on the grid, and
/// a report rounded to the nearest step is under the longest line half the
/// time: a warm tree then keeps a break made in a wider box while a cold one
/// makes a narrower break in the same box, and the paragraph gains a line.
#[test]
fn a_text_is_given_back_a_box_the_line_it_measured_fits_in() {
let mut warm = Harness::new((900, 1200));
let (inner, text) = plant_a_measured_width(&mut warm, false);
warm.frame();
warm.rsc[inner].children = swapped_in(&mut warm);
warm.frame();
let mut cold = Harness::new((900, 1200));
let (_, cold_text) = plant_a_measured_width(&mut cold, true);
cold.frame();
assert_eq!(warm.region(&text), cold.region(&cold_text));
}
+90 -548
View File
@@ -1,19 +1,21 @@
//! Random trees, checked against building the same tree cold.
//! Laying a tree out again has to land where growing it that way would.
//!
//! A frame reaches its layout by keeping most of the last one: movable regions
//! or primitive boxes rewritten, some widgets drawn again, the rest untouched.
//! The result must be the tree a cold start would have produced, so anything
//! wrongly retained shows up as a difference in somebody's box.
//! Every case is one of `scenario`'s, over the trees `iris::random` grows
//! from a seed. The fast test takes a handful of seeds and the ignored one
//! takes as many as it is asked for; both run the same cases the shrinker
//! does over the same trees, so a seed that fails here is reduced by
//!
//! `iris::random` grows the tree and `examples/random.rs` draws one. A seed is
//! the whole reproduction; `a_long_run_of_seeds_agrees` is the ignored sweep
//! for when it is worth spending the time.
//! SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case> \
//! cargo test --release --test shrink -- --ignored --nocapture
//!
//! `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS` and `IRIS_GENERATED_DEPTH`
//! select what the long run covers.
use std::collections::HashMap;
#[path = "scenario/mod.rs"]
mod scenario;
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Aligns, Edits, Lens, Rng, SpanEdit, Tree, grow};
use iris::random::{Edits, plan};
use scenario::{ALL, Case, diverges, env, over_seeds};
/// How deep the generator branches. The generator widens two to four ways per
/// level, so depth is exponential in width and a deep narrow tree is not
@@ -23,562 +25,102 @@ fn depth() -> usize {
env("IRIS_GENERATED_DEPTH", 4)
}
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(fallback)
}
const SEEDS: [u64; 9] = [1, 2, 3, 5, 8, 10, 13, 86, 98];
/// The seeds the ordinary tests take. Seven that have never failed; 86,
/// which a `Scroll` fixed point once settled differently on; and 20, which
/// caught a locally redrawn widget being placed twice in the box its parent
/// had already placed it in.
const SEEDS: [u64; 10] = [1, 2, 3, 5, 8, 10, 13, 20, 86, 98];
/// The same box, to a step of the grid per level of nesting between the two
/// ways of reaching it. A move, a repaint and a row of shares land on the
/// same number now; what is left is a box centred in a fraction of its parent
/// against the same box centred in its own pixels. A step is a thousandth of
/// a pixel, where this was a twentieth of one before any of it was on a grid.
const AGREE_STEPS: i32 = 2;
fn same_region(got: Option<PixelRegion>, want: Option<PixelRegion>) -> bool {
match (got, want) {
(Some(got), Some(want)) => {
let same = |a: Px, b: Px| (a - b).abs() <= Px::STEP.mul_int(AGREE_STEPS);
same(got.top_left.x, want.top_left.x)
&& same(got.top_left.y, want.top_left.y)
&& same(got.bot_right.x, want.bot_right.x)
&& same(got.bot_right.y, want.bot_right.y)
}
(None, None) => true,
_ => false,
}
}
fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree {
let (root, tree) = grow(&mut h.rsc, seed, depth(), edits);
h.state.root = Some(root);
h.frame();
tree
}
fn resize_one(h: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Lens {
let lens = [
Some(LayoutLen::px(20.0 + rng.below(180) as f32)),
Some(LayoutLen::px(20.0 + rng.below(180) as f32)),
];
h.rsc
.widgets_mut()
.set_size_rules(tree.sized[idx], lens[0], lens[1]);
lens
}
/// Changes a few of the declared sizes, and says which, so the cold tree can
/// be grown with the same ones.
fn edit(h: &mut Harness, tree: &Tree, rng: &mut Rng) -> HashMap<usize, Lens> {
let mut edits = HashMap::new();
for _ in 0..4 {
let idx = rng.below(tree.sized.len());
edits.insert(idx, resize_one(h, tree, idx, rng));
}
edits
}
/// Every declared size at once, so every reader of a size in the tree has a
/// changed descendant in the same frame and the whole dirty set has to settle
/// together.
fn edit_every(h: &mut Harness, tree: &Tree, rng: &mut Rng) -> HashMap<usize, Lens> {
(0..tree.sized.len())
.map(|idx| (idx, resize_one(h, tree, idx, rng)))
.collect()
}
/// A way of changing what a span holds. Each is a shape worth its own case:
/// taking a child out of the middle is not the same as emptying a span, and
/// adding one is not the same as adding three.
#[derive(Clone, Copy, Debug)]
enum Shuffle {
/// Every other child, so what is left is interleaved with what went.
EveryOther,
/// Everything but the first, which is the last step before empty.
AllButFirst,
/// Three more on the end at once.
AddThree,
/// The first out and three more on, so the count moves both ways.
SwapForThree,
/// One out of the middle and one on the end.
TradeOne,
}
const SHUFFLES: [Shuffle; 5] = [
Shuffle::EveryOther,
Shuffle::AllButFirst,
Shuffle::AddThree,
Shuffle::SwapForThree,
Shuffle::TradeOne,
];
impl Shuffle {
fn of(self, grown: usize) -> SpanEdit {
let all = |step: usize, from: usize| (from..grown).step_by(step).collect();
match self {
Self::EveryOther => SpanEdit {
detach: all(2, 0),
attach: 0,
},
Self::AllButFirst => SpanEdit {
detach: all(1, 1),
attach: 0,
},
Self::AddThree => SpanEdit {
detach: Vec::new(),
attach: 3,
},
Self::SwapForThree => SpanEdit {
detach: vec![0],
attach: 3,
},
Self::TradeOne => SpanEdit {
detach: vec![grown / 2],
attach: 1,
},
}
}
}
/// Applies `shuffle` to every third span, and says what it did so the cold
/// tree can be grown that way. The widgets it takes out are given back: the
/// last share of one must outlive the comparison, or its id is handed to
/// something else and the two trees stop lining up.
fn reshuffle(
h: &mut Harness,
tree: &mut Tree,
shuffle: Shuffle,
) -> (HashMap<usize, SpanEdit>, Vec<StrongWidget>) {
let mut edits = HashMap::new();
let mut detached = Vec::new();
for (idx, span) in tree.spans.iter_mut().enumerate().step_by(3) {
let span_edit = shuffle.of(span.grown);
let mut take = span_edit.detach.clone();
take.sort_unstable();
let children = &mut h.rsc[span.id].children;
// Highest first, so an index means the same child however many of
// its neighbours are going too.
for j in take.into_iter().rev() {
if j < children.len() {
detached.push(children.remove(j));
}
}
let attach = span_edit.attach.min(span.spares.len());
children.extend(span.spares.drain(..attach));
edits.insert(idx, span_edit);
}
(edits, detached)
}
/// What a widget was configured with, so a tree the generator found can be
/// written out by hand. A fuzz failure is a lead; the fast test that replaces
/// it has to be buildable from what the failure printed.
fn describe(id: WidgetId, h: &Harness) -> String {
let rules = h.rsc.widgets().size_rules(id);
let rule = |r: SizeRule| match r.exact() {
Some(len) => format!("{len}"),
None => "-".into(),
};
let align = h.rsc.widgets().alignment(id);
let side = |a: AxisAlign| {
if a == AxisAlign::NEG {
"neg".into()
} else if a == AxisAlign::CENTER {
"mid".into()
} else if a == AxisAlign::POS {
"pos".into()
} else {
format!("{:.2}", a.rel())
}
};
// A rule and an alignment are properties of whatever carries them, so
// they print with that widget rather than as widgets of their own.
let mut out = describe_widget(id, h);
if (rules.x, rules.y) != (SizeRule::Free, SizeRule::Free) {
out += &format!("[x:{},y:{}]", rule(rules.x), rule(rules.y));
}
if align != RegionAlign::default() {
out += &format!("@{},{}", side(align.x), side(align.y));
}
out
}
fn describe_widget(id: WidgetId, h: &Harness) -> String {
let label = h.rsc.widgets().label(id).to_string();
let Some(widget) = h.rsc.widgets().get_dyn(id) else {
return label;
};
let any: &dyn std::any::Any = widget;
if let Some(w) = any.downcast_ref::<Span>() {
let sign = if w.dir.sign == Sign::Neg { "-" } else { "+" };
return format!(
"Span{{dir:{:?}{sign},gap:{},n:{}}}",
w.dir.axis,
w.gap,
w.children.len()
fn check(seed: u64, depth: usize, case: Case) {
let grown = plan(seed, depth, &Edits::default());
if let Some(how) = diverges(&grown, case, seed) {
panic!(
"seed {seed} at depth {depth} differs after {}: {how}\n\
reduce it with SHRINK_SEED={seed} SHRINK_DEPTH={depth} \
SHRINK_CASE={} cargo test --release --test shrink -- --ignored --nocapture",
case.name(),
case.name(),
);
}
if let Some(w) = any.downcast_ref::<Pad>() {
let p = &w.padding;
return format!(
"Pad{{l:{},r:{},t:{},b:{}}}",
p.left, p.right, p.top, p.bottom
);
}
if let Some(w) = any.downcast_ref::<Stack>() {
return format!("Stack{{n:{}}}", w.children.len());
}
label
}
/// Every widget in one tree against the matching widget in the other. A
/// mismatch prints the widget's ancestry, marking region nodes, since where
/// two trees disagree is rarely where the cause is.
fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness, &Tree)) {
let ((wh, wt), (ch, ct)) = (warm, cold);
assert_eq!(wt.ids.len(), ct.ids.len(), "seed {seed}: different trees");
let mut drawn = 0;
let mut wrong = 0;
for (i, (&w, &c)) in wt.ids.iter().zip(&ct.ids).enumerate() {
let (got, want) = (wh.region(&w), ch.region(&c));
drawn += usize::from(got.is_some());
// This oracle cares where rasterization lands, not whether equivalent
// arithmetic produced the same f32. Keep the tolerance to one
// twentieth of a physical pixel, while whether a widget drew remains
// exact.
if same_region(got, want) {
continue;
}
wrong += 1;
if wrong <= 3 {
let mut chain = Vec::new();
let mut at = Some(w);
while let Some(id) = at {
let active = &wh.render.active[&id];
let node = match active.move_idx == active.parent_move {
true => "",
false => "*",
};
chain.push(format!("{}{node}", describe(id, wh)));
at = active.parent;
macro_rules! case {
($name:ident, $case:expr) => {
#[test]
fn $name() {
for seed in SEEDS {
check(seed, depth(), $case);
}
println!(
"seed {seed} after {what}: widget {i}\n warm {got:?}\n cold {want:?}\n {}",
chain.join(" < ")
);
}
}
assert!(drawn > 0, "seed {seed}: nothing was drawn");
assert_eq!(wrong, 0, "seed {seed}: {wrong} widgets differ after {what}");
}
fn changed_size(seed: u64) {
let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
// Not every tree grows a declared size to change.
if grown.sized.is_empty() {
return;
}
let mut rng = Rng::new(seed ^ 0x5eed);
let sizes = edit(&mut warm, &grown, &mut rng);
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
sizes,
..Default::default()
},
);
assert_same(seed, "a size change", (&warm, &grown), (&cold, &same));
}
/// Moves one widget to a different corner of the box it is given.
fn realign_one(h: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Aligns {
let mut side = || match rng.below(4) {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
};
let aligns = [side(), side()];
for (axis, align) in [Axis::X, Axis::Y].into_iter().zip(aligns) {
h.rsc
.widgets_mut()
.set_alignment(tree.aligned[idx], axis, align.unwrap_or_default());
}
aligns
}
fn changed_alignment(seed: u64) {
let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
if grown.aligned.is_empty() {
return;
}
let mut rng = Rng::new(seed ^ 0xa11);
let aligns = (0..grown.aligned.len())
.step_by(3)
.map(|idx| (idx, realign_one(&mut warm, &grown, idx, &mut rng)))
.collect();
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
aligns,
..Default::default()
},
);
assert_same(seed, "an alignment change", (&warm, &grown), (&cold, &same));
}
/// Giving a widget a movable region of its own, or taking it away, is a
/// structural change: every primitive under it changes which chain resolves
/// it. A cold tree built that way is what says the rebuild was complete.
fn changed_region_node(seed: u64) {
let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
if grown.nodes.is_empty() {
return;
}
let nodes: HashMap<usize, bool> = (0..grown.nodes.len())
.step_by(2)
.map(|idx| {
let id = grown.nodes[idx];
let was = warm.rsc.widgets().is_region_node(id);
warm.rsc.widgets_mut().set_region_node(id, !was);
(idx, !was)
})
.collect();
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
nodes,
..Default::default()
},
);
assert_same(
seed,
"a region-node change",
(&warm, &grown),
(&cold, &same),
);
}
fn reshuffled(seed: u64, shuffle: Shuffle) {
let mut warm = Harness::new((900, 1200));
let mut grown = plant(&mut warm, seed, &Edits::default());
// Some seeds grow nothing but wrappers, and a shuffle with no span to
// shuffle is not the same thing as one that had no effect. A span behind
// a branch nobody took is the same kind of nothing: it is not drawn, so
// shuffling it cannot move anything.
let shuffles = grown
.spans
.iter()
.step_by(3)
.any(|span| warm.region(&span.id.id()).is_some());
if !shuffles {
return;
}
let (spans, _held) = reshuffle(&mut warm, &mut grown, shuffle);
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
spans,
..Default::default()
},
);
let what = format!("{shuffle:?}");
assert_same(seed, &what, (&warm, &grown), (&cold, &same));
}
fn changed_every_size(seed: u64) {
let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
if grown.sized.is_empty() {
return;
}
let mut rng = Rng::new(seed ^ 0xa11);
let sizes = edit_every(&mut warm, &grown, &mut rng);
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
sizes,
..Default::default()
},
);
assert_same(seed, "every size at once", (&warm, &grown), (&cold, &same));
}
/// Marks a spread of widgets for redraw at once. Nothing changes, so no box
/// may either; what this exercises is the order a frame settles a dirty set
/// in, which the other cases reach one dependency path at a time.
fn repainted_together(seed: u64) {
let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
for &id in grown.ids.iter().step_by(5) {
warm.rsc.widgets_mut().get_dyn_mut(id);
}
assert!(
!warm.rsc.widgets().needs_redraw.is_empty(),
"seed {seed}: nothing was marked"
);
warm.frame();
let mut cold = Harness::new((900, 1200));
let same = plant(&mut cold, seed, &Edits::default());
let what = "many repaints at once";
assert_same(seed, what, (&warm, &grown), (&cold, &same));
}
fn resized(seed: u64) {
let mut warm = Harness::new((1920, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
warm.resize((640, 900));
warm.frame();
let mut cold = Harness::new((640, 900));
let same = plant(&mut cold, seed, &Edits::default());
assert_same(seed, "a resize", (&warm, &grown), (&cold, &same));
}
fn resized_then_changed(seed: u64) {
let mut warm = Harness::new((1920, 1200));
let grown = plant(&mut warm, seed, &Edits::default());
if grown.sized.is_empty() {
return;
}
warm.resize((640, 900));
warm.frame();
let mut rng = Rng::new(seed ^ 0xb0a7);
let sizes = edit(&mut warm, &grown, &mut rng);
warm.frame();
let mut cold = Harness::new((640, 900));
let same = plant(
&mut cold,
seed,
&Edits {
sizes,
..Default::default()
},
);
let what = "a resize then a size change";
assert_same(seed, what, (&warm, &grown), (&cold, &same));
}
#[test]
fn a_changed_size_lands_where_growing_it_that_way_would() {
SEEDS.into_iter().for_each(changed_size);
}
#[test]
fn a_changed_alignment_lands_where_growing_it_that_way_would() {
SEEDS.into_iter().for_each(changed_alignment);
}
#[test]
fn a_toggled_region_node_lands_where_growing_it_that_way_would() {
SEEDS.into_iter().for_each(changed_region_node);
}
#[test]
fn every_size_changing_at_once_lands_where_growing_it_that_way_would() {
SEEDS.into_iter().for_each(changed_every_size);
}
#[test]
fn many_widgets_redrawing_at_once_leaves_every_box_where_it_was() {
SEEDS.into_iter().for_each(repainted_together);
}
#[test]
fn a_resize_lands_where_starting_at_that_size_would() {
SEEDS.into_iter().for_each(resized);
}
#[test]
fn a_size_change_after_a_resize_lands_the_same_way() {
SEEDS.into_iter().for_each(resized_then_changed);
}
case!(
many_widgets_redrawing_at_once_leaves_every_box_where_it_was,
Case::RepaintSome
);
case!(
everything_redrawing_at_once_leaves_every_box_where_it_was,
Case::Repaint
);
case!(
a_resize_lands_where_starting_at_that_size_would,
Case::Resize
);
case!(
a_resize_and_a_repaint_land_where_starting_that_way_would,
Case::ResizeRepaint
);
case!(
a_size_change_after_a_resize_lands_the_same_way,
Case::ResizeSize
);
case!(
a_size_change_lands_where_growing_it_that_way_would,
Case::Size
);
case!(
every_size_changing_at_once_lands_where_growing_it_that_way_would,
Case::EverySize
);
case!(
an_alignment_change_lands_where_growing_it_that_way_would,
Case::Align
);
case!(
giving_and_taking_a_movable_region_rebuilds_what_resolves_it,
Case::RegionNode
);
case!(
reordering_a_span_lands_where_growing_it_that_way_would,
Case::Reorder
);
#[test]
fn adding_and_removing_span_children_lands_where_growing_it_that_way_would() {
for shuffle in SHUFFLES {
for seed in SEEDS {
reshuffled(seed, shuffle);
for case in ALL {
if matches!(case, Case::Shuffle(_)) {
for seed in SEEDS {
check(seed, depth(), case);
}
}
}
}
/// The same property over a hundred seeds and every scenario. What it has
/// found so far was never where the trees disagreed: a text measured in a box
/// it was not going to get, and a widget re-measured in a box its own answer
/// had decided. `tests/shrink.rs` is how a seed from here becomes a tree
/// small enough to read.
#[test]
#[ignore = "a hundred seeds, rather than the nine the others check"]
#[ignore = "as many seeds as it is asked for, rather than the nine the others check"]
fn a_long_run_of_seeds_agrees() {
let seeds = std::env::var("IRIS_GENERATED_SEED")
let depth = depth();
let seeds: Vec<u64> = match std::env::var("IRIS_GENERATED_SEED")
.ok()
.and_then(|seed| seed.parse().ok())
.map(|seed| seed..=seed)
.unwrap_or_else(|| 1..=env("IRIS_GENERATED_SEEDS", 100));
over_seeds(seeds.collect(), |seed| {
changed_size(seed);
changed_every_size(seed);
repainted_together(seed);
resized(seed);
resized_then_changed(seed);
for shuffle in SHUFFLES {
reshuffled(seed, shuffle);
}
});
}
/// Every seed on its own thread's share of them. A tree is grown, laid out
/// and dropped inside one call, so seeds share nothing, and this is most of
/// the time a run takes. A thread that fails takes the scope down with it,
/// which is the same panic libtest would have seen.
///
/// One core short of all of them, so the machine this runs on stays usable.
pub fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
.and_then(|v| v.parse().ok())
{
Some(seed) => vec![seed],
None => (1..=env("IRIS_GENERATED_SEEDS", 100_u64)).collect(),
};
over_seeds(seeds, |seed| {
for case in ALL {
check(seed, depth, case);
}
});
}
+11 -2
View File
@@ -92,9 +92,18 @@ fn trace_selected(tree: &Tree) {
#[cfg(not(feature = "layout-diagnostics"))]
fn trace_selected(_: &Tree) {}
/// The shape a cost is measured on must not depend on what layout measured,
/// or two commits are compared on two different trees. See `Edits`.
fn rig_edits() -> Edits {
Edits {
fixed_branches: true,
..Default::default()
}
}
fn warm(seed: u64, depth: usize) -> (Harness, Tree) {
let mut harness = Harness::new(OUTPUT);
let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default());
let (root, tree) = grow(&mut harness.rsc, seed, depth, &rig_edits());
harness.state.root = Some(root);
harness.frame();
println!(
@@ -173,7 +182,7 @@ fn layout_cost() {
if selected("cold") {
let mut harness = Harness::new(OUTPUT);
let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default());
let (root, tree) = grow(&mut harness.rsc, seed, depth, &rig_edits());
harness.state.root = Some(root);
println!(
"fixture: seed {seed}, depth {depth}, {} widgets",
+467
View File
@@ -0,0 +1,467 @@
//! The scenarios both fuzzers run, over the tree a [`Plan`] describes.
//!
//! One implementation rather than two. The oracle grew its trees from a seed
//! and the shrinker grew its own, with every scenario written out on each
//! side, so a failure the oracle found could not be handed to the shrinker:
//! there was no tree to pass it, only a seed, and a seed cannot be made
//! smaller. Both take a plan now, so whatever finds a counterexample can also
//! reduce it.
//!
//! Each target compiles this for itself, so what only one of them calls is
//! dead code in the other.
#![allow(dead_code)]
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Aligns, Edits, Kind, Lens, Plan, Rng, SpanEdit, Tree, build};
use std::collections::HashMap;
/// A seed per thread but one, since a seed grows, lays out and drops its tree
/// alone. A failing seed still shrinks and panics on its own thread.
pub fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
}
});
}
pub fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(fallback)
}
/// The window a tree is grown in, and the one a resize takes it to.
const OUTER: (f32, f32) = (1920.0, 1200.0);
const INNER: (f32, f32) = (640.0, 900.0);
const STILL: (f32, f32) = (900.0, 1200.0);
/// The same box, to two steps of the grid between the two ways of reaching
/// it. A move, a repaint, a row of shares and every length in pixels land on
/// the same number. What needs the slack is a position: a box centred in a
/// fraction of its parent against the same box centred in its own pixels,
/// and a box re-expressed as a fraction of a parent that changed length.
/// A step is a thousandth of a pixel, where this was a twentieth of one
/// before any of it was on a grid.
///
/// **One step is not enough**, tried 2026-09-17 once a length in pixels
/// stopped being composed: it passes the 100-seed oracle and fails the
/// 400-seed shrinker on `resize-size`, seeds 384 and 162, by 0.002 px. So
/// what is left here is the resize path's own rounding rather than a length
/// reached two ways.
const AGREE_STEPS: i32 = 2;
/// A way of changing what a span holds. Each is a shape worth its own case:
/// taking a child out of the middle is not the same as emptying a span, and
/// adding one is not the same as adding three.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Shuffle {
/// Every other child, so what is left is interleaved with what went.
EveryOther,
/// Everything but the first, which is the last step before empty.
AllButFirst,
/// Three more on the end at once.
AddThree,
/// The first out and three more on, so the count moves both ways.
SwapForThree,
/// One out of the middle and one on the end.
TradeOne,
}
impl Shuffle {
fn of(self, grown: usize) -> SpanEdit {
let all = |step: usize, from: usize| (from..grown).step_by(step).collect();
match self {
Self::EveryOther => SpanEdit {
detach: all(2, 0),
attach: 0,
},
Self::AllButFirst => SpanEdit {
detach: all(1, 1),
attach: 0,
},
Self::AddThree => SpanEdit {
detach: Vec::new(),
attach: 3,
},
Self::SwapForThree => SpanEdit {
detach: vec![0],
attach: 3,
},
Self::TradeOne => SpanEdit {
detach: vec![grown / 2],
attach: 1,
},
}
}
}
/// What a warm tree is put through before it is compared with a cold one
/// grown the way it was left.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Case {
/// Nothing changes, so no box may either. What this exercises is the
/// order a frame settles a dirty set in.
Repaint,
/// Every fifth widget rather than all of them: marking all of them
/// redraws the whole tree, which is a cold start reached the long way,
/// where the mixed case leaves a redrawn subtree beside a retained one.
RepaintSome,
Resize,
ResizeRepaint,
/// A resize and then a size change, so a retained answer is asked to
/// survive two different kinds of invalidation in a row.
ResizeSize,
/// A few declared sizes.
Size,
/// Every declared size at once, so every reader of a size has a changed
/// descendant in the same frame and the whole dirty set settles together.
EverySize,
Align,
/// Giving a widget a movable region of its own, or taking it away, is a
/// structural change: every primitive under it changes which chain
/// resolves it.
RegionNode,
/// The same children in a different order, which moves every one of them
/// without changing what any of them is.
Reorder,
Shuffle(Shuffle),
}
pub const ALL: [Case; 15] = [
Case::Repaint,
Case::RepaintSome,
Case::Resize,
Case::ResizeRepaint,
Case::ResizeSize,
Case::Size,
Case::EverySize,
Case::Align,
Case::RegionNode,
Case::Reorder,
Case::Shuffle(Shuffle::EveryOther),
Case::Shuffle(Shuffle::AllButFirst),
Case::Shuffle(Shuffle::AddThree),
Case::Shuffle(Shuffle::SwapForThree),
Case::Shuffle(Shuffle::TradeOne),
];
impl Case {
/// The name `CASE` selects it by, and the one a failure prints.
pub fn name(self) -> &'static str {
match self {
Self::Repaint => "repaint",
Self::RepaintSome => "repaint-some",
Self::Resize => "resize",
Self::ResizeRepaint => "resize-repaint",
Self::ResizeSize => "resize-size",
Self::Size => "size",
Self::EverySize => "every-size",
Self::Align => "align",
Self::RegionNode => "region-node",
Self::Reorder => "reorder",
Self::Shuffle(Shuffle::EveryOther) => "shuffle-every-other",
Self::Shuffle(Shuffle::AllButFirst) => "shuffle-all-but-first",
Self::Shuffle(Shuffle::AddThree) => "shuffle-add-three",
Self::Shuffle(Shuffle::SwapForThree) => "shuffle-swap-for-three",
Self::Shuffle(Shuffle::TradeOne) => "shuffle-trade-one",
}
}
pub fn named(name: &str) -> Option<Self> {
ALL.into_iter().find(|case| case.name() == name)
}
/// Grown in the first, compared in the second.
fn window(self) -> ((f32, f32), (f32, f32)) {
match self {
Self::Resize | Self::ResizeRepaint | Self::ResizeSize => (OUTER, INNER),
_ => (STILL, STILL),
}
}
}
fn mark(warm: &mut Harness, tree: &Tree, step: usize) {
for &id in tree.ids.iter().step_by(step) {
warm.rsc.widgets_mut().get_dyn_mut(id);
}
}
fn a_len(rng: &mut Rng) -> Option<LayoutLen> {
Some(LayoutLen::px(20.0 + rng.below(180) as f32))
}
fn resize_one(warm: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Lens {
let lens = [a_len(rng), a_len(rng)];
warm.rsc
.widgets_mut()
.set_size_rules(tree.sized[idx], lens[0], lens[1]);
lens
}
fn realign_one(warm: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Aligns {
let side = |rng: &mut Rng| match rng.below(4) {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
};
let align = [side(rng), side(rng)];
let id = tree.aligned[idx];
for (axis, align) in [Axis::X, Axis::Y].into_iter().zip(align) {
warm.rsc
.widgets_mut()
.set_alignment(id, axis, align.unwrap_or_default());
}
align
}
/// Every span's children in a different order, said both to the warm tree and
/// to the plan the cold one is grown from.
fn reorder(warm: &mut Harness, tree: &Tree, plan: &Plan) -> Plan {
for span in &tree.spans {
let children = &mut warm.rsc[span.id].children;
if !children.is_empty() {
children.rotate_left(1);
}
}
let mut out = plan.clone();
out.walk_mut(&mut |node| {
if let Kind::Span { order, .. } = &mut node.kind
&& !order.is_empty()
{
order.rotate_left(1);
}
});
out
}
/// Applies `shuffle` to every third span. What it takes out is given back to
/// the span's spares: the last share of a widget must outlive the comparison,
/// or its id is handed to something else and the two trees stop lining up.
fn reshuffle(warm: &mut Harness, tree: &mut Tree, shuffle: Shuffle) -> HashMap<usize, SpanEdit> {
let mut edits = HashMap::new();
for (idx, span) in tree.spans.iter_mut().enumerate().step_by(3) {
let edit = shuffle.of(span.grown);
let mut take = edit.detach.clone();
take.sort_unstable();
let children = &mut warm.rsc[span.id].children;
// Highest first, so an index means the same child however many of its
// neighbours are going too.
for j in take.into_iter().rev() {
if j < children.len() {
span.spares.push(children.remove(j));
}
}
let attach = edit.attach.min(span.spares.len());
let moved: Vec<_> = span.spares.drain(..attach).collect();
warm.rsc[span.id].children.extend(moved);
edits.insert(idx, edit);
}
edits
}
/// Changes the warm tree and answers with the plan a cold tree grown that way
/// comes from. Each arm settles its own frame, so a case that changes nothing
/// does not get a second one that could settle what the first left.
fn change(case: Case, warm: &mut Harness, tree: &mut Tree, plan: &Plan, rng: &mut Rng) -> Plan {
let some_sizes = |warm: &mut Harness, tree: &Tree, rng: &mut Rng| {
let mut sizes = HashMap::new();
for _ in 0..4 {
if tree.sized.is_empty() {
break;
}
let idx = rng.below(tree.sized.len());
sizes.insert(idx, resize_one(warm, tree, idx, rng));
}
sizes
};
let edits = match case {
Case::Resize => return plan.clone(),
Case::Repaint | Case::ResizeRepaint => {
mark(warm, tree, 1);
warm.frame();
return plan.clone();
}
Case::RepaintSome => {
mark(warm, tree, 5);
warm.frame();
return plan.clone();
}
Case::Reorder => {
let out = reorder(warm, tree, plan);
warm.frame();
return out;
}
Case::Size | Case::ResizeSize => Edits {
sizes: some_sizes(warm, tree, rng),
..Default::default()
},
Case::EverySize => Edits {
sizes: (0..tree.sized.len())
.map(|idx| (idx, resize_one(warm, tree, idx, rng)))
.collect(),
..Default::default()
},
Case::Align => Edits {
aligns: (0..tree.aligned.len())
.step_by(3)
.map(|idx| (idx, realign_one(warm, tree, idx, rng)))
.collect(),
..Default::default()
},
Case::RegionNode => {
let mut nodes = HashMap::new();
for idx in (0..tree.nodes.len()).step_by(2) {
let id = tree.nodes[idx];
let take = !warm.rsc.widgets().is_region_node(id);
warm.rsc.widgets_mut().set_region_node(id, take);
nodes.insert(idx, take);
}
Edits {
nodes,
..Default::default()
}
}
Case::Shuffle(shuffle) => Edits {
spans: reshuffle(warm, tree, shuffle),
..Default::default()
},
};
warm.frame();
plan.edited(&edits)
}
/// What a widget was configured with, so a tree a fuzzer found can be written
/// out by hand. A failure is a lead; the fast test that replaces it has to be
/// buildable from what the failure printed.
fn describe(id: WidgetId, h: &Harness) -> String {
let rules = h.rsc.widgets().size_rules(id);
let rule = |r: SizeRule| match r.exact() {
Some(len) => format!("{len}"),
None => "-".into(),
};
let align = h.rsc.widgets().alignment(id);
let side = |a: AxisAlign| {
if a == AxisAlign::NEG {
"neg".into()
} else if a == AxisAlign::CENTER {
"mid".into()
} else if a == AxisAlign::POS {
"pos".into()
} else {
format!("{:.2}", a.rel())
}
};
// A rule and an alignment are properties of whatever carries them, so
// they print with that widget rather than as widgets of their own.
let mut out = describe_widget(id, h);
if (rules.x, rules.y) != (SizeRule::Free, SizeRule::Free) {
out += &format!("[x:{},y:{}]", rule(rules.x), rule(rules.y));
}
if align != RegionAlign::default() {
out += &format!("@{},{}", side(align.x), side(align.y));
}
out
}
fn describe_widget(id: WidgetId, h: &Harness) -> String {
let label = h.rsc.widgets().label(id).to_string();
let Some(widget) = h.rsc.widgets().get_dyn(id) else {
return label;
};
let any: &dyn std::any::Any = widget;
if let Some(w) = any.downcast_ref::<Span>() {
let sign = if w.dir.sign == Sign::Neg { "-" } else { "+" };
return format!(
"Span{{dir:{:?}{sign},gap:{},n:{}}}",
w.dir.axis,
w.gap,
w.children.len()
);
}
if let Some(w) = any.downcast_ref::<Pad>() {
let p = &w.padding;
return format!(
"Pad{{l:{},r:{},t:{},b:{}}}",
p.left, p.right, p.top, p.bottom
);
}
if let Some(w) = any.downcast_ref::<Stack>() {
return format!("Stack{{n:{}}}", w.children.len());
}
label
}
fn same_region(got: Option<PixelRegion>, want: Option<PixelRegion>) -> bool {
match (got, want) {
(Some(got), Some(want)) => {
let same = |a: Px, b: Px| (a - b).abs() <= Px::STEP.mul_int(AGREE_STEPS);
same(got.top_left.x, want.top_left.x)
&& same(got.top_left.y, want.top_left.y)
&& same(got.bot_right.x, want.bot_right.x)
&& same(got.bot_right.y, want.bot_right.y)
}
(None, None) => true,
_ => false,
}
}
/// Runs `case` on the tree `plan` describes, warm and cold, and says where
/// the two disagree. `seed` chooses only the values a case picks at random,
/// so one plan under one case is one comparison however it was reached.
pub fn diverges(plan: &Plan, case: Case, seed: u64) -> Option<String> {
let (start, end) = case.window();
let mut warm = Harness::new(start);
let (root, mut tree) = build(&mut warm.rsc, plan);
warm.state.root = Some(root);
// The frame that makes it warm: without it nothing is retained and the
// comparison is two cold starts agreeing with each other.
warm.frame();
if start != end {
warm.resize(end);
warm.frame();
}
let cold_plan = change(case, &mut warm, &mut tree, plan, &mut Rng::new(seed));
let mut cold = Harness::new(end);
let (root, cold_tree) = build(&mut cold.rsc, &cold_plan);
cold.state.root = Some(root);
cold.frame();
let mut drawn = 0;
for (i, (&w, &c)) in tree.ids.iter().zip(&cold_tree.ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
drawn += got.is_some() as usize;
if same_region(got, want) {
continue;
}
// Where two trees disagree is rarely where the cause is, so the
// ancestry comes with it, marking the widgets that own a region.
let mut chain = Vec::new();
let mut at = Some(w);
while let Some(id) = at {
let active = &warm.render.active[&id];
let node = match active.move_idx == active.parent_move {
true => "",
false => "*",
};
chain.push(format!("{}{node}", describe(id, &warm)));
at = active.parent;
}
return Some(format!(
"widget {i}\n warm {got:?}\n cold {want:?}\n {}",
chain.join(" < ")
));
}
match drawn {
0 => Some("nothing was drawn".into()),
_ => None,
}
}
+66 -586
View File
@@ -1,556 +1,38 @@
//! A property test that shrinks its own counterexample.
//! A fuzzer that reduces its own counterexample.
//!
//! `generated.rs` reproduces a failure from a seed, but a seed is not a lead
//! anybody can read: the tree is hundreds of widgets, and reconstructing the
//! part that matters by hand has failed every time it has been tried. This
//! grows trees it can take apart, so a failure is reduced to the smallest
//! tree that still shows it and printed as something to write a fast test
//! from.
//! A seed is not a lead anybody can read: the tree is hundreds of widgets,
//! and reconstructing the part that matters by hand has failed every time it
//! has been tried. This grows the trees `iris::random` describes, takes them
//! apart, and prints the smallest one that still fails as something to write
//! a fast test from.
//!
//! cargo test --release --test shrink -- --ignored --nocapture
//!
//! `SHRINK_SEEDS` how many trees to try, `SHRINK_DEPTH` how deep to grow
//! them, `SHRINK_CASE` which scenario. It is a fuzzer: run it once the
//! ordinary tests pass, and turn what it finds into a test of its own rather
//! than leaving a seed as the record.
//! them, `SHRINK_CASE` which scenario or `all` for every one. `SHRINK_SEED`
//! takes a single seed, which is how a failure `generated` printed is handed
//! straight here: the two run the same cases over the same trees, so a seed
//! that fails there fails here and is reduced.
//!
//! It is a fuzzer: run it once the ordinary tests pass, and turn what it
//! finds into a test of its own rather than leaving a seed as the record.
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Branch, Rng};
#[path = "scenario/mod.rs"]
mod scenario;
/// The same two leaves `iris::random` grows, since only one of them reads the
/// width it is given and that is the difference that matters.
const WORDS: &[&str] = &[
"Wrapping",
"shapes",
"one",
"source",
"into",
"as",
"many",
"lines",
"as",
"the",
"box",
"leaves",
"room",
"for,",
"so",
"a",
"paragraph's",
"height",
"is",
"an",
"answer",
"and",
"not",
"a",
"setting.",
];
use iris::random::{Edits, Plan, plan};
use scenario::{ALL, Case, diverges, env, over_seeds};
const ONE_LINE: &str = "one line, overflowing whatever it is given";
const OUTER: (f32, f32) = (1920.0, 1200.0);
/// Steps of the grid two ways of reaching a box may differ by: one per level
/// of nesting between them, and these trees are five deep. See
/// `docs/HANDOFF.md`'s "Fixed point" in `ai-app-2` for what is left.
const AGREE_STEPS: i32 = 2;
const INNER: (f32, f32) = (640.0, 900.0);
#[derive(Clone, Debug, PartialEq)]
enum Node {
/// Words taken from [`WORDS`], and whether it wraps.
Text(usize, bool),
/// The leaf that overflows whatever box it is given rather than wrapping.
OneLine,
Rect,
/// Direction, gap, children in creation order, and the order they are
/// attached in -- separate so a tree that reorders its children
/// still makes the same widgets in the same order, and two
/// builds line up index for index.
Span(bool, f32, Vec<Node>, Vec<usize>),
Stack(Vec<Node>),
Pad(f32, Box<Node>),
Aligned(u8, u8, Box<Node>),
Sized(Option<LayoutLen>, Option<LayoutLen>, Box<Node>),
Scroll(bool, Box<Node>),
Branch(Box<Node>, Box<Node>, Box<Node>, f32),
}
fn axis_align(v: u8) -> Option<AxisAlign> {
match v % 4 {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
}
}
fn dir(down: bool) -> Dir {
if down { Dir::DOWN } else { Dir::RIGHT }
}
impl Node {
/// Builds into `h`, pushing every id in tree order, so two builds of one
/// node line up index for index and their boxes can be compared.
fn build(
&self,
h: &mut Harness,
out: &mut Vec<WidgetId>,
spans: &mut Vec<WeakWidget<Span>>,
sized: &mut Vec<WidgetId>,
) -> StrongWidget {
let id: StrongWidget = match self {
Node::Text(words, wrap) => {
let n = (*words).clamp(1, WORDS.len());
wtext(WORDS[..n].join(" "))
.size(16)
.wrap(*wrap)
.add_strong(&mut h.rsc)
}
Node::OneLine => wtext(ONE_LINE).size(16).wrap(false).add_strong(&mut h.rsc),
Node::Rect => rect(Color::RED).add_strong(&mut h.rsc),
Node::Span(down, gap, kids, order) => {
let mut built: Vec<_> = kids
.iter()
.map(|k| Some(k.build(h, out, spans, sized)))
.collect();
// `order` is a permutation, so each is taken exactly once.
let children = order
.iter()
.map(|&i| built[i].take().expect("order repeats an index"))
.collect();
let handle = Span {
children,
dir: dir(*down),
gap: Px::from_f32(*gap),
}
.add(&mut h.rsc);
// A row takes the height it is given; a column is as wide
// as its widest child, which needs no rule.
if !*down {
h.rsc
.widgets_mut()
.set_size_rules(handle, None, Some(LayoutLen::rel(1.0)));
}
spans.push(handle);
handle.add_strong(&mut h.rsc)
}
Node::Stack(kids) => {
let children = kids.iter().map(|k| k.build(h, out, spans, sized)).collect();
Stack {
children,
size: StackSize::Child(0),
}
.add_strong(&mut h.rsc)
}
Node::Pad(p, kid) => {
let inner = kid.build(h, out, spans, sized);
Pad {
padding: Padding::uniform(*p),
inner,
}
.add_strong(&mut h.rsc)
}
Node::Aligned(x, y, kid) => {
let inner = kid.build(h, out, spans, sized);
for (axis, align) in [(Axis::X, axis_align(*x)), (Axis::Y, axis_align(*y))] {
if let Some(align) = align {
h.rsc.widgets_mut().set_alignment(&inner, axis, align);
}
}
inner
}
Node::Sized(x, y, kid) => {
let inner = kid.build(h, out, spans, sized);
h.rsc.widgets_mut().set_size_rules(&inner, *x, *y);
sized.push(inner.id());
inner
}
Node::Scroll(down, kid) => {
let inner = kid.build(h, out, spans, sized);
let axis = if *down { Axis::Y } else { Axis::X };
Scroll::new(inner, axis).add_strong(&mut h.rsc)
}
Node::Branch(probe, a, b, at) => {
let probe = probe.build(h, out, spans, sized);
let wide = a.build(h, out, spans, sized);
let narrow = b.build(h, out, spans, sized);
Branch {
probe,
wide,
narrow,
threshold: *at,
}
.add_strong(&mut h.rsc)
}
};
out.push(id.id());
id
}
/// The lengths every `Sized` node would carry after `resized`, in the
/// order `build` pushes them.
fn sized_lens(&self, out: &mut Vec<(Option<LayoutLen>, Option<LayoutLen>)>) {
match self {
Node::Text(..) | Node::OneLine | Node::Rect => {}
Node::Span(_, _, kids, _) | Node::Stack(kids) => {
kids.iter().for_each(|k| k.sized_lens(out));
}
Node::Pad(_, k) | Node::Aligned(_, _, k) | Node::Scroll(_, k) => k.sized_lens(out),
Node::Sized(x, y, k) => {
k.sized_lens(out);
out.push((resized_len(*x), resized_len(*y)));
}
Node::Branch(p, a, b, _) => {
p.sized_lens(out);
a.sized_lens(out);
b.sized_lens(out);
}
}
}
fn size(&self) -> usize {
1 + match self {
Node::Text(..) | Node::OneLine | Node::Rect => 0,
Node::Span(_, _, kids, _) | Node::Stack(kids) => kids.iter().map(Node::size).sum(),
Node::Pad(_, k)
| Node::Aligned(_, _, k)
| Node::Sized(_, _, k)
| Node::Scroll(_, k) => k.size(),
Node::Branch(p, a, b, _) => p.size() + a.size() + b.size(),
}
}
/// Every one-step simplification: a wrapper replaced by what it wrapped, a
/// child dropped, a length or a word count reduced. Ordered cheapest-first
/// so the greedy walk takes the biggest bites early.
fn smaller(&self) -> Vec<Node> {
let mut out = Vec::new();
let leaf = Node::Rect;
match self {
Node::Text(words, wrap) => {
if *words > 1 {
out.push(Node::Text(words / 2, *wrap));
out.push(Node::Text(words - 1, *wrap));
}
if *wrap {
out.push(Node::Text(*words, false));
}
out.push(leaf);
}
Node::OneLine => out.push(Node::Rect),
Node::Rect => {}
Node::Span(down, gap, kids, order) => {
out.extend(order.iter().map(|&i| kids[i].clone()));
for i in 0..kids.len() {
if kids.len() > 1 {
let mut less = kids.clone();
less.remove(i);
let order = (0..less.len()).collect();
out.push(Node::Span(*down, *gap, less, order));
}
}
if *gap != 0.0 {
out.push(Node::Span(*down, 0.0, kids.clone(), order.clone()));
}
for (i, kid) in kids.iter().enumerate() {
for small in kid.smaller() {
let mut next = kids.clone();
next[i] = small;
out.push(Node::Span(*down, *gap, next, order.clone()));
}
}
}
Node::Stack(kids) => {
out.extend(kids.iter().cloned());
for i in 0..kids.len() {
if kids.len() > 1 {
let mut less = kids.clone();
less.remove(i);
out.push(Node::Stack(less));
}
}
for (i, kid) in kids.iter().enumerate() {
for small in kid.smaller() {
let mut next = kids.clone();
next[i] = small;
out.push(Node::Stack(next));
}
}
}
Node::Pad(p, kid) => {
out.push((**kid).clone());
if *p != 0.0 {
out.push(Node::Pad(0.0, kid.clone()));
}
out.extend(
kid.smaller()
.into_iter()
.map(|k| Node::Pad(*p, Box::new(k))),
);
}
Node::Aligned(x, y, kid) => {
out.push((**kid).clone());
for (nx, ny) in [(0, *y), (*x, 0)] {
if (nx, ny) != (*x, *y) {
out.push(Node::Aligned(nx, ny, kid.clone()));
}
}
out.extend(
kid.smaller()
.into_iter()
.map(|k| Node::Aligned(*x, *y, Box::new(k))),
);
}
Node::Sized(x, y, kid) => {
out.push((**kid).clone());
if x.is_some() {
out.push(Node::Sized(None, *y, kid.clone()));
}
if y.is_some() {
out.push(Node::Sized(*x, None, kid.clone()));
}
out.extend(
kid.smaller()
.into_iter()
.map(|k| Node::Sized(*x, *y, Box::new(k))),
);
}
Node::Scroll(down, kid) => {
out.push((**kid).clone());
out.extend(
kid.smaller()
.into_iter()
.map(|k| Node::Scroll(*down, Box::new(k))),
);
}
Node::Branch(p, a, b, at) => {
out.push((**p).clone());
out.push((**a).clone());
out.push((**b).clone());
for small in p.smaller() {
out.push(Node::Branch(Box::new(small), a.clone(), b.clone(), *at));
}
for small in a.smaller() {
out.push(Node::Branch(p.clone(), Box::new(small), b.clone(), *at));
}
for small in b.smaller() {
out.push(Node::Branch(p.clone(), a.clone(), Box::new(small), *at));
}
}
}
out
}
}
/// A declared size over about half the tree, the way `iris::random` puts them
/// in: on the way into every child rather than as a node kind of its own, so
/// readers of a size are dense rather than occasional.
fn sized(rng: &mut Rng, inner: Node) -> Node {
if !rng.chance() {
return inner;
}
let len = |rng: &mut Rng| match rng.below(4) {
0 => Some(LayoutLen::px(20.0 + rng.below(180) as f32)),
1 => Some(LayoutLen::LEFTOVER),
_ => None,
};
Node::Sized(len(rng), len(rng), Box::new(inner))
}
fn grow(rng: &mut Rng, depth: usize) -> Node {
if depth == 0 {
return match rng.below(4) {
0 => Node::Text(1 + rng.below(WORDS.len()), true),
1 => Node::OneLine,
_ => Node::Rect,
};
}
let len = |rng: &mut Rng| match rng.below(4) {
0 => Some(LayoutLen::px(20.0 + rng.below(180) as f32)),
1 => Some(LayoutLen::LEFTOVER),
2 => Some(LayoutLen::rel(0.25 + rng.below(3) as f32 * 0.25)),
_ => None,
};
let kid = |rng: &mut Rng| {
let inner = grow(rng, depth - 1);
sized(rng, inner)
};
match rng.below(8) {
0 => Node::Scroll(rng.chance(), Box::new(kid(rng))),
1 => Node::Aligned(rng.below(4) as u8, rng.below(4) as u8, Box::new(kid(rng))),
2 => Node::Pad(rng.below(24) as f32, Box::new(kid(rng))),
3 => Node::Sized(len(rng), len(rng), Box::new(kid(rng))),
4 => Node::Branch(
Box::new(kid(rng)),
Box::new(kid(rng)),
Box::new(kid(rng)),
rng.below(500) as f32,
),
5 => Node::Stack((0..2 + rng.below(2)).map(|_| kid(rng)).collect()),
_ => {
let kids: Vec<_> = (0..2 + rng.below(3)).map(|_| kid(rng)).collect();
let order = (0..kids.len()).collect();
Node::Span(rng.chance(), rng.below(3) as f32 * 4.0, kids, order)
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Case {
Resize,
Repaint,
ResizeRepaint,
Reorder,
SizeChange,
}
/// A different declared length, kept the same kind so the change is to the
/// value alone.
fn resized_len(len: Option<LayoutLen>) -> Option<LayoutLen> {
let half = Rel::from_f32(0.5);
len.map(|len| LayoutLen {
px: len.px.mul(half) + Px::from_int(13),
rel: len.rel.mul(half),
leftover: len.leftover,
})
}
/// Every declared size changed, as a tree rather than as a change.
fn resized(node: &Node) -> Node {
match node {
Node::Span(down, gap, kids, order) => Node::Span(
*down,
*gap,
kids.iter().map(resized).collect(),
order.clone(),
),
Node::Stack(kids) => Node::Stack(kids.iter().map(resized).collect()),
Node::Pad(p, k) => Node::Pad(*p, Box::new(resized(k))),
Node::Aligned(x, y, k) => Node::Aligned(*x, *y, Box::new(resized(k))),
Node::Sized(x, y, k) => Node::Sized(resized_len(*x), resized_len(*y), Box::new(resized(k))),
Node::Scroll(d, k) => Node::Scroll(*d, Box::new(resized(k))),
Node::Branch(p, a, b, at) => Node::Branch(
Box::new(resized(p)),
Box::new(resized(a)),
Box::new(resized(b)),
*at,
),
leaf => leaf.clone(),
}
}
/// Every span's children rotated by one, as a tree rather than as a change:
/// what a warm frame reaches by moving them has to be where growing them that
/// way lands.
fn reordered(node: &Node) -> Node {
match node {
Node::Span(down, gap, kids, order) => {
let kids = kids.iter().map(reordered).collect::<Vec<_>>();
let mut order = order.clone();
order.rotate_left(1);
Node::Span(*down, *gap, kids, order)
}
Node::Stack(kids) => Node::Stack(kids.iter().map(reordered).collect()),
Node::Pad(p, k) => Node::Pad(*p, Box::new(reordered(k))),
Node::Aligned(x, y, k) => Node::Aligned(*x, *y, Box::new(reordered(k))),
Node::Sized(x, y, k) => Node::Sized(*x, *y, Box::new(reordered(k))),
Node::Scroll(d, k) => Node::Scroll(*d, Box::new(reordered(k))),
Node::Branch(p, a, b, at) => Node::Branch(
Box::new(reordered(p)),
Box::new(reordered(a)),
Box::new(reordered(b)),
*at,
),
leaf => leaf.clone(),
}
}
/// Runs one scenario warm and cold and says where they disagree.
fn diverges(node: &Node, case: Case) -> Option<String> {
let resizes = matches!(case, Case::Resize | Case::ResizeRepaint);
let repaints = matches!(case, Case::Repaint | Case::ResizeRepaint);
let start = if resizes { OUTER } else { INNER };
let mut warm = Harness::new(start);
let mut warm_ids = Vec::new();
let mut warm_spans = Vec::new();
let mut warm_sized = Vec::new();
let root = node.build(&mut warm, &mut warm_ids, &mut warm_spans, &mut warm_sized);
warm.state.root = Some(root);
// The frame that makes it warm: without it there is nothing retained and
// the comparison is two cold starts agreeing with each other.
warm.frame();
if resizes {
warm.resize(INNER);
warm.frame();
}
if repaints {
for &id in &warm_ids {
warm.rsc.widgets_mut().get_dyn_mut(id);
}
warm.frame();
}
if case == Case::Reorder {
for span in &warm_spans {
warm.rsc[*span].children.rotate_left(1);
}
warm.frame();
}
if case == Case::SizeChange {
let mut lens = Vec::new();
node.sized_lens(&mut lens);
for (id, (x, y)) in warm_sized.iter().zip(lens) {
warm.rsc.widgets_mut().set_size_rules(*id, x, y);
}
warm.frame();
}
// What the warm tree was moved into, grown that way from the start.
let want = match case {
Case::Reorder => reordered(node),
Case::SizeChange => resized(node),
_ => node.clone(),
};
let mut cold = Harness::new(INNER);
let mut cold_ids = Vec::new();
let mut cold_spans = Vec::new();
let mut cold_sized = Vec::new();
let root = want.build(&mut cold, &mut cold_ids, &mut cold_spans, &mut cold_sized);
cold.state.root = Some(root);
cold.frame();
for (i, (&w, &c)) in warm_ids.iter().zip(&cold_ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
// To a couple of steps of the grid, each a thousandth of a pixel: a
// move or a resize lands on the same number now, and a length
// measured one way against the same length composed another can
// still be a step out per composition between them.
let same = match (got, want) {
(Some(g), Some(c)) => {
let d = |a: Px, b: Px| (a - b).abs() <= Px::STEP.mul_int(AGREE_STEPS);
d(g.top_left.x, c.top_left.x)
&& d(g.top_left.y, c.top_left.y)
&& d(g.bot_right.x, c.bot_right.x)
&& d(g.bot_right.y, c.bot_right.y)
}
(None, None) => true,
_ => false,
};
if !same {
return Some(format!("widget {i}: warm {got:?} cold {want:?}"));
}
}
None
}
/// Takes the first simplification that still fails, until none does.
fn shrink(mut node: Node, case: Case) -> Node {
/// Takes the first simplification that still fails, until none does. The
/// simplifications come biggest first, so this walks down rather than
/// nibbling: a six-hundred-widget tree reaches single figures in a few
/// hundred builds.
fn shrink(mut node: Plan, case: Case, seed: u64) -> Plan {
loop {
let Some(next) = node
.smaller()
.into_iter()
.find(|small| diverges(small, case).is_some())
.find(|small| diverges(small, case, seed).is_some())
else {
return node;
};
@@ -558,62 +40,60 @@ fn shrink(mut node: Node, case: Case) -> Node {
}
}
/// One thread per core but one, each taking a share of the seeds: a tree is
/// grown, laid out and dropped within a seed, so nothing is shared. A seed
/// that fails shrinks on its own thread and panics there, which brings the
/// scope down with it.
fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
}
});
}
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(fallback)
fn cases() -> Vec<Case> {
match env("SHRINK_CASE", String::from("all")).as_str() {
"all" => ALL.to_vec(),
name => match Case::named(name) {
Some(case) => vec![case],
None => panic!(
"unknown SHRINK_CASE {name:?}; one of all, {}",
ALL.map(Case::name).join(", ")
),
},
}
}
#[test]
#[ignore = "a fuzzer; run it once the ordinary tests pass"]
fn no_grown_tree_lays_out_differently_warm_than_cold() {
let seeds: u64 = env("SHRINK_SEEDS", 400);
let depth: usize = env("SHRINK_DEPTH", 5);
let case = match env("SHRINK_CASE", String::from("resize")).as_str() {
"repaint" => Case::Repaint,
"resize-repaint" => Case::ResizeRepaint,
"reorder" => Case::Reorder,
"size-change" => Case::SizeChange,
_ => Case::Resize,
let cases = cases();
let seeds: Vec<u64> = match std::env::var("SHRINK_SEED")
.ok()
.and_then(|v| v.parse().ok())
{
Some(seed) => vec![seed],
None => (1..=env("SHRINK_SEEDS", 400_u64)).collect(),
};
let count = seeds.len();
over_seeds((1..=seeds).collect(), |seed| {
let node = grow(&mut Rng::new(seed), depth);
let Some(how) = diverges(&node, case) else {
return;
};
let small = shrink(node.clone(), case);
println!(
"seed {seed}: {how}\ngrown {} widgets, shrank to {}\n{small:#?}",
node.size(),
small.size()
);
panic!("seed {seed} lays out differently warm than cold");
over_seeds(seeds, |seed| {
let grown = plan(seed, depth, &Edits::default());
for &case in &cases {
let Some(how) = diverges(&grown, case, seed) else {
continue;
};
let small = shrink(grown.clone(), case, seed);
println!(
"seed {seed} case {}: {how}\ngrown {} widgets, shrank to {}\n{small:#?}",
case.name(),
grown.size(),
small.size()
);
panic!(
"seed {seed} lays out differently warm than cold after {}",
case.name()
);
}
});
let sizes: Vec<usize> = (1..=seeds)
.map(|seed| grow(&mut Rng::new(seed), depth).size())
let sizes: Vec<usize> = (1..=count as u64)
.map(|seed| plan(seed, depth, &Edits::default()).size())
.collect();
let total: usize = sizes.iter().sum();
println!(
"{seeds} trees at depth {depth} agree: {} widgets total, largest {}",
total,
"{count} trees at depth {depth} agree over {} case(s): {} widgets total, largest {}",
cases.len(),
sizes.iter().sum::<usize>(),
sizes.iter().max().copied().unwrap_or(0)
);
}
+2
View File
@@ -16,6 +16,8 @@ mod drift;
mod idempotence;
#[path = "cases/layout.rs"]
mod layout;
#[path = "cases/plan.rs"]
mod plan;
#[path = "cases/pointer.rs"]
mod pointer;
#[path = "cases/pointer_routing.rs"]