Compare commits

..
Author SHA1 Message Date
iris-aiandClaude Opus 5 169db7f16f Compose a position in the shader the way the CPU composes it
The shader used mix() where UiScalar::within writes from + (to - from) *
t, so the two associate the arithmetic differently and can put an edge on
either side of an integer. Writing it out matches them, and is a
multiplication cheaper. The five reference renders and the resize render
are byte-identical either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:31:31 -04:00
iris-aiandClaude Opus 5 4063635f39 Check that a one-pixel line keeps its pixel through the chain
Both edges of a fixed length share their box's fraction, so composing
the chain moves them together and the shader's floor can shift the pixel
between them but not round it away. The second test is the case that
makes the first one worth having: a span short of room takes it from its
shares, which go to nothing and then past it, and never from the fixed
lengths between them. Expressing the same line as a fraction of the
output fails both, which is what the tests are there to keep visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:31:31 -04:00
iris-aiandClaude Fable 5.1 f61e8936f1 Restore abs() in the rect shader, and validate every shader without a device
`7c50a3e` renamed a length's `abs` component to `px` and took the WGSL `abs()`
builtin in the rounded-rect distance with it, so every window failed shader
validation on the first frame while `cargo test` stayed green. `naga` is
reachable through `wgpu`, so a unit test now composes each shader file with the
prelude the way the renderer does and parses and validates it; it reads the
shader directory rather than naming primitives, so a new one is covered by
adding its file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:16:56 -04:00
iris-aiandClaude Fable 5.1 02ff8c7454 Measure a dirty widget where its parent asked, not in a box its answer decided
A local redraw drew a dirty widget in the box it was placed in. When a reader
decided that box from the widget's own answer -- an aligned span sized to its
children, a text at the tail of a row, a scroll's content -- the old answer is a
fixed point of measuring there whatever the content now says, so the layout had
two stable answers and which one it reached depended on the tree's history.
`tests/unsettled.rs` has the two shrunk cases: the four-widget aligned span,
and a scroll placing a pass-through `SetSize` in a box the content decided,
where the span under it was placed once and nothing at its own edge said so.

`ActiveData::offered_px` keeps the pixel size of the box the parent first asked
about the child in, whether through `known_len` or a first `place`, beside `px`,
the box it drew against. A dirty widget whose size reads an axis on which some
reader up its chain gave what it read a box other than the one it asked in is
not drawn locally: the chain is marked and the parent of the highest such
placement draws, since above it every box is a constraint rather than an
answer. The walk goes up the whole reader chain because a pass-through hands a
derived box down unchanged.

`Scroll` read its box's length for the clamp through `px_len`, which records
the reported size as depending on it, and it does not: its size is its
content's. That made every scroll tick a size question asked in a derived box,
at 34x the instructions. `Painter::px_len_for_draw` is the read that records
nothing. Instructions per frame on the depth-8 rig against the previous head:
`many` at 32 dirty 0.66M to 0.74M, at 130 dirty 27.7M to 26.5M, `resize` 15.8M
to 15.0M, `scroll`, `repaint` and `size` unchanged. The shrinking fuzzer passes
200 trees at depth 7 in all four cases, the hundred-seed sweep passes, and the
five reference renders and the resize render are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:16:56 -04:00
iris-aiandClaude Opus 5 65f68bbb8a Reorder a span's children in the fuzzer, and find two fixed points
The shrinking fuzzer had no case for what `generated.rs` calls a reshuffle,
which was the only thing still failing there. `Case::Reorder` rotates every
span's children after a warm frame and compares against a tree grown that
way -- which needs a span's creation order kept apart from the order its
children are attached in, or the two trees make the same widgets in
different orders and cannot be lined up.

It found a four-widget tree, from 486, and the trace says the layout has
more than one answer rather than one answer reached twice.

    Aligned(mid, -, Span[ Text(wrap), OneLine ])

A span measures its children in its own box. Its own box is what its parent
gave it, from the size it reported, from those children. So with the
wrapping text second it is offered `cursor..end` of a span 663.376 wide and
asked for 357.44, which is what it already holds -- the size is valid, the
span reports 663.376 again, and nothing moves. Grown in that order from
scratch the span is offered the window, the text is asked for 334.06 and
answers 318.45, and the span settles at 624.38. Both are stable. Which one
you get depends on what the tree was before.

So this is not a stale drawing kept too long, and no rule about when to
keep one will fix it: it is a circular dependency with two solutions.
`Painter::settle` in `Aligned` -- place into the child's own size without
measuring there -- makes all four cases in `unsettled.rs` pass and breaks
two in `generated.rs`, whether or not the child is drawn first. Not kept;
the shape of the fix is the constraint a container measures under being
something it is given rather than something it ends up with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:38:26 -04:00
iris-aiandClaude Opus 5 99131940ab Answer a break from the one in hand wherever it is still the same break
A parent that sizes to a child offers it back the length it just reported,
so a wrapping text was re-broken at exactly its own longest line. That is a
knife edge: the length is composed back through the box chain, so it lands
an ulp either side of where it started, and which side decides whether the
longest line still fits. One side kept three lines at 167.41, the other
took four at 163.49 -- from the same text in the same box, differing only
in what the output size had been.

A greedy break does not need recomputing there. Breaking at one width gives
lines that each fit, none of which could have taken another word; at any
narrower width down to the longest of them, every line still fits and none
can take a word that did not fit in more room. So one break answers a whole
interval, and the cache now hits across it rather than on the exact width.

The tolerance is what makes it hold at the edge, which is the case that
matters: sub-pixel, so no break it admits is one anybody could see.

The generated sweep passes at depth 6, where it failed; the shrinking
fuzzer agrees over 800 trees at depth 7 on all three scenarios, where two
of them failed. `tests/unsettled.rs` is green, so the whole suite is.
Depth 7 of the generated sweep still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:29:24 -04:00
iris-aiandClaude Opus 5 e5a3e640d4 Add the second shrunk case, and a trace rig for what box a text is drawn in
Six widgets from 905, and it fails in 0.06s: everything inside a declared
189x176 box is the same size whatever the output is, so a resize may not
reach any of it, and the text still comes out 3.92px narrower warm than
cold.

`tests/trace_unsettled.rs` says why, and it is not what it looked like. A
span measures a content-sized child in the space remaining, is told 167.41,
and then offers that back as the child's box -- so the text is re-broken at
exactly its own longest line, which is a knife edge: warm lands on four
lines and 163.49, cold stays on three and 167.41. Measuring an answer
against itself is unstable precisely at the fixed point.

`Painter::settle` -- move the child's slot, keep the drawing, never measure
again -- is the shape of the fix and does not work yet. In a span it breaks
five cases, because a container child may have laid its own children out as
fractions of the box it drew in, so moving it into a shorter one shrinks
them; reporting a length in pixels does not mean the drawing is positioned
in pixels. In `Aligned` alone it breaks two. Recorded rather than kept: the
condition wants to be something a widget declares, near `OnResize`, rather
than something its caller infers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:10:45 -04:00
iris-aiandClaude Opus 5 c596bf12c6 Measure a child in the length its parent declared, not the box it was offered
`SetSize` drew its child in whatever box it had been given and then
reported its declared length, so the child answered about a box it was
never going to have -- and the answer on the *other* axis was taken under
that. A wrapping text under `SetSize(x: 76px)` was measured in the whole
640 available, reported one line, and the parent sized itself to one line.
The text was then drawn again at 76 and reported two, but by then its box
was settled and nothing revisited it. A repaint put it right, which is why
the first frame and the second disagreed.

So the layout was not a function of the state, and "cold" was not a fixed
point -- which means the warm-against-cold oracle has been measuring
against a tree that had not settled, and some of what it reported as a
retained-layout defect was the cold side being wrong. Nothing about
retained state is involved in this: it reproduces in six widgets on a
first frame.

The declared length is what the child gets, so that is where it is
measured. `apply_rest` carries `rel` and `rest` through unchanged, and a
`px` length composes as an offset, so the child's box does not move again
when this widget's own box shrinks to what it declared.

`tests/unsettled.rs` passes, and the generated sweep now passes at depth 5
where it failed. Depth 6 and 7 still fail; there is more than one of these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:01:57 -04:00
iris-aiandClaude Opus 5 f0c7df06ac Let the unsettled-layout tests fail
Ignoring is for cost, not for status: a fuzzer earns it, a known defect
does not. Hiding this one behind an attribute turns a loud failure into a
quiet one nobody goes looking for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:52:07 -04:00
iris-aiandClaude Opus 5 b7caab3b9e Grow trees that can be taken apart, and find that a first frame is wrong
Reconstructing a generated failure by hand had failed three times: a seed
reproduces a tree of hundreds of widgets, and the printed chain is not
enough to see which part matters. `tests/shrink.rs` grows trees from a
description it can simplify -- drop a child, unwrap a wrapper, shorten a
text, drop a declared length -- and takes the first simplification that
still fails until none does. It lives in the tests; nothing in the library
knows about it.

It works: with the box-length check in `try_reuse` deliberately disabled
it reduced a 96-widget tree to 2. That check is worth keeping, because a
fuzzer that cannot fail is a fuzzer that agrees with everything.

What it found is not what any of this was looking for. Six widgets, shrunk
from 402:

    Span[ Stack[ Text("Wrapping"), Aligned(pos,pos,
          SetSize(x: 76px, Text("Wrapping shapes", wrap))) ] ]

The wrapping text is one line on the first frame and two after a repaint,
and two is right for a 76px box -- so the *cold* tree is the one that has
not settled. `generated.rs` has been comparing a warm frame against a cold
one and calling the difference a retained-layout defect, while at least
some of it is the first frame shaping a text at a width it was measured in
rather than the one it was given. Retained state is not involved.

`tests/unsettled.rs` is that case by hand, in 0.06s. Both of its tests
fail, so both are ignored with the reason rather than left to break the
build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:49:00 -04:00
iris-aiandClaude Opus 5 386a0d1b8f Steer the fuzzer, and print enough of a failure to rebuild it by hand
`DEPTH` was a constant at 4, and the generator widens two to four ways per
level, so raising it buys overlap between dependency paths rather than
ancestry. `IRIS_GENERATED_DEPTH` and `IRIS_GENERATED_SEEDS` select the
load; the default is what it was.

Depth 4 was hiding divergences. At depth 5 and beyond the sweep fails on
the tree as it stands, with no `Branch` node and every span filling across
its axis, so it is neither of the things I suspected -- it predates both.

A failure printed a chain of type names, which is not enough to write the
tree out again, and hand-reconstruction from one has failed three times
now. `describe` prints what each ancestor was configured with, so a run
says `Text < SetSize{x:34 px;} < Aligned{x:neg,y:pos} < SetSize{x:35 px;}
< Stack{n:2}` and the fast test that replaces the seed can be built from
that. `Widget: Any`, so this needs no new plumbing.

Two fixtures assumed every tree grows a declared size to change, and one
assumed a span it shuffles is drawn -- a span behind a branch nobody took
is not. Both are vacuous seeds rather than failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:27:41 -04:00
iris-aiandClaude Opus 5 1b1378b05a Branch on a measurement, so a wrong one shows as a different tree
Comparing boxes catches a widget that moved. It does not catch one that
measured a child, was handed an answer a cold start would not have given,
and took the other branch -- the same defect, arriving where a pixel
comparison cannot see it. Branching on what the painter tells you is
something a widget is allowed to do, so the library owes the same answer
warm and cold; only a widget changing its own configuration is exempt.

`random::Branch` measures a child and draws one of two others on the
result, with both grown either way so the ids match whichever is drawn.
It joins the generator, which makes every existing scenario a control-flow
oracle as well as a geometric one. `tests/determinism.rs` is the same
widget by hand across eight thresholds, including either side of the
answer, and is the fast check -- the sweep is a fuzzer and confirms at the
end rather than being iterated against.

A span behind a branch nobody took is not drawn, so shuffling it cannot
move anything; `reshuffled` now treats that as vacuous, the way it already
treats a tree with no spans, rather than as a shuffle that had no effect.

Both new tests pass, and the sweep passes at depth 4 and 5 over 200 seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:06:54 -04:00
iris-aiandClaude Opus 5 60175c3821 Check that measuring a text and giving it that width is a fixed point
A span that sizes to its children measures one, is told a length, and
hands that length back -- so whether measurement is idempotent decides
whether the two chase each other. Nothing checked it.

It holds: a wrapping text in a `Dir::RIGHT` span, which is the wrap axis
and the span's own axis together, stays at 881.84 across six repaints
that change nothing. So the narrowing recorded against LAYOUT.md §4 is
not something text does on its own, and looking for the cause there is
looking in the wrong place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:51:29 -04:00
iris-aiandClaude Opus 5 b165164e59 Carry a span's rest weight up instead of collapsing it to one share
A span reporting `Len::default()` whenever a child had a share threw away
how many shares it was holding, so each level of nesting re-divided a
share rather than dividing the same space. One span of a rect beside a
span of three gave 1/2 and 1/6 each, where the same four rects directly
in one span get a quarter.

A span that sizes from its children does not resolve `rest`, it passes
the weight up; resolution belongs at the nearest ancestor with a length,
and since the output became a box there is always one. The placement loop
already divides by `len.rest / total.rest`, so it consumes carried
weights unchanged -- only what the span reported was wrong.

The uneven nesting is the case that fails without this; the even one
passes either way and is here as the statement of intent.

Decided by the owner, 2026-09-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:29:25 -04:00
iris-aiandClaude Opus 5 ef815dadfd Let OnResize answer for the window too, and delete the second rule
A resize had its own mechanism: `reads_output` recorded that a widget had
looked at the output, `update` scanned every active widget for one whose
`output_px` had moved, marked it and its whole reader chain, and
`resize_marks` kept those marks from counting as content dirtiness --
while `on_resize` answered the same question for every other box. Two
answers to "does this drawing survive its box changing length", and the
one that applied to the window ignored what the widget had declared.

With the output held as the root of the chain there is one question. A
resize offers the root widget its box again, `try_reuse` answers per axis
from `on_resize`, and `redraws_under` prices the subtree. Gone with it:
`reads_output`, `resized`, `resize_marks`, the scan, the eager reader
marking, and the shallowest-first branch in `redraw_updates`, which only
existed because resize marking worked differently -- the settle loop now
has one order.

Two things this needed. An unslotted widget may be reused when only its
parent's box changed length: it has nothing of its own to write, and what
it drew is a fraction of that box, so the slot above it already carries
the change. And `root_readers` holds the widgets whose size came from the
output rather than their own box -- `MaxSize` -- since no box of theirs
need have changed; they are marked per axis, from a set kept as they draw
rather than by scanning.

`a_resize_does_not_redraw_what_the_shader_can_move` now says `Scale`,
which is what it was always describing, and `a_resize_redraws_what_does
_not_scale` is its other half. `ReadsWidth` declares `Scale` across the
axis it does not read, so per-axis precision comes from the widget rather
than from which output axis it happened to touch.

Resize phase, seed 1 depth 8: 6.45M instructions per frame to 5.84M.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:50:06 -04:00
iris-aiandClaude Opus 5 9f4311774b Hold the output as the box every chain bottoms out in
A position was composed up the slot chain to a normalized region and then
multiplied by the output's size, so the window was the one box in the
system that was not a box. Seeding the chain with a root slot holding it
in pixels makes composing through it leave everything below in pixels,
which is what the multiplication was doing.

`within` already does the arithmetic: a child at `rel` 1 inside a span of
`px` 0 to `px` 1920 composes to `px` 1920 and `rel` 0, so the trailing
`to_px` becomes the identity rather than a step. The shader walks the
same chain and needs no change for the same reason.

This is the shape the resize machinery wants before it can be deleted: a
resize becomes one slot written, which `try_reuse` and `redraws_under`
already carry. Nothing is removed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:32:53 -04:00
iris-aiandClaude Opus 5 7c50a3e51b Rename a length's abs component to px
`dp` is coming, and then `abs` says which of the two it is not. The
component has always been a pixel count, so the name that admits it is
the one that leaves room for a second unit beside it.

Mechanical: the field on `Len` and `UiScalar`, their constructors,
`to_abs`/`get_abs`, the matching WGSL struct member and the locals
composing it. Field order and types are unchanged, so the `Pod` layout
the shader reads is the same bytes. `f32::abs` is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:29:20 -04:00
iris-aiandClaude Opus 5 3f7cd8251b Carry a widget's depth down the draw instead of walking up for it
Choosing which dirty widget to settle next asked every one of them how
deep it was, and answering meant walking its ancestry to the root. At
130 of 260 widgets dirty that was 25.8% of the frame -- more than laying
out or rendering.

A widget's depth is known where it is drawn: its parent's plus one. So
`Painter` carries it and `ActiveData` keeps it, and the choice reads a
field. Being reused counts as being visited, so the two reuse paths keep
it current too; only a subtree nothing looked at can hold an old one,
and nothing under an unvisited subtree is being ordered.

The order is unchanged, so nothing about the layout is: the five
reference renders and the resize render are byte-identical. What the
carried depth might get wrong is itself, so `depth` asserts it against
the ancestry in debug builds, and the hundred-seed sweep passes with
those assertions on -- including the reshuffles, which are what move a
widget to another parent.

Same load, 1000 frames, 130 of 260 dirty: 8.16M instructions per frame
to 7.14M, median 0.813 ms to 0.639, and the choosing from 25.8% of the
frame to 4.7%. What is left of it is iterating the dirty set itself,
which a `HashSet` walks by capacity rather than by length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:10:27 -04:00
iris-aiandClaude Opus 5 bf9438087a Say what the settle order is holding up
`try_reuse` asks whether the widget in front of it is dirty and, if not,
hands its parent the size it last reported. Nothing asks whether a dirty
widget sits under it through the size dependencies -- which is the check
`retained_size` makes, for exactly this reason, on the path that does not
draw.

What covers the gap is the order `redraw_updates` settles in: taking the
deepest dirty widget first means that by the time a reader draws, what it
reads has already drawn and propagated. Drawing in any other order
returns a stale size. Measured rather than reasoned: picking whatever the
dirty set yields first fails seed 2 of `tests/generated.rs` with 24
widgets wrong, a subtree keeping a 317 px width where a cold tree has
147, and the traces are identical until a `Span` reports 317 against 147
from the same child sizes -- it had reused a subtree holding a `SetSize`
whose declared width had changed.

So the coupling is real and was written down nowhere. Say it in both
places, since a reader of either would otherwise conclude the order is
about cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:57:08 -04:00
iris-aiandClaude Opus 5 77bb75e5de Dirty many widgets at once, which nothing was checking
Every generated case changes one thing: four declared sizes, or one
span's children, or the output. A frame settling one dependency path
says nothing about a frame settling a set of them that overlap, which
is the case the settle order exists for.

So two more: every declared size in the tree changing at once, and a
spread of widgets marked for redraw together. The second changes
nothing, which is the point -- no box may move, and the order the
dirty set is taken in is all that can make one. The hundred-seed sweep
is 1,000 comparisons now rather than 800, and passes.

`IRIS_PHASE=many` is the same load for the diagnostics rig, with
`IRIS_DIRTY` widgets marked per frame. It says what one repainting leaf
cannot: at 130 of 260 widgets, choosing which dirty widget to settle
next is 24.5% of the frame, because the dirty set is scanned once per
widget settled and a hash set is walked by capacity rather than by
length. Memoizing the depth walk inside one scan does not pay -- it
trades parent lookups for memo lookups and costs 4% more instructions --
so the fix is to stop rescanning, which changes the order widgets
settle in and wants agreeing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:21:47 -04:00
iris-aiandClaude Opus 5 2525637e26 Re-break a text's lines for a new width instead of shaping it again
Only the line breaking depends on the width. The shaped runs under it --
the font selection, the unicode analysis, harfrust -- are a function of
the text and the attrs, and parley re-breaks them in place; its own
editor does exactly this on every resize. So a new width is a break and
a placement, not a shaping.

On the depth-8 tree that is 107 breaks at 0.119 ms where the shapings
they replace were 4.0 ms, and it holds however far the width moves,
which is what the store could not do: a width the layout has not seen
before is a miss, and a drag never sees one twice. Instructions per
frame over 500 resize frames of `tests/revision_cost.rs`, for widths
that alternate and widths that never repeat:

    #18 head             124.2M   123.6M
    a store of shapings   17.7M    45.9M
    re-breaking alone     32.9M    32.8M
    both                  20.6M    24.2M

The store stays because re-breaking does not place the glyphs, so it now
holds those instead: fewer instructions than either alone in the case
that never repeats, and 3 MB rather than 4 MB on a tree of 4,000 texts,
against the 132 MB the code before #16 reaches after the same resizes.
The worst frame is 2.54 ms where that code's is 6.47 ms, and the two
gestures are within a millisecond of each other rather than a factor of
two apart.

Count the breaks and time them separately from shaping, since which of
the two a frame is doing is the whole question here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:21:20 -04:00
iris-aiandClaude Opus 5 e5f8b6b244 Shape a text once per width, not once per ask
A container measures a child by drawing it in a box it may not keep, so
one layout asks a text for a dozen widths and comes back to widths it
has already had -- the hottest text in the depth-8 tree draws 32 times.
Each ask re-ran the shaper, because the two caches in front of it held
one entry each and a trial width alternating with a final width evicts
the answer about to be wanted again. `perf record` put 63% of a resize
frame in text and 0.9% in `draw_inner`.

So keep more than one: a bounded store of shapings on `TextData`, keyed
by the text, the attrs and the width, holding the parley layout and the
glyphs placed from it. Bounding the store rather than each buffer is
what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is
19 MB less than the code before #16 holds after the same resizes.

`TextBuffer` now holds the glyphs of the shaping it is drawn as, which
is where `TextView::tex` was. That leaves one place to invalidate rather
than two, so the `MutDetect` flags on a view's text and attrs have no
reader and go, along with the `buf.changed = true` after every edit.

On a 40-row tree of distinct random paragraphs, 500 resize frames:
124.2M instructions per frame before, 17.7M after, and 45.9M when the
width never repeats. The five reference renders and the resize render
are byte-identical, and the 100-seed sweep passes.

`tests/revision_cost.rs` is that tree, written in the API subset
`43ce8c7` shares so the same source measures the code this replaced.
Report the worst frame and p99 beside the median, since a stutter is
what somebody sees. Count glyph placements, and count a text render per
ask rather than per shaping, so the store cannot hide how many times a
layout drew the same text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:49:45 -04:00
iris-aiandClaude Opus 5 f1a47e9b7b Say what three retained-layout details mean
Reading this back, three things claim something they do not do.

`OnResize::Translate` is returned by `TextView::on_resize` under a
comment weighing anchored glyphs against reshaping ones, but nothing
consumes it: `try_reuse` asks only whether the answer is `Scale`, so a
widget saying `Translate` is redrawn. Say so on the variant, since the
comment beside it reads as a description of behaviour.

`depend_on_size(child, false)` and `depend_on_size(child, true)` are the
difference between a hint, which is context-free, and a size the child
produced by drawing, which carries every pixel axis the child read. That
is the subtlest rule in the file and it was spelled as a bool; give the
two cases their names.

`draw_started` is the record of what has drawn during the pass under
way, and it worked only because `redraw` removes an id before asking
about it -- nothing emptied the set, so it accumulated the id of every
widget ever drawn, including ones long gone. Empty it with the pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:49:08 -04:00
iris-ai b1b3eca1c0 Retain layout sizes by pixel axis 2026-09-14 17:47:21 -04:00
iris-ai 82fa6c1123 Coalesce resize layout diagnostics 2026-09-14 17:05:11 -04:00
iris-ai 480f0bc99f Retain opt-in layout performance diagnostics 2026-09-14 16:42:03 -04:00
iris-ai 84f589e364 Settle dirty layout from the leaves upward 2026-09-14 15:56:44 -04:00
iris-ai a640c6cce2 Avoid speculative layout when retained answers suffice 2026-09-14 15:33:39 -04:00
iris-aiandClaude Opus 5 cdec29351a Grow scrolling into the random trees
Scrolling is the one thing in these trees that reads the pixel length of its
box, and the one that hands its child a box longer than its own, so a warm
layout under it has to be rebuilt where the rest can be carried over. A
sixth of the nodes at each level is now a scroll over a subtree, on either
axis.

Four of a hundred seeds now grow nothing but wrappers, so `reshuffled`
returns early where there is no span to shuffle: a case with nothing to do
is not the same as a shuffle that had no effect, which is what the assertion
below it is for.

50 tests, and the ignored sweep over 100 seeds and eight scenarios, 800
comparisons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:35:20 -04:00
iris-aiandClaude Opus 5 4178dfbff9 Grow padding into the random trees, and take children in and out of spans
Padding as a node, with each of the four sides its own number: a padding that
is the same all round hides anything that treats one edge differently from
another. Spans now hold two to four children, so a pattern of removals has
something to make a pattern out of.

Five ways of changing what a span holds, each a shape worth its own case
rather than one shuffle: every other child out, everything but the first out,
three on at once, the first out and three on, and one out of the middle with
one on the end. Each is applied to every third span, and the cold tree is
grown holding exactly what the warm one was left with.

Three spare leaves are grown beside every span whether they end up in it or
not, so a tree that leaves them out makes the same widgets in the same order
as one that puts them in -- otherwise the two trees' `ids` stop lining up at
the first difference and every comparison after it is against the wrong
widget. Attaching one moves it, since a widget belongs to one parent;
`upgrade` is for a weak handle that was never added, not a second share. The
detached children are held until the comparison is over for the same reason:
dropping the last share of one frees its id for the next widget to be given.

Each case asserts the tree actually changed before comparing, so a shuffle
that quietly did nothing fails rather than passes.

All of it agrees: 49 tests, and the ignored sweep over 100 seeds and eight
scenarios, 800 comparisons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:48:42 -04:00
iris-aiandClaude Opus 5 2272634dc5 Resize an example under the rig, since a resize is its own case
`--resize WxH@Hz` changes the output once the app is up and screenshots
after, so "it lands where a cold start at that size does" is a command
rather than a procedure. That check caught both of #16's defects and nothing
in `cargo test` can see it; it now passes byte for byte on `tabs` and `text`
for this branch.

Run one at a time: the rig reuses a single compositor and a single output,
so two invocations at once resize each other's window and quietly screenshot
the wrong thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:31:20 -04:00
iris-aiandClaude Opus 5 b0f9f046da Decide reuse on the box a widget drew against, in pixels
Two holes the random trees found, both of which kept a wrapping text shaped
for a width it no longer had.

**A region is a fraction of a slot's box, so an unchanged region is not an
unchanged box.** `try_reuse` compared regions, and a child drawn at
`UiRegion::FULL` of a slot whose box had just halved compared equal to
itself and was reused without being descended into. `ActiveData` now keeps
the pixel size of the box it drew against and the comparison is against
that, which is the question that was being asked all along and is right
through a slot change and an output resize alike.

**A size the parent learnt by drawing the child is an answer for that box
only.** The walk looking for what cannot survive a length change skipped a
child whose own box was a fixed width -- correctly, its box does not change
-- but that width was what the child reported when the span drew it in the
span's box, and the span's box did change. So a child whose size the widget
read is redrawn unless it declares an exact `size_hint` for the changed
axis, which is the one case the parent did not have to draw it to know.

The cost is that a size-reading container gives up its reuse when its box
changes length, which is every span, so `OnResize::Scale` now earns its
keep on moves and on subtrees whose sizes nobody read rather than on every
stretch. Correct first; `replace_cost` still measures the case the chain was
built for.

`tests/generated.rs` is what found both and what says they are fixed: 90 of
90 warm trees now land where a cold build does, against 83 before this
commit and 83 on `db1751f`. The ignored sweep agrees over 300 checks on 100
seeds.

`a_fixed_length_child_is_not_redrawn_when_the_box_around_it_grows` became
`a_declared_length_...`: the child now says its width, since a width the
span measured is not one it may keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:23:29 -04:00
iris-aiandClaude Opus 5 86a7e8dfc3 Grow random trees, and check them against building the same tree cold
`iris::random` grows a seeded tree -- spans in every direction, stacks,
rects with varying opacity, text both wrapping and overflowing, a declared
size over half of it -- and `tests/generated.rs` grows each seed twice: once
and then mutated, once with the mutation built in. Every widget's box has to
match. `examples/random.rs` draws one, and `IRIS_SEED`/`IRIS_DEPTH` pick it.

It found the defect in the commit before this one immediately: a reuse that
marked a descendant for redraw escalated to that descendant's size reader,
which re-placed the child, which marked it again. `try_reuse` now asks
whether anything under the widget would have to be drawn again *before*
keeping the drawing, and drops the whole thing if so, which terminates
because it adds no marks.

It also found one older and larger than this branch, which
`a_wrapping_child_of_a_row_settles_somewhere_else_each_time` reproduces and
documents: a wrapping text on a span's own axis is shaped twice against two
different widths, so where it settles depends on how many passes it has had.
7 of 90 cases diverge on `db1751f` and 30 do here, because a placed child
reaches the second shaping more often. It is the same defect either way, and
it belongs where the two draws meet -- LAYOUT.md §4 -- not in the chain. The
six seeds the live tests use are ones that agree.

`forget_ref` goes with the subtree rewrite that used it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:06:09 -04:00
iris-aiandClaude Opus 5 d98969158f Give a slot to the children a container places, and nothing else
A widget's region is now held in the coordinates of the slot it draws in
rather than the window's, and `Painter::place` is how a container asks for a
slot: it draws a child it decides the box of and may decide again. Everything
under that slot is a fraction of its box, so placing the child a second time
is one entry to write whether it moved or changed length. A child drawn any
other way has no slot and shares its nearest ancestor's.

That is what keeps the chain short. `chain_cost` measured depth as the cost
-- free to 8, +42.6% at 16 -- and a slot per widget put a transcript's glyphs
past that for nothing, since almost every slot was zero. `Span`, `Aligned`
and `Scroll` are the containers that re-place a child after drawing it, and
`tests/layout.rs` pins that four widgets between a span and a leaf leave the
leaf's chain one deep.

`UiRegion::stretch`, `UiRegion::stretchable` and `UiScalar::stretch` are
gone. Nothing is inverted any more: a box that changed length is written to
its slot, and the descendants recompose against it in the shader. That also
retires the case the guard existed for, where a fixed length has no fraction
to recover -- `tests/layout.rs` now stretches a 40-tall row on its other
axis, which `stretchable` refused outright.

What still walks the CPU is deciding who must draw again, which no chain can
answer: `mark_resized` descends from the widget whose box changed and marks
anything whose own box changed length and whose drawing reads it. A part of
a box with no relative extent on an axis is a fixed length, and composing
into it leaves none either, so the walk stops where a length did not change
-- an 80-wide child in a widened row is not redrawn though it says `Redraw`.

`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say
`Scale`: each places in fractions and offsets of its own box and none reads
the box's pixel length. `Scroll` and `MaxSize` do read pixels and stay
`Redraw`.

45 tests pass, five of them new. Render verification comes after the CPU
side, per the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:25:37 -04:00
iris-aiandClaude Opus 5 1f9dc48b80 Carry a box in a move slot, not a translation
A slot now holds the box its contents are placed within, in the coordinates
of the slot it names, and `prelude.wgsl` composes the chain with `within`
instead of adding a delta. A translation is the special case where the box
has its parent's relative extent, so every caller passes
`UiRegion::FULL.offset(delta)` and nothing changes on screen yet: 42 tests
pass and `tabs` at 1920x1200 is byte-identical.

`Moves::resolve` takes the region to compose rather than returning a sum, so
the CPU walk is the same operation the shader performs.

Measured against the translate slot on the same binary with
`tests/chain_cost.rs`, 200k instances: +0.6% at depth 1, +0.5% at 2, +0.8% at
4, then +9.6% at 8 and +32.2% at 64. Free at the depth opt-in slots produce,
which is the next commit; the per-level cost was always the dependent load
rather than the arithmetic.

The identity is `UiRegion::FULL` rather than zero, which `MoveOffset`'s
comment says beside the `Zeroable` that `Pod` requires: a zeroed entry is a
box of no extent and collapses its subtree to a point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:14:24 -04:00
iris-ai db1751fdfd Retire Remap: a translation shifts, and only a stretch needs a fraction
`Remap` existed to invert a composition, and a translation never needed
one: shifting a box shifts everything composed into it by the same
amount, because `lerp(s + d, e + d, t) == lerp(s, e, t) + d` on both
channels. That holds whether or not the box has a relative extent, so
the carry branch was answering a question it did not have to ask.

So the decision is made once, before the walk, and neither relocation
method branches. A translation is already one slot write. A change of
length calls `UiRegion::stretch`, which re-expresses each part at its
own fraction of the new box and needs `stretchable` -- a fixed length
holds its parts as offsets from its start and keeps no fraction to
stretch by.

`Remap`, `UiScalar::outside`, `UiSpan::outside` and `LerpUtil::lerp_inv`
are all gone with it. Nothing inverts a lerp any more: the one division
is done against a denominator `stretchable` already established is not
zero.

What it gives up is the per-axis carry, so a box that changed length on
one axis and not the other is redrawn where it used to be remapped.
Counted: six of `tabs`'s fourteen relocations and five of `text`'s
sixteen, and one extra redraw per frame on `replace_cost`'s 200 rows --
354,310,889 instructions against 354,272,387, which is noise.

Checked: fmt, clippy and 42 tests. `tabs` (with the image replay),
`view`, `minimal` and `text` all still render byte-identical to
`upstream/main`.
2026-09-14 11:07:32 -04:00
iris-ai 78a53b6bf6 Keep the re-place load as a rig, so the next attempt is compared not argued 2026-09-14 10:49:49 -04:00
iris-ai d8c497fcd7 Measure what the chain walk costs, and find that depth is the cost
`tests/chain_cost.rs` times the pass on the GPU with timestamp queries,
which this adapter supports, rather than by the clock. 200,000 two-pixel
instances at 1024x1024, so vertex work dominates, best of eight batches:

  depth  1     77.9 us    +0.0%
  depth  2     78.1 us    +0.2%
  depth  4     79.0 us    +1.3%
  depth  8     81.8 us    +5.0%
  depth 16    111.1 us   +42.6%
  depth 32    159.6 us  +104.9%
  depth 64    250.3 us  +221.3%

Free to about depth 8 and then roughly 3 us per level. Each step is a
storage load whose address is the previous load's result, so it is the
chaining that costs rather than the arithmetic at each level -- which
means the number would look the same for a slot carrying a whole region
instead of a delta.

That matters because every active widget owns a slot, so a primitive
resolves through its full depth in the widget tree, and LAYOUT.md notes
real trees have exceeded 16. At the couple of hundred primitives an
example draws it is nothing; a transcript's glyphs are tens of thousands
of primitives, which is the regime measured here.

No behaviour change. Recorded rather than acted on: keeping the chain
shallow means not giving every widget a slot, which is a design decision
of LAYOUT.md §2 and §6 and the owner's to make.
2026-09-14 03:31:33 -04:00
iris-ai c8ec0866d4 Exercise the subtree remap, which no test reached
`mov` ran ten times across the suite and never once recursed: `Rect`,
`Image` and `()` are the only widgets claiming `OnResize::Scale` and all
three are childless, so the walk that remaps a subtree's children -- the
thing `Remap` exists for -- had no coverage at all.

`Stretchy` is a test widget that claims `Scale` and holds a child, which
is the shape no shipped container has. Removing the recursion leaves its
child behind at the old box and the test says so.
2026-09-14 03:25:35 -04:00
iris-ai 8223a55cfb Move a subtree by writing one slot
`try_reuse`'s pure-translation case now writes the widget's move slot
instead of remapping every primitive in its subtree. Counted on a span
of 20 rows, each five primitives deep, when the row above them changes
height:

  before   100 primitive region writes
  now        0, and 20 slot writes -- one per row the span re-placed

`window_region` walks the same chain on the CPU, so hit testing and
anyone asking in window pixels see a widget where the shader draws it.
`Moves::resolve` stops at `CHAIN_LIMIT` like the shader, and asserts in
debug that it got to the end rather than running out.

Two things fall out of it. Rewriting a region is the one thing a slot
cannot express, so `mov` zeroes the slots of everything it rewrites: a
region is what its slot was a delta from. And `try_reuse` loses its
`old == region` shortcut, which was wrong once a slot exists -- a widget
offered exactly the box it drew against has to have its delta cleared,
not skipped.

`Moves` lives on `UiRenderState` rather than `UiData`, because the draw
is what produces it and `window_region` should not need the ui's
resources to answer where something is. The renderer already takes both.

A slot is retired in `remove_rec`, after the descendants whose slots
name it as their parent. Either order is correct here -- nothing can
claim a freed index while a subtree is coming down, since `on_undraw`
cannot reach the slots -- but this way `remove`'s `undraw` flag only
notifies rather than also deciding slot lifetime, and the retirement
sits beside the recursion it follows.

Checked: fmt, clippy and 41 tests. `tabs` (with the image replay),
`view`, `minimal` and `text` all still render byte-identical, and the
live sway resize round trip -- which re-places most of the tree at the
same size, so it is the slot path throughout -- matches a cold start at
each size.
2026-09-14 03:15:17 -04:00
iris-ai f9ef7514e7 Resolve a primitive's position through a chain of move slots
The plumbing for O(1) subtree movement (LAYOUT.md §2), with every slot
still at zero, so this changes no pixels and the next commit can change
behaviour against a known-good picture.

Every active widget owns a slot in `UiData::moves`: a translation in
physical pixels and the slot it is relative to. A primitive instance and
a mask each name one, and `prelude.wgsl` walks the chain and adds the
accumulated delta. A mask resolves its own chain rather than the drawn
primitive's, so a stationary viewport can clip content that moves inside
it. `CHAIN_LIMIT` is stated on both sides; it bounds a malformed cycle
rather than any real tree.

A slot outlives any one `ActiveData`, because a redraw replaces that
while the widget's children go on pointing at the slot, so it lives in
`UiRenderState::moves` keyed by widget and is retired when the widget
stops being drawn. `MoveIdx` is its own type rather than another
`Id<u32>`: it sits beside `MaskIdx` in an instance and the two must not
be swappable.

`Vec2` is now `repr(align(8))`, which is WGSL's alignment for a
`vec2<f32>`, so a GPU struct holding one is laid out the way its shader
reads it without saying so itself -- `GlyphPrimitive` no longer states
its own alignment, and `MoveOffset` never has to. Both keep a manual
`unsafe impl Pod`, since the trailing padding that alignment introduces
is what `derive(Pod)` refuses. `WindowUniform` holds the `Vec2` its
shader has always called `dim` rather than two loose floats, which was
the last place the two sides described the same bytes differently.

Checked: fmt, clippy and 40 tests. `tabs` (with the image replay),
`view` and `minimal` render byte-identical to `upstream/main`, and
`text` is unchanged.
2026-09-14 03:05:14 -04:00
iris-ai ca2b4b2173 Bring the headless rig into the repository (#17)
Reviewed-on: iris/iris#17
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: iris-ai <4+iris-ai@noreply.localhost>
2026-09-14 02:50:09 -04:00
52 changed files with 5077 additions and 426 deletions

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ name = "iris"
version.workspace = true
edition.workspace = true
[features]
layout-diagnostics = ["iris-core/layout-diagnostics"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
+3
View File
@@ -3,6 +3,9 @@ name = "iris-core"
version.workspace = true
edition.workspace = true
[features]
layout-diagnostics = []
[dependencies]
wgpu = { workspace = true }
bytemuck ={ workspace = true }
+488
View File
@@ -0,0 +1,488 @@
//! Opt-in counters and coarse timers for explaining CPU layout cost.
//!
//! Enable the `layout-diagnostics` feature. With it disabled, none of the
//! instrumentation is compiled into Iris. The retained rig in
//! `tests/layout_diagnostics.rs` is the ordinary entry point.
//!
//! Timers are inclusive: `update total` contains `full layout` or
//! `incremental layout`, and `text render` contains shaping and glyph
//! placement. They locate cost within one instrumented run and must not be
//! added together. Use an uninstrumented build under `perf` for final CPU
//! totals; counting every primitive and distinct widget deliberately perturbs
//! the instrumented run.
//!
//! Call [`trace_widget`] before a frame to retain the ordered constraint,
//! reuse, size, placement, and text events for one suspicious widget. The
//! selection is a set and survives [`take`] until cleared.
use crate::{Axis, Len, Size, UiRegion, WidgetId, util::Vec2};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
fmt::Write,
time::Instant,
};
#[derive(Clone, Copy)]
pub(crate) enum Counter {
Updates,
ResizeDependents,
DrawRequests,
WidgetDraws,
PlaceCalls,
SizeReads,
HintHits,
HintMisses,
RetainedSizeHits,
ReuseAttempts,
ReuseExact,
ReuseMoved,
ReuseDirty,
ReuseWrongParent,
ReuseUnslotted,
ReuseOwnResize,
ReuseDescendantResize,
ResizeChecks,
ResizeCheckChildren,
QueuePops,
DepthReads,
EagerReaderRedraws,
LocalRedraws,
SizeChanges,
ReaderEdges,
PrimitiveWrites,
TextRenders,
TextShapeHits,
TextShapes,
TextBreaks,
GlyphPlacements,
}
impl Counter {
const COUNT: usize = Self::GlyphPlacements as usize + 1;
const NAMES: [&'static str; Self::COUNT] = [
"updates",
"resize dependents",
"draw requests",
"widget draws",
"place calls",
"draw-result size reads",
"hint hits",
"hint misses",
"retained size hits",
"reuse attempts",
"reuse exact",
"reuse moved",
"reuse: dirty",
"reuse: wrong parent",
"reuse: unslotted",
"reuse: own resize",
"reuse: descendant resize",
"resize checks",
"resize children checked",
"redraw queue pops",
"depth reads",
"eager reader redraws",
"local redraws",
"size changes",
"reader edges",
"primitive writes",
"text renders",
"text shape hits",
"text shapes",
"text line breaks",
"glyph placements",
];
}
#[derive(Clone, Copy)]
pub(crate) enum TimerKind {
Update,
FullLayout,
ResizeMarking,
IncrementalLayout,
TextRender,
TextShape,
TextBreak,
GlyphPlacement,
}
impl TimerKind {
const COUNT: usize = Self::GlyphPlacement as usize + 1;
const NAMES: [&'static str; Self::COUNT] = [
"update total",
"full layout",
"resize marking",
"incremental layout",
"text render",
"text shape",
"text line break",
"glyph placement",
];
}
#[derive(Clone)]
pub struct Report {
counters: [u64; Counter::COUNT],
nanos: [u64; TimerKind::COUNT],
distinct_widgets: usize,
distinct_text_widgets: usize,
hot_widgets: Vec<Callsite>,
hot_text: Vec<Callsite>,
traces: Vec<TraceEvent>,
}
impl Default for Report {
fn default() -> Self {
Self {
counters: [0; Counter::COUNT],
nanos: [0; TimerKind::COUNT],
distinct_widgets: 0,
distinct_text_widgets: 0,
hot_widgets: Vec::new(),
hot_text: Vec::new(),
traces: Vec::new(),
}
}
}
impl Report {
pub fn counters(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
Counter::NAMES.into_iter().zip(self.counters)
}
/// Inclusive elapsed time accumulated for each targeted operation.
pub fn timings_ns(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
TimerKind::NAMES.into_iter().zip(self.nanos)
}
pub fn distinct_widgets(&self) -> usize {
self.distinct_widgets
}
pub fn distinct_text_widgets(&self) -> usize {
self.distinct_text_widgets
}
pub fn hot_widgets(&self) -> &[Callsite] {
&self.hot_widgets
}
pub fn hot_text(&self) -> &[Callsite] {
&self.hot_text
}
/// Ordered layout events for widgets selected with [`trace_widget`].
pub fn traces(&self) -> &[TraceEvent] {
&self.traces
}
/// Formats nonzero totals divided by `frames`.
pub fn per_frame(&self, frames: usize) -> String {
let divisor = frames.max(1) as f64;
let mut out = String::new();
for (name, value) in self.counters() {
if value != 0 {
let _ = writeln!(out, " {name:<27} {:>12.2}", value as f64 / divisor);
}
}
if self.distinct_widgets != 0 {
let _ = writeln!(
out,
" {:<27} {:>12}",
"distinct widgets", self.distinct_widgets
);
}
if self.distinct_text_widgets != 0 {
let _ = writeln!(
out,
" {:<27} {:>12}",
"distinct text widgets", self.distinct_text_widgets
);
}
for (name, nanos) in self.timings_ns() {
if nanos != 0 {
let ms = nanos as f64 / divisor / 1_000_000.0;
let _ = writeln!(out, " {name:<27} {ms:>12.3} ms");
}
}
if !self.hot_widgets.is_empty() {
let _ = writeln!(out, " hottest widget draws:");
for callsite in &self.hot_widgets {
let calls = callsite.calls as f64 / divisor;
let _ = writeln!(
out,
" {calls:>9.2} {:?} {}",
callsite.id, callsite.label
);
}
}
if !self.hot_text.is_empty() {
let _ = writeln!(out, " hottest text renders:");
for callsite in &self.hot_text {
let calls = callsite.calls as f64 / divisor;
let _ = writeln!(
out,
" {calls:>9.2} {:>3} widths {:?} {}",
callsite.distinct_widths, callsite.id, callsite.label
);
}
}
if !self.traces.is_empty() {
let _ = writeln!(out, " targeted layout trace:");
for event in &self.traces {
let _ = writeln!(out, " {event:?}");
}
}
out
}
}
#[derive(Clone)]
pub struct Callsite {
pub id: WidgetId,
pub label: String,
pub calls: u64,
pub distinct_widths: usize,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ReuseOutcome {
Exact,
Moved,
Dirty,
WrongParent,
Unslotted,
OwnResize,
DescendantResize,
}
/// One targeted layout event. Events are retained in execution order, making
/// repeated constraint paths visible without logging every widget globally.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TraceEvent {
DrawRequest {
id: WidgetId,
parent: Option<WidgetId>,
region: UiRegion,
pixel_size: Vec2,
slotted: bool,
},
Reuse {
id: WidgetId,
outcome: ReuseOutcome,
},
SizeReported {
id: WidgetId,
size: Size,
},
Placed {
id: WidgetId,
parent: WidgetId,
region: UiRegion,
},
SizeRead {
id: WidgetId,
reader: WidgetId,
size: Size,
},
HintRead {
id: WidgetId,
reader: WidgetId,
axis: Axis,
hint: Option<Len>,
},
TextRendered {
id: WidgetId,
width: Option<f32>,
},
}
#[derive(Default)]
struct Calls {
label: String,
count: u64,
widths: HashSet<Option<u32>>,
}
#[derive(Default)]
struct Current {
report: Report,
widgets: HashMap<WidgetId, Calls>,
text_widgets: HashMap<WidgetId, Calls>,
traced: HashSet<WidgetId>,
}
thread_local! {
static CURRENT: RefCell<Current> = RefCell::new(Current::default());
}
pub(crate) fn bump(counter: Counter) {
CURRENT.with_borrow_mut(|current| current.report.counters[counter as usize] += 1);
}
pub(crate) fn draw_widget(id: WidgetId, label: &str) {
CURRENT.with_borrow_mut(|current| {
let calls = current.widgets.entry(id).or_default();
if calls.label.is_empty() {
calls.label = label.to_owned();
}
calls.count += 1;
});
}
/// Adds a widget to the targeted trace set. Selection survives [`take`]
/// until explicitly removed or cleared.
pub fn trace_widget(id: impl Into<WidgetId>) {
CURRENT.with_borrow_mut(|current| {
current.traced.insert(id.into());
});
}
pub fn untrace_widget(id: impl Into<WidgetId>) {
CURRENT.with_borrow_mut(|current| {
current.traced.remove(&id.into());
});
}
pub fn clear_traced_widgets() {
CURRENT.with_borrow_mut(|current| current.traced.clear());
}
fn trace(id: WidgetId, event: TraceEvent) {
CURRENT.with_borrow_mut(|current| {
if current.traced.contains(&id) {
current.report.traces.push(event);
}
});
}
pub(crate) fn draw_request(
id: WidgetId,
parent: Option<WidgetId>,
region: UiRegion,
pixel_size: Vec2,
slotted: bool,
) {
trace(
id,
TraceEvent::DrawRequest {
id,
parent,
region,
pixel_size,
slotted,
},
);
}
pub(crate) fn reuse(id: WidgetId, outcome: ReuseOutcome) {
trace(id, TraceEvent::Reuse { id, outcome });
}
pub(crate) fn size_reported(id: WidgetId, size: Size) {
trace(id, TraceEvent::SizeReported { id, size });
}
pub(crate) fn placed(id: WidgetId, parent: WidgetId, region: UiRegion) {
trace(id, TraceEvent::Placed { id, parent, region });
}
pub(crate) fn size_read(id: WidgetId, reader: WidgetId, size: Size) {
trace(id, TraceEvent::SizeRead { id, reader, size });
}
pub(crate) fn hint_read(id: WidgetId, reader: WidgetId, axis: Axis, hint: Option<Len>) {
trace(
id,
TraceEvent::HintRead {
id,
reader,
axis,
hint,
},
);
}
pub(crate) fn render_text(id: WidgetId, label: &str, width: Option<f32>) {
CURRENT.with_borrow_mut(|current| {
let calls = current.text_widgets.entry(id).or_default();
if calls.label.is_empty() {
calls.label = label.to_owned();
}
calls.count += 1;
calls.widths.insert(width.map(f32::to_bits));
if current.traced.contains(&id) {
current
.report
.traces
.push(TraceEvent::TextRendered { id, width });
}
});
}
pub(crate) struct Timer {
kind: TimerKind,
start: Instant,
}
pub(crate) fn timer(kind: TimerKind) -> Timer {
Timer {
kind,
start: Instant::now(),
}
}
impl Drop for Timer {
fn drop(&mut self) {
let nanos = self.start.elapsed().as_nanos().min(u64::MAX as u128) as u64;
CURRENT.with_borrow_mut(|current| current.report.nanos[self.kind as usize] += nanos);
}
}
/// Takes all diagnostics accumulated on this thread and resets them.
pub fn take() -> Report {
CURRENT.with_borrow_mut(|current| {
current.report.distinct_widgets = current.widgets.len();
current.report.distinct_text_widgets = current.text_widgets.len();
current.report.hot_widgets = hottest(&current.widgets);
current.report.hot_text = hottest(&current.text_widgets);
let report = std::mem::take(&mut current.report);
current.widgets.clear();
current.text_widgets.clear();
report
})
}
fn hottest(calls: &HashMap<WidgetId, Calls>) -> Vec<Callsite> {
let mut calls: Vec<_> = calls
.iter()
.map(|(&id, calls)| Callsite {
id,
label: calls.label.clone(),
calls: calls.count,
distinct_widths: calls.widths.len(),
})
.collect();
calls.sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.label.cmp(&b.label)));
calls.truncate(8);
calls
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn taking_a_report_resets_its_counters() {
let _ = take();
bump(Counter::Updates);
bump(Counter::Updates);
let report = take();
assert_eq!(report.counters().next(), Some(("updates", 2)));
assert!(take().counters().all(|(_, count)| count == 0));
}
}
+3
View File
@@ -10,6 +10,9 @@
#![feature(coerce_unsized)]
#![feature(option_into_flat_iter)]
#[cfg(feature = "layout-diagnostics")]
pub mod layout_diagnostics;
mod attr;
mod event;
mod num;
+2 -2
View File
@@ -144,10 +144,10 @@ impl UiScalar {
pub const fn align(&self, align: AxisAlign) -> UiSpan {
let rel = align.rel();
let mut start = UiScalar::rel(rel);
start.abs -= self.abs * rel;
start.px -= self.px * rel;
start.rel -= self.rel * rel;
let mut end = UiScalar::rel(rel);
end.abs += self.abs * (1.0 - rel);
end.px += self.px * (1.0 - rel);
end.rel += self.rel * (1.0 - rel);
UiSpan { start, end }
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::*;
#[derive(Copy, Clone, Eq, PartialEq)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Axis {
X,
Y,
+20 -20
View File
@@ -9,14 +9,14 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len {
pub abs: f32,
pub px: f32,
pub rel: f32,
pub rest: f32,
}
impl<N: UiNum> From<N> for Len {
fn from(value: N) -> Self {
Len::abs(value.to_f32())
Len::px(value.to_f32())
}
}
@@ -46,10 +46,10 @@ impl Size {
y: Len::REST,
};
pub fn abs(v: Vec2) -> Self {
pub fn px(v: Vec2) -> Self {
Self {
x: Len::abs(v.x),
y: Len::abs(v.y),
x: Len::px(v.x),
y: Len::px(v.y),
}
}
@@ -97,13 +97,13 @@ impl Size {
impl Len {
pub const ZERO: Self = Self {
abs: 0.0,
px: 0.0,
rel: 0.0,
rest: 0.0,
};
pub const REST: Self = Self {
abs: 0.0,
px: 0.0,
rel: 0.0,
rest: 1.0,
};
@@ -111,27 +111,27 @@ impl Len {
pub fn apply_rest(&self) -> UiScalar {
UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs,
px: self.px,
}
}
pub fn abs(abs: impl UiNum) -> Self {
pub fn px(px: impl UiNum) -> Self {
Self {
abs: abs.to_f32(),
px: px.to_f32(),
rel: 0.0,
rest: 0.0,
}
}
pub fn rel(rel: impl UiNum) -> Self {
Self {
abs: 0.0,
px: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
}
pub fn rest(ratio: impl UiNum) -> Self {
Self {
abs: 0.0,
px: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
@@ -141,31 +141,31 @@ impl Len {
pub mod len_fns {
use super::*;
pub fn abs(abs: impl UiNum) -> Len {
pub fn px(px: impl UiNum) -> Len {
Len {
abs: abs.to_f32(),
px: px.to_f32(),
rel: 0.0,
rest: 0.0,
}
}
pub fn rel(rel: impl UiNum) -> Len {
Len {
abs: 0.0,
px: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
}
pub fn rest(ratio: impl UiNum) -> Len {
Len {
abs: 0.0,
px: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
}
}
impl_op!(Len Add add; abs rel rest);
impl_op!(Len Sub sub; abs rel rest);
impl_op!(Len Add add; px rel rest);
impl_op!(Len Sub sub; px rel rest);
impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y);
@@ -184,8 +184,8 @@ impl std::fmt::Display for Size {
impl std::fmt::Display for Len {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?;
if self.px != 0.0 {
write!(f, "{} px;", self.px)?;
}
if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?;
+38 -97
View File
@@ -23,11 +23,11 @@ impl UiVec2 {
Self { x, y }
}
pub const fn abs(abs: impl const Into<Vec2>) -> Self {
let abs = abs.into();
pub const fn px(px: impl const Into<Vec2>) -> Self {
let px = px.into();
Self {
x: UiScalar::abs(abs.x),
y: UiScalar::abs(abs.y),
x: UiScalar::px(px.x),
y: UiScalar::px(px.y),
}
}
@@ -70,10 +70,10 @@ impl UiVec2 {
}
}
pub fn to_abs(&self, rel: Vec2) -> Vec2 {
pub fn to_px(&self, rel: Vec2) -> Vec2 {
Vec2 {
x: self.x.to_abs(rel.x),
y: self.y.to_abs(rel.y),
x: self.x.to_px(rel.x),
y: self.y.to_px(rel.y),
}
}
@@ -92,8 +92,8 @@ impl UiVec2 {
}
}
pub fn get_abs(&self) -> Vec2 {
(self.x.abs, self.y.abs).into()
pub fn get_px(&self) -> Vec2 {
(self.x.px, self.y.px).into()
}
pub fn get_rel(&self) -> Vec2 {
@@ -102,15 +102,15 @@ impl UiVec2 {
pub fn abs_mut(&mut self) -> Vec2View<'_> {
Vec2View {
x: &mut self.x.abs,
y: &mut self.y.abs,
x: &mut self.x.px,
y: &mut self.y.px,
}
}
}
impl Display for UiVec2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "rel{};abs{}", self.get_rel(), self.get_abs())
write!(f, "rel{};px{}", self.get_rel(), self.get_px())
}
}
@@ -118,8 +118,8 @@ impl_op!(UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y);
const impl From<Vec2> for UiVec2 {
fn from(abs: Vec2) -> Self {
Self::abs(abs)
fn from(px: Vec2) -> Self {
Self::px(px)
}
}
@@ -127,8 +127,8 @@ const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
where
(T, U): const Destruct,
{
fn from(abs: (T, U)) -> Self {
Self::abs(abs)
fn from(px: (T, U)) -> Self {
Self::px(px)
}
}
@@ -136,34 +136,34 @@ where
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)]
pub struct UiScalar {
pub rel: f32,
pub abs: f32,
pub px: f32,
}
impl Eq for UiScalar {}
impl Hash for UiScalar {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
state.write_u32(self.rel.to_bits());
state.write_u32(self.abs.to_bits());
state.write_u32(self.px.to_bits());
}
}
impl_op!(UiScalar Add add; rel abs);
impl_op!(UiScalar Sub sub; rel abs);
impl_op!(UiScalar Add add; rel px);
impl_op!(UiScalar Sub sub; rel px);
impl UiScalar {
pub const ZERO: Self = Self { rel: 0.0, abs: 0.0 };
pub const FULL: Self = Self { rel: 1.0, abs: 0.0 };
pub const ZERO: Self = Self { rel: 0.0, px: 0.0 };
pub const FULL: Self = Self { rel: 1.0, px: 0.0 };
pub const fn new(rel: f32, abs: f32) -> Self {
Self { rel, abs }
pub const fn new(rel: f32, px: f32) -> Self {
Self { rel, px }
}
pub const fn rel(rel: f32) -> Self {
Self { rel, abs: 0.0 }
Self { rel, px: 0.0 }
}
pub const fn abs(abs: f32) -> Self {
Self { rel: 0.0, abs }
pub const fn px(px: f32) -> Self {
Self { rel: 0.0, px }
}
pub const fn rel_min() -> Self {
@@ -177,39 +177,31 @@ impl UiScalar {
pub const fn max(&self, other: Self) -> Self {
Self {
rel: self.rel.max(other.rel),
abs: self.abs.max(other.abs),
px: self.px.max(other.px),
}
}
pub const fn min(&self, other: Self) -> Self {
Self {
rel: self.rel.min(other.rel),
abs: self.abs.min(other.abs),
px: self.px.min(other.px),
}
}
pub const fn offset(mut self, amt: f32) -> Self {
self.abs += amt;
self.px += amt;
self
}
pub const fn within(&self, span: &UiSpan) -> Self {
let anchor = self.rel.lerp(span.start.rel, span.end.rel);
let offset = self.abs + self.rel.lerp(span.start.abs, span.end.abs);
let offset = self.px + self.rel.lerp(span.start.px, span.end.px);
Self {
rel: anchor,
abs: offset,
px: offset,
}
}
/// Undoes `within`, and `None` where the span has a fixed length: every
/// fraction of it lands on the same `rel`, so none can be told apart.
pub fn outside(&self, span: &UiSpan) -> Option<Self> {
let rel = self.rel.lerp_inv(span.start.rel, span.end.rel)?;
let abs = self.abs - rel.lerp(span.start.abs, span.end.abs);
Some(Self { rel, abs })
}
pub fn within_len(&self, len: UiScalar) -> Self {
self.within(&UiSpan {
start: UiScalar::ZERO,
@@ -223,15 +215,15 @@ impl UiScalar {
pub const fn flip(&mut self) {
self.rel = 1.0 - self.rel;
self.abs = -self.abs;
self.px = -self.px;
}
pub const fn to(&self, end: Self) -> UiSpan {
UiSpan { start: *self, end }
}
pub const fn to_abs(&self, rel: f32) -> f32 {
self.rel * rel + self.abs
pub const fn to_px(&self, rel: f32) -> f32 {
self.rel * rel + self.px
}
}
@@ -263,7 +255,7 @@ impl UiSpan {
self.start.flip();
self.end.flip();
std::mem::swap(&mut self.start.rel, &mut self.end.rel);
std::mem::swap(&mut self.start.abs, &mut self.end.abs);
std::mem::swap(&mut self.start.px, &mut self.end.px);
}
pub const fn shift(&mut self, offset: UiScalar) {
@@ -278,13 +270,6 @@ impl UiSpan {
}
}
pub fn outside(&self, parent: &Self) -> Option<Self> {
Some(Self {
start: self.start.outside(parent)?,
end: self.end.outside(parent)?,
})
}
pub const fn len(&self) -> UiScalar {
self.end - self.start
}
@@ -353,8 +338,8 @@ impl UiRegion {
pub fn to_px(&self, size: Vec2) -> PixelRegion {
PixelRegion {
top_left: self.top_left().get_rel() * size + self.top_left().get_abs(),
bot_right: self.bot_right().get_rel() * size + self.bot_right().get_abs(),
top_left: self.top_left().get_rel() * size + self.top_left().get_px(),
bot_right: self.bot_right().get_rel() * size + self.bot_right().get_px(),
}
}
@@ -397,50 +382,6 @@ impl UiRegion {
}
}
/// Taking a drawing out of one box and putting it in another, checked once
/// for a whole subtree so that applying it cannot fail.
///
/// A box of a fixed length holds each part as an offset from its start rather
/// than as a fraction of it, so those parts can be carried to a box of the
/// same length but never stretched to a different one.
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct Remap {
from: UiRegion,
to: UiRegion,
}
impl Remap {
pub fn new(from: UiRegion, to: UiRegion) -> Option<Self> {
[Axis::X, Axis::Y]
.into_iter()
.all(|axis| {
let (from, to) = (from.axis(axis), to.axis(axis));
from.start.rel != from.end.rel || from.len() == to.len()
})
.then_some(Self { from, to })
}
pub fn apply(&self, region: UiRegion) -> UiRegion {
UiRegion {
x: Self::span(region.x, self.from.x, self.to.x),
y: Self::span(region.y, self.from.y, self.to.y),
}
}
fn span(span: UiSpan, from: UiSpan, to: UiSpan) -> UiSpan {
match span.outside(&from) {
Some(out) => out.within(&to),
// `new` admits this only where the two are the same length, so
// the difference between their starts is the whole move.
None => {
let mut span = span;
span.shift(to.start - from.start);
span
}
}
}
}
impl Display for UiRegion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
+154 -11
View File
@@ -1,3 +1,5 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, TimerKind};
use crate::{
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, UiColor, util::Vec2,
};
@@ -5,7 +7,10 @@ use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::{
collections::VecDeque,
hash::{DefaultHasher, Hash, Hasher},
};
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
@@ -17,8 +22,32 @@ pub struct TextData {
pub layout_ctx: LayoutContext<UiColor>,
scale_ctx: ScaleContext,
pub atlas: GlyphAtlas,
spare: VecDeque<Placed>,
}
/// The glyphs of one text at one width. A buffer holds the ones it is drawn
/// as; these are the ones it had before, kept because a container measures a
/// child by drawing it in a box it may not keep, and so comes back to widths
/// it has already asked for.
struct Placed {
/// Where the glyphs land is a function of these three and nothing else,
/// so no widget or buffer identity is involved and two texts of the same
/// words share an answer.
text: String,
key: LayoutKey,
glyphs: RenderedText,
}
/// How many to keep. Bounding the whole store rather than each buffer is what
/// makes this a fixed cost instead of one a tree of ten thousand texts pays
/// ten thousand times; the re-asks come from laying out one subtree, so they
/// are close together and few are needed. Instructions over 500 resize frames
/// of `tests/revision_cost.rs`, both the repeating widths and the sweep that
/// cannot hit across frames: 13.7B at 32, 12.1B at 64, 10.4B and 12.1B at 128,
/// and nothing past that -- so 128, which is no worse in the case that never
/// repeats and better in the one that does.
const SPARE_PLACED: usize = 128;
impl Default for TextData {
fn default() -> Self {
Self {
@@ -26,6 +55,7 @@ impl Default for TextData {
layout_ctx: LayoutContext::new(),
scale_ctx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
spare: VecDeque::new(),
}
}
}
@@ -76,11 +106,21 @@ 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,
layout: Layout<UiColor>,
layout_key: Option<LayoutKey>,
/// The glyphs placed from `layout`, so drawing this text again at the
/// width it already has places them once.
placed: Option<RenderedText>,
}
#[derive(PartialEq)]
@@ -95,6 +135,7 @@ impl TextBuffer {
text: text.into(),
layout: Layout::new(),
layout_key: None,
placed: None,
}
}
@@ -119,15 +160,28 @@ impl TextBuffer {
if text != self.text {
self.text = text;
self.layout_key = None;
self.placed = None;
}
}
/// Invalidates the layout and returns the underlying string for editing.
pub fn edit(&mut self) -> &mut String {
self.layout_key = None;
self.placed = None;
&mut self.text
}
/// The glyphs of the shaping it is drawn as, once they are placed.
pub fn rendered(&self) -> Option<&RenderedText> {
self.placed.as_ref()
}
/// The width its shaping wraps at, and `None` where it does not wrap or
/// has not been shaped.
pub fn wrap_width(&self) -> Option<f32> {
self.layout_key.as_ref()?.max_width
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
@@ -138,8 +192,58 @@ impl TextBuffer {
max_width: width,
};
if self.layout_key.as_ref() == Some(&layout_key) {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);
return;
}
// 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.
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()
{
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);
return;
}
let same_shaping = self
.layout_key
.as_ref()
.is_some_and(|key| key.attrs == *attrs);
let old_key = self.layout_key.replace(layout_key);
// The glyphs it holds are of the width it held, which the layout may
// well come back to.
if let Some(key) = old_key
&& let Some(glyphs) = self.placed.take()
{
data.keep_placed(Placed {
text: self.text.clone(),
key,
glyphs,
});
}
// Only the line breaking depends on the width: the shaped runs under
// it are a function of the text and the attrs, and parley re-breaks
// them in place. So a new width is a break, not a shaping.
if same_shaping {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextBreaks);
#[cfg(feature = "layout-diagnostics")]
let _break = diag::timer(TimerKind::TextBreak);
self.break_lines(width);
return;
}
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapes);
#[cfg(feature = "layout-diagnostics")]
let _shape = diag::timer(TimerKind::TextShape);
let mut builder = data
.layout_ctx
.ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
@@ -150,10 +254,13 @@ impl TextBuffer {
)));
builder.push_default(StyleProperty::Brush(attrs.color));
builder.build_into(&mut self.layout, &self.text);
self.break_lines(width);
}
fn break_lines(&mut self, width: Option<f32>) {
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.layout_key = Some(layout_key);
}
}
@@ -265,18 +372,54 @@ pub struct RenderedText {
}
impl TextData {
pub fn render(
/// The glyphs of this text at this width, taken out of what is kept.
fn take_placed(&mut self, text: &str, key: &LayoutKey) -> Option<RenderedText> {
// From the newest, since a re-ask is usually of something recent.
let at = self
.spare
.iter()
.rposition(|spare| spare.key == *key && spare.text == text)?;
self.spare.remove(at).map(|spare| spare.glyphs)
}
fn keep_placed(&mut self, placed: Placed) {
if self.spare.len() >= SPARE_PLACED {
self.spare.pop_front();
}
self.spare.push_back(placed);
}
pub fn render<'b>(
&mut self,
buffer: &mut TextBuffer,
buffer: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
) -> &'b RenderedText {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextRenders);
#[cfg(feature = "layout-diagnostics")]
let _render = diag::timer(TimerKind::TextRender);
buffer.shape(self, attrs, width);
let glyphs = self.place(buffer);
RenderedText {
glyphs,
size: buffer.size(),
color: attrs.color,
}
// Only asked for when the buffer no longer holds them: taking one out
// of the store to then drop it would throw an answer away.
let placed = buffer.placed.take().or_else(|| {
let key = buffer.layout_key.as_ref()?;
self.take_placed(&buffer.text, key)
});
let placed = match placed {
Some(placed) => placed,
None => {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::GlyphPlacements);
#[cfg(feature = "layout-diagnostics")]
let _place = diag::timer(TimerKind::GlyphPlacement);
RenderedText {
glyphs: self.place(buffer),
size: buffer.size(),
color: attrs.color,
}
}
};
buffer.placed.insert(placed)
}
}
+46 -4
View File
@@ -1,11 +1,10 @@
use crate::{UiRegion, util::Id};
use crate::{UiRegion, util::Id, util::Vec2};
use wgpu::*;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct WindowUniform {
pub width: f32,
pub height: f32,
pub dim: Vec2,
}
#[repr(C)]
@@ -13,15 +12,17 @@ pub struct WindowUniform {
pub struct PrimitiveInstance {
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 5] = vertex_attr_array![
const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
5 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
@@ -43,4 +44,45 @@ impl MaskIdx {
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask {
pub region: UiRegion,
pub move_idx: MoveIdx,
}
/// Its own type rather than another `Id<u32>`, because it sits beside
/// `MaskIdx` in an instance and the two must not be swappable.
#[repr(transparent)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveIdx(u32);
impl MoveIdx {
pub const NONE: Self = Self(u32::MAX);
pub(crate) fn slot(idx: usize) -> Self {
Self(idx as u32)
}
pub(crate) fn idx(self) -> usize {
self.0 as usize
}
}
/// One link of the chain a primitive's position is resolved through: the box
/// its contents are placed within, given in the coordinates of the slot it
/// names. Moving or resizing a subtree writes its own slot and nothing else.
///
/// The identity is `UiRegion::FULL`, not zero: a zeroed entry is a box of no
/// extent, which collapses everything under it to a point.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct MoveOffset {
pub region: UiRegion,
pub parent: MoveIdx,
}
unsafe impl bytemuck::Pod for MoveOffset {}
unsafe impl bytemuck::Zeroable for MoveOffset {}
impl MoveOffset {
pub fn new(parent: MoveIdx, region: UiRegion) -> Self {
Self { region, parent }
}
}
+81 -18
View File
@@ -17,11 +17,15 @@ mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx};
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
fn module_source(wgsl: &str) -> String {
format!("{PRELUDE}\n{wgsl}")
}
pub struct UiRenderNode {
shared_layout: BindGroupLayout,
shared_group: BindGroup,
@@ -34,6 +38,7 @@ pub struct UiRenderNode {
active: Vec<usize>,
window_buffer: Buffer,
masks: ArrBuf<Mask>,
moves: ArrBuf<MoveOffset>,
}
struct RenderLayer {
@@ -127,32 +132,35 @@ impl UiRenderNode {
for primitive in &mut self.primitives {
primitive.render.update(ui);
}
let mut regroup = false;
if ui.masks.changed {
ui.masks.changed = false;
if self.masks.update(device, queue, &ui.masks[..]) {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
);
}
regroup |= self.masks.update(device, queue, &ui.masks[..]);
}
if ui_render.moves.changed {
ui_render.moves.changed = false;
regroup |= self.moves.update(device, queue, ui_render.moves.entries());
}
if regroup {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
&self.moves,
);
}
}
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform {
width: size.x,
height: size.y,
}];
let slice = &[WindowUniform { dim: size }];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let window_uniform = WindowUniform {
width: config.width as f32,
height: config.height as f32,
dim: Vec2::new(config.width as f32, config.height as f32),
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
@@ -166,7 +174,13 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
let moves = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let shared_group =
Self::shared_group(device, &shared_layout, &window_buffer, &masks, &moves);
Self {
shared_layout,
@@ -177,6 +191,7 @@ impl UiRenderNode {
layers: HashMap::default(),
active: Vec::new(),
masks,
moves,
}
}
@@ -211,7 +226,7 @@ impl UiRenderNode {
) -> RenderPipeline {
let module = device.create_shader_module(ShaderModuleDescriptor {
label: Some(label),
source: ShaderSource::Wgsl(format!("{PRELUDE}\n{wgsl}").into()),
source: ShaderSource::Wgsl(module_source(wgsl).into()),
});
device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some(label),
@@ -252,7 +267,8 @@ impl UiRenderNode {
})
}
/// What every draw in the ui is given: the window and the masks.
/// What every draw in the ui is given: the window, the masks and the
/// move chain every position is resolved through.
fn shared_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -276,6 +292,16 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<MoveOffset>() as u64),
},
count: None,
},
],
label: Some("ui shared"),
})
@@ -286,6 +312,7 @@ impl UiRenderNode {
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
moves: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -298,6 +325,10 @@ impl UiRenderNode {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: moves.buffer.as_entire_binding(),
},
],
label: Some("ui shared"),
})
@@ -374,3 +405,35 @@ impl ListBuffers {
}
}
}
#[cfg(test)]
mod tests {
use super::module_source;
use wgpu::naga::{
front::wgsl,
valid::{Capabilities, ValidationFlags, Validator},
};
/// Every shader file, composed as the renderer composes it, parses and
/// validates with no device -- so an edit that breaks one fails here and
/// not in the first window opened.
#[test]
fn every_shader_validates() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/render/shader");
let mut checked = 0;
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_none_or(|e| e != "wgsl") || path.ends_with("prelude.wgsl") {
continue;
}
let source = module_source(&std::fs::read_to_string(&path).unwrap());
let module = wgsl::parse_str(&source)
.unwrap_or_else(|e| panic!("{}: {}", path.display(), e.emit_to_string(&source)));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&module)
.unwrap_or_else(|e| panic!("{}: {e:?}", path.display()));
checked += 1;
}
assert!(checked > 0, "no shaders found in {dir}");
}
}
+11 -5
View File
@@ -3,7 +3,7 @@ use std::{any::TypeId, marker::PhantomData};
use crate::{
Color, TextureHandle, UiData, UiRegion, WidgetId,
render::{
data::{MaskIdx, PrimitiveInstance},
data::{MaskIdx, MoveIdx, PrimitiveInstance},
page::GlyphRender,
texture::ImageRender,
},
@@ -246,6 +246,7 @@ impl LayerDraws {
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
@@ -258,7 +259,11 @@ impl LayerDraws {
.get_or_insert_with(InstanceList::new::<P>)
.push(
id,
PrimitiveInstance { region, mask_idx },
PrimitiveInstance {
region,
mask_idx,
move_idx,
},
bytemuck::bytes_of(&primitive),
);
PrimitiveHandle {
@@ -304,6 +309,7 @@ pub struct PrimitiveInst<P> {
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
pub struct PrimitiveChange {
@@ -347,7 +353,7 @@ impl RectPrimitive {
/// `color` is multiplied by the atlas alpha for a mask glyph; a colour glyph
/// takes the texel unchanged, which `GlyphEntry::IS_COLORED` selects.
#[repr(C, align(8))]
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
pub uv_min: Vec2,
@@ -358,8 +364,8 @@ pub struct GlyphPrimitive {
pub flags: u32,
}
// Manual rather than derived: the align(8) leaves four bytes of padding, which
// is how WGSL lays the struct out.
// Manual rather than derived: `Vec2`'s alignment leaves four bytes of padding
// here, which is how WGSL lays the struct out.
unsafe impl bytemuck::Pod for GlyphPrimitive {}
unsafe impl bytemuck::Zeroable for GlyphPrimitive {}
impl Primitive for GlyphPrimitive {
+70 -13
View File
@@ -7,6 +7,8 @@
var<uniform> window: WindowUniform;
@group(0) @binding(1)
var<storage> masks: array<Mask>;
@group(0) @binding(2)
var<storage> move_offsets: array<MoveOffset>;
struct WindowUniform {
dim: vec2<f32>,
@@ -15,6 +17,52 @@ struct WindowUniform {
struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
}
struct MoveOffset {
x: UiSpan,
y: UiSpan,
parent: u32,
}
struct Region {
x: UiSpan,
y: UiSpan,
}
const MOVE_NONE: u32 = 4294967295u;
// Keep in step with `iris_core::CHAIN_LIMIT`. It bounds a malformed cycle
// rather than any real tree, and the CPU walk uses the same number so both
// resolve a deep one the same way.
const CHAIN_LIMIT: u32 = 64u;
// Written the way `UiScalar::within` writes it rather than as `mix`, so the
// CPU and the shader compose a position with the same arithmetic and answer
// the same thing about where a widget is.
fn scalar_within(s: UiScalar, p: UiSpan) -> UiScalar {
return UiScalar(
p.start.rel + (p.end.rel - p.start.rel) * s.rel,
s.px + (p.start.px + (p.end.px - p.start.px) * s.rel),
);
}
fn span_within(s: UiSpan, p: UiSpan) -> UiSpan {
return UiSpan(scalar_within(s.start, p), scalar_within(s.end, p));
}
fn resolve_move(idx: u32, local: Region) -> Region {
var r = local;
var at = idx;
for (var step = 0u; step < CHAIN_LIMIT; step++) {
if at == MOVE_NONE {
break;
}
let entry = move_offsets[at];
r = Region(span_within(r.x, entry.x), span_within(r.y, entry.y));
at = entry.parent;
}
return r;
}
struct UiSpan {
@@ -24,7 +72,7 @@ struct UiSpan {
struct UiScalar {
rel: f32,
abs: f32,
px: f32,
}
struct InstanceInput {
@@ -33,6 +81,7 @@ struct InstanceInput {
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) mask_idx: u32,
@location(5) move_idx: u32,
}
struct VertexOutput {
@@ -52,13 +101,18 @@ fn vs_main(
) -> VertexOutput {
var out: VertexOutput;
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let local = Region(
UiSpan(UiScalar(in.x_start.x, in.x_start.y), UiScalar(in.x_end.x, in.x_end.y)),
UiSpan(UiScalar(in.y_start.x, in.y_start.y), UiScalar(in.y_end.x, in.y_end.y)),
);
let r = resolve_move(in.move_idx, local);
let top_left_rel = vec2(r.x.start.rel, r.y.start.rel);
let top_left_px = vec2(r.x.start.px, r.y.start.px);
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 = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_px);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_px);
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -81,13 +135,16 @@ fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
return color;
}
let mask = masks[in.mask_idx];
let tl = vec2(mask.x.start.rel, mask.y.start.rel);
let tl_abs = vec2(mask.x.start.abs, mask.y.start.abs);
let br = vec2(mask.x.end.rel, mask.y.end.rel);
let br_abs = vec2(mask.x.end.abs, mask.y.end.abs);
// Its own chain, not the drawn primitive's, so a stationary viewport
// clips content that moves inside it.
let m = resolve_move(mask.move_idx, Region(mask.x, mask.y));
let tl = vec2(m.x.start.rel, m.y.start.rel);
let tl_px = vec2(m.x.start.px, m.y.start.px);
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 = floor(tl * window.dim) + floor(tl_abs);
let bot_right = floor(br * window.dim) + floor(br_abs);
let top_left = floor(tl * window.dim) + floor(tl_px);
let bot_right = floor(br * window.dim) + floor(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;
+28 -3
View File
@@ -1,4 +1,6 @@
use crate::{LayerId, MaskIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing
#[derive(Debug)]
@@ -7,14 +9,37 @@ pub struct ActiveData {
pub region: UiRegion,
/// What the widget said it used of `region`, the last time it drew.
pub size: Size,
/// The pixel size of the box it drew against. `region` alone cannot say:
/// it is a fraction of a slot's box, and the same fraction of a box that
/// has since changed is a different number of pixels.
pub px: Vec2,
/// The pixel size of the box its parent first asked about it in, before
/// knowing what it came to. `px` may be a box derived from that answer,
/// and a size measured there is only the same answer asked again.
pub offered_px: Vec2,
pub parent: Option<WidgetId>,
/// How far down the tree it was drawn, the root being 1. Carried down a
/// draw rather than worked out by walking up, so it is right for every
/// widget a frame visits and cannot drift while one is being drawn.
pub depth: usize,
pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub size_deps: Vec<WidgetId>,
/// Whether it read the output's size, and so is wrong when that changes.
pub reads_output: bool,
/// Offered pixel axes which flowed into this widget's reported size,
/// directly or through a child size it read.
pub size_box_inputs: [bool; 2],
/// Output axes read while producing `size`, distinct from the widget's
/// own box when that box has a fixed pixel length.
pub size_output_inputs: [bool; 2],
/// The output dimensions against which those dependencies were observed.
pub output_px: Vec2,
/// The slot its primitives are positioned through: its own if its parent
/// placed it, otherwise the nearest ancestor that has one.
pub move_idx: MoveIdx,
/// The slot `region` is given in, which is whatever its parent drew in.
pub parent_move: MoveIdx,
pub mask: MaskIdx,
pub layer: LayerId,
}
+90 -1
View File
@@ -1,7 +1,14 @@
use crate::{
Mask, PrimitiveRegistry, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
Mask, MoveIdx, MoveOffset, PrimitiveRegistry, TextData, Textures, UiRegion, WeakWidget,
WidgetId, Widgets,
util::{Arena, Id, TrackedArena},
};
/// How far the shader will walk a move chain. It bounds a malformed cycle
/// rather than any real tree; `Moves::resolve` uses the same number so the
/// two agree on what a deep tree resolves to.
pub const CHAIN_LIMIT: u32 = 64;
mod active;
mod painter;
mod render_state;
@@ -20,6 +27,88 @@ pub struct UiData {
pub masks: TrackedArena<Mask, u32>,
}
/// Where each widget's drawing sits relative to its parent's slot, so moving
/// a subtree writes one entry rather than every descendant's primitives.
#[derive(Default)]
pub struct Moves {
arena: Arena<MoveOffset, u32>,
pub changed: bool,
}
impl Moves {
pub fn push(&mut self, parent: MoveIdx, region: UiRegion) -> MoveIdx {
self.changed = true;
MoveIdx::slot(self.arena.push(MoveOffset::new(parent, region)).idx())
}
/// Re-points a slot at a different parent, for a widget drawn somewhere
/// else in the tree than it was.
pub fn set_parent(&mut self, idx: MoveIdx, parent: MoveIdx) {
let entry = self.arena.get_mut(Id::preset(idx.idx() as u32));
if entry.parent != parent {
entry.parent = parent;
self.changed = true;
}
}
pub fn remove(&mut self, idx: MoveIdx) {
self.changed = true;
self.arena.remove(Id::preset(idx.idx() as u32));
}
/// Sets the box a slot's contents are placed within, itself given in the
/// coordinates of its parent slot.
pub fn set(&mut self, idx: MoveIdx, region: UiRegion) {
let entry = self.arena.get_mut(Id::preset(idx.idx() as u32));
if entry.region != region {
entry.region = region;
self.changed = true;
}
}
/// Composes a region held in `idx`'s coordinates down the chain, which is
/// the same walk the vertex shader does.
pub fn resolve(&self, idx: MoveIdx, local: UiRegion) -> UiRegion {
let mut region = local;
let mut at = idx;
for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE {
return region;
}
let entry = self.arena[at.idx()];
region = region.within(&entry.region);
at = entry.parent;
}
debug_assert!(
at == MoveIdx::NONE,
"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
/// the shader's walk costs per primitive.
pub fn depth(&self, idx: MoveIdx) -> usize {
let mut depth = 0;
let mut at = idx;
while at != MoveIdx::NONE && depth < CHAIN_LIMIT as usize {
at = self.arena[at.idx()].parent;
depth += 1;
}
depth
}
pub fn entries(&self) -> &[MoveOffset] {
&self.arena
}
pub fn clear(&mut self) {
self.changed = true;
self.arena = Arena::default();
}
}
pub trait UiRsc {
fn ui(&self) -> &UiData;
fn ui_mut(&mut self) -> &mut UiData;
+199 -28
View File
@@ -1,9 +1,11 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, PrimitiveKind,
TexturePrimitive,
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
PrimitiveKind, TexturePrimitive,
},
util::Vec2,
};
@@ -13,15 +15,25 @@ pub struct Painter<'a> {
pub(super) state: &'a mut UiRenderState,
pub(super) rsc: &'a mut dyn UiRsc,
/// This widget's box, in the coordinates of `move_idx`.
pub(super) region: UiRegion,
pub(super) mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>,
/// The children asked about so far, so the first box each was asked
/// about is the one recorded as its offer.
pub(super) offered: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
pub(super) reads_output: bool,
/// Offered pixel axes which can affect the size this draw reports.
pub(super) size_box_inputs: [bool; 2],
pub(super) size_output_inputs: [bool; 2],
/// The slot this widget's primitives are positioned through: its own if
/// its parent placed it, otherwise the nearest ancestor that has one.
pub(super) move_idx: MoveIdx,
pub layer: usize,
pub(super) depth: usize,
pub(super) id: WidgetId,
}
@@ -33,6 +45,8 @@ impl<'a> Painter<'a> {
/// Takes the kind, for a caller writing many of one primitive.
fn write<P: Primitive>(&mut self, kind: PrimitiveKind<P>, primitive: P, region: UiRegion) {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PrimitiveWrites);
let h = self.state.layers.write(
self.layer,
PrimitiveInst {
@@ -41,6 +55,7 @@ impl<'a> Painter<'a> {
primitive,
region,
mask_idx: self.mask,
move_idx: self.move_idx,
},
);
self.push_primitive(h);
@@ -67,29 +82,51 @@ impl<'a> Painter<'a> {
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask { region });
self.mask = self.rsc.ui_mut().masks.push(Mask {
region,
move_idx: self.move_idx,
});
}
/// Draws a widget within this widget's region.
pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget<W>) -> DrawResult<'s, 'a, W> {
self.widget_at(id, self.region)
self.widget_at(id, self.region, false)
}
/// Draws a widget somewhere within this one. Drawing one a second time
/// gives it a new box, keeping the drawing it already has where it can.
/// Draws a widget somewhere within this one.
pub fn widget_within<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'s, 'a, W> {
let region = region.within(&self.region);
self.widget_at(id, region)
self.widget_at(id, region, false)
}
/// Draws a child this widget decides the box of, and may decide again
/// once it knows what the child came to. The child gets a slot of its
/// own, so placing it a second time writes one entry however much it
/// drew -- moved or resized alike, since everything under the slot is
/// held as a fraction of its box. A child drawn any other way has no slot
/// and can only be given a different box by drawing again.
pub fn place<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'s, 'a, W> {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PlaceCalls);
let region = region.within(&self.region);
#[cfg(feature = "layout-diagnostics")]
diag::placed(id.id(), self.id, region);
self.widget_at(id, region, true)
}
fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
slotted: bool,
) -> DrawResult<'s, 'a, W> {
// A child listed twice would be moved twice.
if !self.children.contains(&id.id()) {
@@ -100,10 +137,14 @@ impl<'a> Painter<'a> {
id.id(),
region,
Some(self.id),
self.depth + 1,
self.move_idx,
slotted,
self.mask,
None,
self.rsc,
);
self.offer(id.id(), region);
DrawResult {
child: id,
painter: self,
@@ -114,23 +155,119 @@ impl<'a> Painter<'a> {
/// What a child says its length is without being drawn, if it can say.
/// Asking counts as reading its size.
pub fn size_hint<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> {
let hint = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis)?;
self.depend_on_size(id);
Some(hint)
}
fn depend_on_size<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
if !self.size_deps.contains(&child.id()) {
self.size_deps.push(child.id());
let hint = self
.rsc
.widgets()
.get_dyn(id.id())
.and_then(|widget| widget.size_hint(axis));
#[cfg(feature = "layout-diagnostics")]
diag::hint_read(id.id(), self.id, axis, hint);
match hint {
Some(hint) => {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::HintHits);
self.depend_on_hint(id);
Some(hint)
}
None => {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::HintMisses);
None
}
}
}
pub fn render_text(
/// A retained child length valid under the region it is about to be
/// offered. Unlike a hint, this is contextual: it is kept only when none
/// of the offered pixel axes which produced it changed.
pub fn known_len<W: ?Sized>(
&mut self,
buffer: &mut TextBuffer,
child: &StrongWidget<W>,
axis: Axis,
region: UiRegion,
) -> Option<Len> {
let region = region.within(&self.region);
self.offer(child.id(), region);
if let Some(hint) = self.size_hint(child, axis) {
return Some(hint);
}
self.retained_size(child, region)
.map(|size| size.axis(axis))
}
/// `region` in this widget's own coordinates.
fn retained_size<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
region: UiRegion,
) -> Option<Size> {
let (size, box_inputs, output_inputs) =
self.state
.retained_size(child.id(), region, self.move_idx, self.rsc.widgets())?;
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::RetainedSizeHits);
self.depend_on_size_inputs(child, box_inputs, output_inputs);
Some(size)
}
/// Records the box a child was first asked about in this draw. Any later
/// box this draw gives it was decided knowing its answer, so a size the
/// child measures there is not an answer to this widget's question.
fn offer(&mut self, child: WidgetId, region: UiRegion) {
if self.offered.contains(&child) {
return;
}
self.offered.push(child);
let px = self.state.px_of(self.move_idx, region);
if let Some(active) = self.state.active.get_mut(&child) {
active.offered_px = px;
}
}
/// Depends on a length the child gave without being drawn. A hint is
/// context-free, so this depends on the child but on no pixel axis.
fn depend_on_hint<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
self.depend_on_size_inputs(child, [false; 2], [false; 2]);
}
/// Depends on a size the child produced by drawing, which carries
/// whatever the child read to produce it.
fn depend_on_drawn_size<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
let (box_inputs, output_inputs) = self
.state
.active
.get(&child.id())
.map_or(([false; 2], [false; 2]), |active| {
(active.size_box_inputs, active.size_output_inputs)
});
self.depend_on_size_inputs(child, box_inputs, output_inputs);
}
fn depend_on_size_inputs<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
box_inputs: [bool; 2],
output_inputs: [bool; 2],
) {
if !self.size_deps.contains(&child.id()) {
self.size_deps.push(child.id());
}
for (own, child) in self.size_box_inputs.iter_mut().zip(box_inputs) {
*own |= child;
}
for (own, child) in self.size_output_inputs.iter_mut().zip(output_inputs) {
*own |= child;
}
}
pub fn render_text<'b>(
&mut self,
buffer: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
) -> &'b RenderedText {
#[cfg(feature = "layout-diagnostics")]
diag::render_text(self.id, self.rsc.widgets().label(self.id), width);
let ui = self.rsc.ui_mut();
ui.text.render(buffer, attrs, width)
}
@@ -142,9 +279,9 @@ impl<'a> Painter<'a> {
let mut region = origin;
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::abs(glyph.offset));
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
let mut region = region.offset(UiVec2::px(glyph.offset));
region.x.end = region.x.start + UiScalar::px(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::px(glyph.entry.height as f32);
self.write(
kind,
GlyphPrimitive {
@@ -159,6 +296,8 @@ impl<'a> Painter<'a> {
}
}
/// This widget's box, in the coordinates its own primitives are written
/// in -- so a region composed `within` it may be drawn directly.
pub fn region(&self) -> UiRegion {
self.region
}
@@ -166,15 +305,42 @@ impl<'a> Painter<'a> {
/// The output's size in pixels. A widget that reads it draws again when
/// the output changes, since nothing else can put that right.
pub fn output_size(&mut self) -> Vec2 {
self.reads_output = true;
self.size_output_inputs = [true; 2];
self.state.output_size
}
/// This widget's box in pixels. Resolved against the output's size, so a
/// widget that reads it draws again when the output changes.
/// One axis of the output in pixels. Prefer this to [`Self::output_size`]
/// when the other axis cannot affect the size this widget reports.
pub fn output_len(&mut self, axis: Axis) -> f32 {
self.size_output_inputs[axis as usize] = true;
self.state.output_size.axis(axis)
}
/// This widget's box in pixels. Resolved against the output's size and
/// the boxes it sits within, so a widget that reads it draws again when
/// the output changes.
pub fn px_size(&mut self) -> Vec2 {
self.reads_output = true;
self.region.size().to_abs(self.state.output_size)
self.size_box_inputs = [true; 2];
let region = self.state.moves.resolve(self.move_idx, self.region);
region.size().to_px(self.state.output_size)
}
/// One axis of this widget's box in pixels. Prefer this to
/// [`Self::px_size`] when the other axis cannot affect the reported size.
pub fn px_len(&mut self, axis: Axis) -> f32 {
self.size_box_inputs[axis as usize] = true;
self.px_len_for_draw(axis)
}
/// One axis of this widget's box in pixels, for a draw whose reported
/// size does not follow from it -- a clamp or a position. Nothing records
/// the read, so a size that does depend on it would go stale.
pub fn px_len_for_draw(&self, axis: Axis) -> f32 {
let region = self.state.moves.resolve(self.move_idx, self.region);
region
.size()
.axis(axis)
.to_px(self.state.output_size.axis(axis))
}
pub fn text_data(&mut self) -> &mut TextData {
@@ -209,7 +375,12 @@ pub struct DrawResult<'p, 'a, W: ?Sized> {
impl<W: ?Sized> DrawResult<'_, '_, W> {
pub fn size(self) -> Size {
self.painter.depend_on_size(self.child);
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::SizeReads);
diag::size_read(self.child.id(), self.painter.id, self.size);
}
self.painter.depend_on_drawn_size(self.child);
self.size
}
+574 -93
View File
@@ -1,17 +1,40 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::{
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, OnResize, Painter, PixelRegion, Remap, Size,
StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
util::{HashMap, HashSet, Vec2, forget_ref},
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId, Widgets,
util::{HashMap, HashSet, Vec2},
};
const AXES: [Axis; 2] = [Axis::X, Axis::Y];
const LAYOUT_EPSILON_PX: f32 = 0.05;
fn pixel_len_changed(old: f32, new: f32) -> bool {
(old - new).abs() > LAYOUT_EPSILON_PX
}
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: DrawLayers,
pub(super) output_size: Vec2,
old_root: Option<WidgetId>,
resized: bool,
/// The slot every chain bottoms out in, holding the output as a box.
root_move: MoveIdx,
/// Widgets whose reported size depends on the root box rather than on
/// their own, so nothing below them changing length can reach them.
root_readers: HashSet<WidgetId>,
/// Content/state dirtiness whose retained size cannot answer a layout
/// question until that widget has drawn again.
invalid_sizes: HashSet<WidgetId>,
/// What has already been drawn during the pass under way, so a widget
/// reached by redrawing an ancestor is not drawn again on its own
/// account. Emptied when the pass ends.
draw_started: HashSet<WidgetId>,
/// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot.
slots: HashMap<WidgetId, MoveIdx>,
pub moves: Moves,
}
impl UiRenderState {
@@ -21,14 +44,48 @@ impl UiRenderState {
layers: Default::default(),
output_size: Vec2::ZERO,
old_root: None,
resized: false,
invalid_sizes: Default::default(),
draw_started: Default::default(),
slots: Default::default(),
moves: Default::default(),
root_move: MoveIdx::NONE,
root_readers: Default::default(),
}
}
/// 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(UiScalar::ZERO, UiScalar::px(self.output_size.x)),
UiSpan::new(UiScalar::ZERO, UiScalar::px(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),
}
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.output_size = size.into();
self.resized = true;
self.write_root();
}
/// Which axes of the root widget's box are no longer the ones the root
/// slot holds, which is all a resize now is: one slot written, found by
/// the same comparison every other box change is found by.
fn root_axes_changed(&self) -> [bool; 2] {
let Some(active) = self.old_root.and_then(|root| self.active.get(&root)) else {
return [false; 2];
};
let px = self.px_of(active.parent_move, active.region);
let mut changed = [false; 2];
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
*c = pixel_len_changed(active.px.axis(axis), px.axis(axis));
}
changed
}
pub fn output_size(&self) -> Vec2 {
@@ -36,6 +93,13 @@ impl UiRenderState {
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::Updates);
#[cfg(feature = "layout-diagnostics")]
let _update = diag::timer(TimerKind::Update);
self.invalid_sizes.clear();
self.invalid_sizes
.extend(rsc.widgets().needs_redraw.iter().copied());
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
if !rsc.widgets().waiting.is_empty() {
@@ -56,27 +120,74 @@ impl UiRenderState {
if self.root_changed(root) {
self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id());
} else if self.resized {
// A region is a fraction of the output plus an offset, resolved
// against the window in the shader, so a resize moves the whole
// drawing on its own. Only a widget that read pixels can be wrong.
for (&id, active) in &self.active {
if active.reads_output {
rsc.widgets_mut().needs_redraw.insert(id);
} else if self.root_axes_changed().iter().any(|&c| c) {
// Every box is a part of the root box, so writing it is a box
// that changed length like any other. Offering the root widget
// its box again puts that through `try_reuse`, which answers per
// axis and lets `redraws_under` price the subtree -- rather than
// marking it, which would redraw it whichever axis moved. What
// that cannot reach is a widget whose size came from the root box
// instead of its own, since its own box need not have changed.
#[cfg(feature = "layout-diagnostics")]
let _marking = diag::timer(TimerKind::ResizeMarking);
let changed = self.root_axes_changed();
for id in self.root_readers.clone() {
let reads = self
.active
.get(&id)
.map_or([false; 2], |active| active.size_output_inputs);
if !AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && reads[axis as usize])
{
continue;
}
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeDependents);
rsc.widgets_mut().needs_redraw.insert(id);
}
if let Some(root) = root {
self.draw_inner(
0,
root.id(),
UiRegion::FULL,
None,
1,
self.root_move,
false,
MaskIdx::NONE,
None,
rsc,
);
}
}
self.resized = false;
if rsc.widgets().has_updates() {
self.redraw_updates(rsc);
}
self.invalid_sizes.clear();
self.draw_started.clear();
}
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
#[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 {
self.draw_inner(0, id.id(), UiRegion::FULL, None, MaskIdx::NONE, None, rsc);
self.draw_inner(
0,
id.id(),
UiRegion::FULL,
None,
1,
self.root_move,
false,
MaskIdx::NONE,
None,
rsc,
);
}
}
@@ -88,53 +199,90 @@ impl UiRenderState {
id: WidgetId,
region: UiRegion,
parent: Option<WidgetId>,
depth: usize,
parent_move: MoveIdx,
slotted: bool,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
mut old: Option<ActiveData>,
rsc: &mut dyn UiRsc,
) -> Size {
let mut old_children = old_children.unwrap_or_default();
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::DrawRequests);
diag::draw_request(id, parent, region, self.px_of(parent_move, region), slotted);
}
if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, rsc) {
if let Some(size) = self.try_reuse(id, region, depth, parent_move, rsc) {
return size;
}
// if not, then maintain resize and track old children to remove unneeded
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
old = self.remove(id, false, rsc);
}
// draw widget
let (move_idx, local) = match slotted {
// Its box becomes its slot's, so it draws in the slot's own
// coordinates and the box it was given is one entry to rewrite.
true => (self.move_slot(id, parent_move, region), UiRegion::FULL),
false => {
self.drop_slot(id);
(parent_move, region)
}
};
let px = self.px_of(move_idx, local);
// Drawn again in a box its parent already decided: the offer is the
// one recorded when the parent first asked, not this box.
let (old_children, offered_px) = match old {
Some(old) => (old.children, old.offered_px),
None => (Vec::new(), px),
};
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id);
let mut painter = Painter {
state: self,
region,
region: local,
mask,
layer,
id,
textures: Vec::new(),
primitives: Vec::new(),
children: Vec::new(),
offered: Vec::new(),
size_deps: Vec::new(),
reads_output: false,
depth,
size_box_inputs: [false; 2],
size_output_inputs: [false; 2],
move_idx,
rsc,
};
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::WidgetDraws);
diag::draw_widget(id, painter.rsc.widgets().label(id));
}
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
let size = widget.draw(&mut painter);
drop(widget);
#[cfg(feature = "layout-diagnostics")]
diag::size_reported(id, size);
let Painter {
state: _,
rsc: _,
region,
region: _,
mask,
textures,
primitives,
children,
offered: _,
size_deps,
reads_output,
size_box_inputs,
size_output_inputs,
move_idx,
layer,
depth: _,
id,
} = painter;
@@ -149,16 +297,22 @@ impl UiRenderState {
id,
region,
size,
px,
offered_px,
parent,
depth,
textures,
primitives,
children,
size_deps,
reads_output,
size_box_inputs,
size_output_inputs,
output_px: self.output_size,
move_idx,
parent_move,
mask,
layer,
};
// remove old children that weren't kept
for c in &old_children {
if !active.children.contains(c) {
@@ -166,54 +320,260 @@ impl UiRenderState {
}
}
match active.size_output_inputs.iter().any(|&reads| reads) {
true => self.root_readers.insert(id),
false => self.root_readers.remove(&id),
};
rsc.on_draw(&active);
self.active.insert(id, active);
self.invalid_sizes.remove(&id);
size
}
/// The slot a widget's box is held in, made on its first placed draw and
/// kept until it stops being drawn -- a redraw replaces its `ActiveData`
/// while descendants go on naming the slot.
fn move_slot(&mut self, id: WidgetId, parent: MoveIdx, region: UiRegion) -> MoveIdx {
if let Some(&idx) = self.slots.get(&id) {
self.moves.set_parent(idx, parent);
self.moves.set(idx, region);
return idx;
}
let idx = self.moves.push(parent, region);
self.slots.insert(id, idx);
idx
}
/// Gives up a slot a widget no longer needs, because it is drawn somewhere
/// that does not place it. Its descendants name it, so this is only
/// reached where they are about to be drawn again.
fn drop_slot(&mut self, id: WidgetId) {
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
}
/// The pixel size of a region held in `slot`'s coordinates.
pub(super) fn px_of(&self, slot: MoveIdx, region: UiRegion) -> Vec2 {
self.moves
.resolve(slot, region)
.size()
.to_px(self.output_size)
}
/// A clean widget's retained size, when the offered pixel axes which
/// produced that answer are unchanged. This observes the old answer only;
/// it does not move or otherwise reuse the widget's drawing.
pub(super) fn retained_size(
&self,
id: WidgetId,
region: UiRegion,
parent_move: MoveIdx,
widgets: &Widgets,
) -> Option<(Size, [bool; 2], [bool; 2])> {
if self.size_is_invalid(id, widgets) || self.dirty_size_under(id, widgets) {
return None;
}
let active = self.active.get(&id)?;
if active.parent_move != parent_move {
return None;
}
let px = self.px_of(parent_move, region);
let valid_box = AXES
.into_iter()
.zip(active.size_box_inputs)
.all(|(axis, depends)| {
!depends || !pixel_len_changed(active.px.axis(axis), px.axis(axis))
});
let valid_output =
AXES.into_iter()
.zip(active.size_output_inputs)
.all(|(axis, depends)| {
!depends
|| !pixel_len_changed(
active.output_px.axis(axis),
self.output_size.axis(axis),
)
});
(valid_box && valid_output).then_some((
active.size,
active.size_box_inputs,
active.size_output_inputs,
))
}
fn size_is_invalid(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.invalid_sizes.contains(&id) || widgets.needs_redraw.contains(&id)
}
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| {
self.size_is_invalid(*child, widgets) || self.dirty_size_under(*child, widgets)
})
})
}
/// The drawing a widget already has, kept for a new box if the box has not
/// changed in a way it depends on.
fn try_reuse(&mut self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> Option<Size> {
fn try_reuse(
&mut self,
id: WidgetId,
region: UiRegion,
depth: usize,
parent_move: MoveIdx,
rsc: &mut dyn UiRsc,
) -> Option<Size> {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ReuseAttempts);
// Only its own dirtiness, not anything dirty under it that could
// change the size this hands back. What makes that safe is the order
// `redraw_updates` settles in, and nothing else: by the time a reader
// draws, everything dirty below it has been drawn and has propagated.
// Draw in another order and this returns a stale size -- measured, on
// seed 2 of `tests/generated.rs`.
if rsc.widgets().needs_redraw.contains(&id) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDirty);
diag::reuse(id, ReuseOutcome::Dirty);
}
return None;
}
let active = self.active.get(&id)?;
let (size, old) = (active.size, active.region);
if old == region {
return Some(size);
}
// TODO: epsilon?
if old.size() != region.size() && !self.reusable(id, region, rsc) {
// Drawn somewhere else in the tree: its box is in coordinates it no
// longer sits in, and its slot names the wrong parent.
if active.parent_move != parent_move {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseWrongParent);
diag::reuse(id, ReuseOutcome::WrongParent);
}
return None;
}
// Its drawing stands, if the new box can be reached from the old one.
self.mov(id, &Remap::new(old, region)?);
let (size, old_region, slot, old_px) =
(active.size, active.region, active.move_idx, active.px);
// 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.
let px = self.px_of(parent_move, region);
let mut changed = [false; 2];
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
*c = pixel_len_changed(old_px.axis(axis), px.axis(axis));
}
if !changed.iter().any(|&c| c) && old_region == region {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseExact);
diag::reuse(id, ReuseOutcome::Exact);
}
self.keep_depth(id, depth);
return Some(size);
}
// Only a placed widget can be given a different *region* without
// drawing again: it has an entry of its own to say where it went,
// where an unslotted one shares its parent's and has nothing to
// write. Its parent's box changing length is not that -- everything
// it drew is a fraction of that box, so the slot already above it
// carries the change and `on_resize` below decides whether the
// drawing survives it.
if slot == parent_move && old_region != region {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseUnslotted);
diag::reuse(id, ReuseOutcome::Unslotted);
}
return None;
}
if changed.iter().any(|&c| c) {
let widget = rsc.widgets().get_dyn(id)?;
let redraws = AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale);
// Anything under it that has to be drawn again is drawn by drawing
// this, because whatever reads that widget's size sits in between
// and has to lay out around what it comes to.
if redraws {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseOwnResize);
diag::reuse(id, ReuseOutcome::OwnResize);
}
return None;
}
if self.redraws_under(id, changed, rsc) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDescendantResize);
diag::reuse(id, ReuseOutcome::DescendantResize);
}
return None;
}
}
if slot != parent_move {
self.moves.set(slot, region);
}
self.keep_depth(id, depth);
let active = self.active.get_mut(&id).unwrap();
active.region = region;
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseMoved);
diag::reuse(id, ReuseOutcome::Moved);
}
Some(size)
}
/// Whether the widget can keep the drawing it has and be given `region`
/// instead, asked one axis at a time: a change on an axis it does not
/// depend on costs nothing, whatever it depends on elsewhere.
fn reusable(&self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> bool {
/// Whether anything under `id` would have to be drawn again for the box
/// it is a fraction of changing length, `changed` saying which axes of
/// that box did.
///
/// A part of a box with no relative extent on an axis is a fixed length,
/// held as offsets from that box's start, and composing anything into it
/// leaves no relative extent either. So a widget whose own box did not
/// change length has no descendant whose box did, and the walk stops
/// there -- an 80-wide child of a widened row is not asked at all.
fn redraws_under(&self, id: WidgetId, changed: [bool; 2], rsc: &dyn UiRsc) -> bool {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeChecks);
let Some(active) = self.active.get(&id) else {
return false;
};
let Some(widget) = rsc.widgets().get_dyn(id) else {
return false;
};
[Axis::X, Axis::Y].into_iter().all(|axis| {
let offered = region.axis(axis).len();
let had = active.region.axis(axis).len();
match widget.on_resize(axis) {
OnResize::Scale => true,
// `Translate` is not acted on yet, and cannot be until a
// drawing can sit somewhere other than its box. `region` is
// both the box a widget was given and the box its primitives
// are in, and `mov` remaps from it -- so carrying a drawing at
// its old size while the box grows makes the next move stretch
// it. The offset chain is what separates the two.
OnResize::Translate | OnResize::Redraw => offered == had,
let size_deps = &active.size_deps;
active.children.iter().any(|&child| {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeCheckChildren);
let Some(data) = self.active.get(&child) else {
return false;
};
let Some(widget) = rsc.widgets().get_dyn(child) else {
return true;
};
// What it drew to learn this child's size was the child in *this*
// box, so a different box is a different answer -- unless the
// child gave an exact one without being drawn at all.
if size_deps.contains(&child) {
let measured = AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && widget.size_hint(axis).is_none());
if measured {
return true;
}
}
let mut own = changed;
for (axis, c) in AXES.into_iter().zip(own.iter_mut()) {
*c &= data.region.axis(axis).len().rel != 0.0;
}
if !own.iter().any(|&c| c) {
return false;
}
let redraws = AXES
.into_iter()
.zip(own)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale);
redraws || self.redraws_under(child, own, rsc)
})
}
@@ -221,27 +581,13 @@ impl UiRenderState {
let Some(widget) = rsc.widgets().get_dyn(id) else {
return true;
};
[Axis::X, Axis::Y].into_iter().all(|axis| {
AXES.into_iter().all(|axis| {
widget
.size_hint(axis)
.is_none_or(|hint| hint == size.axis(axis))
})
}
fn mov(&mut self, id: WidgetId, remap: &Remap) {
let active = self.active.get_mut(&id).unwrap();
for h in &active.primitives {
let region = self.layers[h.layer].region_mut(h);
*region = remap.apply(*region);
}
active.region = remap.apply(active.region);
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for child in children {
self.mov(*child, remap);
}
}
/// NOTE: instance textures are cleared and self.textures freed
fn remove(&mut self, id: WidgetId, undraw: bool, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let mut active = self.active.remove(&id);
@@ -268,6 +614,10 @@ impl UiRenderState {
self.remove_rec(*c, rsc);
}
}
// After the descendants, whose slots name this one as their parent.
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
inst
}
@@ -275,18 +625,70 @@ impl UiRenderState {
for (_, active) in self.active.drain() {
rsc.on_undraw(&active);
}
self.slots.clear();
self.moves.clear();
self.root_move = MoveIdx::NONE;
self.layers.clear();
self.invalid_sizes.clear();
self.draw_started.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
}
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
while let Some(&id) = rsc.widgets().needs_redraw.iter().next() {
#[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::IncrementalLayout);
// A reader's answer is only valid after every dirty size it reads has
// settled, and taking the deepest first is what arranges that --
// `try_reuse` hands back a retained size without asking whether
// anything dirty sits under it, so this order is load-bearing for the
// answer and not only for the cost. Equal-depth widgets are
// independent, so their order does not matter. Resize dirtiness already marks whole reader chains, so
// choosing their shallowest roots coalesces descendants that share a
// reader and gives each changing box its final constraints first.
while let Some(id) = {
let dirty = rsc.widgets().needs_redraw.iter().copied();
dirty.max_by_key(|&id| self.depth(id))
} {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::QueuePops);
self.redraw(id, rsc);
}
rsc.free();
}
/// Keeps a reused widget's depth current, since being reused is being
/// visited: only a subtree nobody looked at can hold a stale one.
fn keep_depth(&mut self, id: WidgetId, depth: usize) {
if let Some(active) = self.active.get_mut(&id) {
active.depth = depth;
}
}
fn depth(&self, id: WidgetId) -> usize {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::DepthReads);
let depth = self.active.get(&id).map_or(1, |active| active.depth);
debug_assert_eq!(
depth,
self.walked_depth(id),
"a widget's kept depth is not the one its ancestry says"
);
depth
}
/// What the kept depth is checked against, and the only thing that reads
/// the ancestry to find one.
fn walked_depth(&self, id: WidgetId) -> usize {
let mut depth = 0;
let mut at = Some(id);
while let Some(id) = at {
at = self.active.get(&id).and_then(|active| active.parent);
depth += 1;
}
depth
}
pub fn root_changed<'a>(&self, root: impl Into<Option<&'a StrongWidget>>) -> bool {
root.into().map(|r| r.id()) != self.old_root
}
@@ -296,7 +698,9 @@ impl UiRenderState {
root: impl Into<Option<&'a StrongWidget>>,
widgets: &Widgets,
) -> bool {
self.root_changed(root) || self.resized || widgets.has_updates()
self.root_changed(root)
|| self.root_axes_changed().iter().any(|&c| c)
|| widgets.has_updates()
}
pub fn active_widgets(&self) -> usize {
@@ -322,22 +726,43 @@ 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.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let region = self.active.get(&id.id())?.region;
let active = self.active.get(&id.id())?;
let region = self.moves.resolve(active.parent_move, active.region);
Some(region.to_px(self.output_size))
}
/// redraws a widget that's currently active (drawn)
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
self.draw_started.remove(&id);
// Whoever read this widget's size may be a different size now, so the
// highest reader is what draws. Everything between the two is marked
// as well: their own boxes have not changed, so the mark is the only
// thing stopping the draw reusing its way past this widget.
if let Some(top) = self.mark_readers(id, rsc) {
if rsc.widgets().needs_redraw.contains(&id) {
self.invalid_sizes.insert(id);
}
// A widget can only answer whether its size changed by drawing in the
// box its parent chose. If that box changed in pixels, its retained
// placement is stale and the highest size reader must choose the new
// box first. The same holds when the box was decided from the
// widget's own answer: measuring there again can only repeat it,
// whatever the content now says. Otherwise the widget can draw
// locally, and its readers only matter if the returned size actually
// changed.
let box_changed = self.active.get(&id).is_some_and(|active| {
let px = self.px_of(active.parent_move, active.region);
AXES.into_iter()
.any(|axis| pixel_len_changed(active.px.axis(axis), px.axis(axis)))
});
let top = match box_changed {
true => self.top_reader(id),
false => None,
}
.or_else(|| self.derived_box_reader(id));
if let Some(top) = top {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::EagerReaderRedraws);
self.mark_below(id, top, rsc);
self.redraw(top, rsc);
// Cleared by that draw if it reached here; if it did not, this is
// no longer drawn and asking again would not end.
rsc.widgets_mut().needs_redraw.remove(&id);
return;
}
@@ -350,35 +775,91 @@ impl UiRenderState {
let Some(active) = self.remove(id, false, rsc) else {
return;
};
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::LocalRedraws);
self.draw_inner(
let old_size = active.size;
let size = self.draw_inner(
active.layer,
id,
active.region,
active.parent,
active.depth,
active.parent_move,
active.move_idx != active.parent_move,
active.mask,
Some(active.children),
Some(active),
rsc,
);
if size != old_size {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::SizeChanges);
if let Some(parent) = self.active.get(&id).and_then(|active| active.parent)
&& self
.active
.get(&parent)
.is_some_and(|active| active.size_deps.contains(&id))
{
// Propagate one dependency edge at a time. If drawing the reader
// does not change its own size, nothing above it can observe this.
rsc.widgets_mut().needs_redraw.insert(parent);
self.invalid_sizes.insert(parent);
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ReaderEdges);
}
}
}
/// The highest reader up the chain that gave what it read a box other
/// than the one it asked in, on an axis this widget's size reads. Above
/// it every box is a constraint rather than an answer. It is the highest
/// and not the nearest because a pass-through hands a derived box down
/// unchanged.
fn derived_box_reader(&self, id: WidgetId) -> Option<WidgetId> {
let reads = self.active.get(&id)?.size_box_inputs;
let mut top = None;
for (active, parent) in self.reader_chain(id) {
let px = self.px_of(active.parent_move, active.region);
if AXES.into_iter().zip(reads).any(|(axis, r)| {
r && pixel_len_changed(active.offered_px.axis(axis), px.axis(axis))
}) {
top = Some(parent);
}
}
top
}
/// The furthest ancestor that read this widget's size, directly or through
/// widgets that did the same, marking everything below it on the way.
fn mark_readers(&self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<WidgetId> {
let mut top = None;
/// widgets that did the same.
fn top_reader(&self, id: WidgetId) -> Option<WidgetId> {
self.reader_chain(id).last().map(|(_, parent)| parent)
}
/// Each widget from `id` upward whose parent read its size, with that
/// parent.
fn reader_chain(&self, id: WidgetId) -> impl Iterator<Item = (&ActiveData, WidgetId)> {
let mut at = Some(id);
std::iter::from_fn(move || {
let active = self.active.get(&at?)?;
let parent = active.parent?;
let read = self.active.get(&parent)?.size_deps.contains(&active.id);
at = read.then_some(parent);
read.then_some((active, parent))
})
}
/// Marks everything from `id` up to, and not including, `top`, so that
/// drawing `top` draws each of them rather than reusing it.
fn mark_below(&self, id: WidgetId, top: WidgetId, rsc: &mut dyn UiRsc) {
let mut at = id;
while let Some(active) = self.active.get(&at)
&& let Some(parent) = active.parent
&& self
.active
.get(&parent)
.is_some_and(|p| p.size_deps.contains(&at))
{
while at != top {
rsc.widgets_mut().needs_redraw.insert(at);
top = Some(parent);
let Some(parent) = self.active.get(&at).and_then(|active| active.parent) else {
return;
};
at = parent;
}
top
}
}
+4
View File
@@ -34,6 +34,10 @@ impl<T, I: IdNum> Arena<T, I> {
self.tracker.free(id);
self.data[i]
}
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
&mut self.data[id.idx()]
}
}
impl<T, I: IdNum> Default for Arena<T, I> {
+1 -10
View File
@@ -1,6 +1,5 @@
pub const trait LerpUtil: Sized {
pub const trait LerpUtil {
fn lerp(self, from: Self, to: Self) -> Self;
fn lerp_inv(self, from: Self, to: Self) -> Option<Self>;
}
const impl LerpUtil for f32 {
@@ -9,14 +8,6 @@ const impl LerpUtil for f32 {
fn lerp(self, from: Self, to: Self) -> Self {
from + (to - from) * self
}
/// inverse of lerp, and `None` where `from` and `to` are the same point:
/// every input lerps to it, so there is no one answer to come back to.
fn lerp_inv(self, from: Self, to: Self) -> Option<Self> {
match to == from {
true => None,
false => Some((self - from) / (to - from)),
}
}
}
macro_rules! impl_op {
-5
View File
@@ -1,8 +1,3 @@
#[allow(clippy::missing_safety_doc)]
pub(crate) unsafe fn forget_ref<'a, T>(x: &T) -> &'a T {
unsafe { std::mem::transmute::<&T, &T>(x) }
}
#[allow(clippy::missing_safety_doc)]
pub(crate) unsafe fn forget_mut<'a, T>(x: &mut T) -> &'a mut T {
unsafe { std::mem::transmute::<&mut T, &mut T>(x) }
+5 -1
View File
@@ -1,7 +1,11 @@
use crate::util::impl_op;
use std::{hash::Hash, ops::*};
#[repr(C)]
/// `align(8)` because that is WGSL's alignment for a `vec2<f32>`, so any GPU
/// struct holding one is laid out the way its shader reads it without having
/// to say so itself. Those structs still need a manual `unsafe impl Pod`,
/// since the trailing padding this introduces is what `derive(Pod)` refuses.
#[repr(C, align(8))]
#[derive(Clone, Copy, PartialEq, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vec2 {
pub x: f32,
+3
View File
@@ -21,6 +21,9 @@ pub use widgets::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OnResize {
Scale,
/// Reserved: nothing reads this yet, so a widget saying it is redrawn.
/// Keeping an unchanged drawing in a bigger box needs the widget to say
/// *where* in that box it should sit, which is the alignment work.
Translate,
#[default]
Redraw,
+31
View File
@@ -0,0 +1,31 @@
//! The seeded random tree `tests/generated.rs` checks, drawn so it can be
//! looked at. `IRIS_SEED` and `IRIS_DEPTH` choose which one.
use iris::prelude::*;
use iris::random::Edits;
fn env(name: &str, fallback: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(fallback)
}
fn main() {
DefaultApp::<State>::run();
}
#[derive(DefaultUiState)]
struct State {
ui_state: DefaultUiState,
}
impl DefaultAppState for State {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let seed = env("IRIS_SEED", 1);
let depth = env("IRIS_DEPTH", 4) as usize;
let (root, _) = iris::random::grow(rsc, seed, depth, &Edits::default());
ui_state.set_root(root);
Self { ui_state }
}
}
+13 -1
View File
@@ -17,6 +17,10 @@
# custom one would otherwise inherit the other's output and quietly screenshot
# the wrong size.
#
# `--resize WxH@Hz` changes the output under the app once it is up, then
# screenshots. A resize is its own case: what it has to match is a cold start
# at that size, byte for byte, and nothing in `cargo test` can see it.
#
# `--replay FILE` drives a `.touch` recording into the window through
# `replay-touch`, which reads it with the same parser `iris::harness` uses. A
# recording is `<ms> down|move|up <x> <y>` in the output's own pixels. With
@@ -46,6 +50,7 @@ run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
resize=""
example=""
kind=example
mode=1920x1200@60Hz
@@ -57,13 +62,14 @@ while [ $# -gt 0 ]; do
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--mode) mode=$2; shift 2 ;;
--resize) resize=$2; shift 2 ;;
--replay) replay=$2; shift 2 ;;
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--dir DIR] [--mode WxH@Hz] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--dir DIR] [--mode WxH@Hz] [--resize WxH@Hz] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
[ -z "$shot" ] || need grim "the screenshot --shot writes"
@@ -142,6 +148,12 @@ while [ $i -lt "$((seconds * 2))" ]; do
i=$((i + 1)); sleep 0.5
done
if [ -n "$resize" ] && kill -0 "$pid" 2>/dev/null; then
swaymsg output HEADLESS-1 mode "$resize" >/dev/null
echo "run-headless: resized to $resize" >&2
sleep 2
fi
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
+1
View File
@@ -8,6 +8,7 @@
pub mod default;
pub mod event;
pub mod harness;
pub mod random;
pub mod widget;
pub use iris_core as core;
+317
View File
@@ -0,0 +1,317 @@
//! A seeded random widget tree, for tests and for looking at.
//!
//! One seed is one tree, on any machine and after any upgrade, so a test can
//! grow the same tree twice and a failing seed is reproduced by its number.
//! `examples/random.rs` draws one; `tests/generated.rs` checks that laying one
//! out again lands where growing it from scratch would.
use crate::prelude::*;
use std::collections::HashMap;
/// The declared lengths of one `SetSize`, by axis.
pub type Lens = [Option<Len>; 2];
/// What a test changes between two trees grown from the same seed, so the
/// warm one can be mutated and the cold one grown that way to begin with.
#[derive(Default)]
pub struct Edits {
/// Declared sizes, by the order the `SetSize` wrappers were made.
pub sizes: HashMap<usize, Lens>,
/// Which children a span has, by the order the spans were made.
pub spans: HashMap<usize, SpanEdit>,
}
#[derive(Default, Clone)]
pub struct SpanEdit {
/// Children to leave out, by index among the ones grown.
pub detach: Vec<usize>,
/// How many of the span's spares are in it, appended in order.
pub attach: usize,
}
/// xorshift64, written out rather than taken from a crate so that a seed
/// keeps meaning the same tree.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Self(seed | 1)
}
pub fn bits(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
pub fn below(&mut self, n: usize) -> usize {
(self.bits() % n as u64) as usize
}
pub fn chance(&mut self) -> bool {
self.bits() & 1 == 0
}
}
const COLORS: [UiColor; 6] = [
UiColor::RED,
UiColor::GREEN,
UiColor::BLUE,
UiColor::YELLOW,
UiColor::CYAN,
UiColor::MAGENTA,
];
/// Leaves grown beside every span, for a test to put into it.
const SPARES: usize = 3;
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.";
/// What growing a tree gives back: every widget in creation order, so two
/// trees from one seed line up index for index, and the declared sizes, which
/// are what a test changes to watch the change propagate.
#[derive(Default)]
pub struct Tree {
pub ids: Vec<WidgetId>,
pub sized: Vec<WeakWidget<SetSize>>,
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
/// that moved; this catches one that believed a measurement a cold start
/// would not have given it, by turning that into a different tree. Its own
/// configuration never changes, so which side draws is a property of the
/// layout alone.
pub struct Branch {
pub probe: StrongWidget,
pub wide: StrongWidget,
pub narrow: StrongWidget,
pub threshold: f32,
}
impl Widget for Branch {
fn draw(&mut self, painter: &mut Painter) -> Size {
let mut top = UiRegion::FULL;
top.y.end = top.y.start.offset(40.0);
let measured = painter.place(&self.probe, top).len(Axis::X);
let px = measured.apply_rest().to_px(painter.px_len(Axis::X));
let mut rest = UiRegion::FULL;
rest.y.start = rest.y.start.offset(40.0);
match px > self.threshold {
true => painter.place(&self.wide, rest),
false => painter.place(&self.narrow, rest),
};
Size::REST
}
}
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.
pub spares: Vec<StrongWidget>,
/// How many children it was grown with, before any edit.
pub grown: usize,
}
/// Grows the tree `seed` describes, `edits` replacing the declared sizes it
/// would otherwise have given those wrappers.
pub fn grow<Rsc: UiRsc + 'static>(
rsc: &mut Rsc,
seed: u64,
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)
}
struct Grow<'a, Rsc> {
rsc: &'a mut Rsc,
rng: Rng,
tree: Tree,
edits: &'a Edits,
}
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),
_ => {
let color = COLORS[self.rng.below(COLORS.len())];
let alpha = (self.rng.below(5) * 63) as u8;
rect(color.alpha(alpha)).add_strong(self.rsc)
}
};
self.tree.ids.push(id.id());
id
}
fn len(&mut self) -> Option<Len> {
match self.rng.below(4) {
0 => Some(Len::px(20.0 + self.rng.below(180) as f32)),
1 => Some(Len::REST),
_ => None,
}
}
fn align(&mut self) -> Align {
let mut axis = || match self.rng.below(4) {
0 => None,
1 => Some(AxisAlign::Neg),
2 => Some(AxisAlign::Center),
_ => Some(AxisAlign::Pos),
};
let (mut x, y) = (axis(), axis());
// Aligning on neither axis is just another transparent wrapper and
// would leave this branch unexercised.
if x.is_none() && y.is_none() {
x = Some(AxisAlign::Center);
}
Align { x, y }
}
/// A declared size over half the tree, kept where a test can change it.
fn sized(&mut self, inner: StrongWidget) -> StrongWidget {
if !self.rng.chance() {
return inner;
}
let idx = self.tree.sized.len();
let lens = [self.len(), self.len()];
let lens = self.edits.sizes.get(&idx).copied().unwrap_or(lens);
let id = SetSize {
inner,
x: lens[0],
y: lens[1],
}
.add(self.rsc);
self.tree.sized.push(id);
self.tree.ids.push(id.id());
id.add_strong(self.rsc)
}
fn node(&mut self, depth: usize) -> StrongWidget {
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 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);
}
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,
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 id = Aligned {
inner,
align: self.align(),
}
.add_strong(self.rsc);
self.tree.ids.push(id.id());
return id;
}
if self.rng.below(4) == 0 {
let inner = self.node(depth - 1);
let inner = self.sized(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 = || self.rng.below(24) as f32;
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 grown = 2 + self.rng.below(3);
let mut children = Vec::with_capacity(grown);
for _ in 0..grown {
let child = self.node(depth - 1);
children.push(self.sized(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;
}
// 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 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: self.rng.below(3) as f32 * 4.0,
}
.add(self.rsc);
self.tree.ids.push(id.id());
self.tree.spans.push(Spanned { id, spares, grown });
id.add_strong(self.rsc)
}
}
+2 -2
View File
@@ -8,11 +8,11 @@ pub struct Image {
impl Widget for Image {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(&self.handle);
Size::abs(self.handle.size())
Size::px(self.handle.size())
}
fn size_hint(&self, axis: Axis) -> Option<Len> {
Some(Len::abs(self.handle.size().axis(axis)))
Some(Len::px(self.handle.size().axis(axis)))
}
fn on_resize(&self, _: Axis) -> OnResize {
+25 -5
View File
@@ -7,16 +7,36 @@ pub struct Aligned {
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
// Drawn where it may be too big, then given its aligned box once its
// size is known.
let size = painter.widget(&self.inner).size();
let known = match self.align.tuple() {
(Some(_), Some(_)) => painter
.known_len(&self.inner, Axis::X, UiRegion::FULL)
.zip(painter.known_len(&self.inner, Axis::Y, UiRegion::FULL))
.map(|(x, y)| Size { x, y }),
(Some(_), None) => painter
.known_len(&self.inner, Axis::X, UiRegion::FULL)
.map(|x| Size { x, y: Len::REST }),
(None, Some(_)) => painter
.known_len(&self.inner, Axis::Y, UiRegion::FULL)
.map(|y| Size { x: Len::REST, y }),
(None, None) => Some(Size::REST),
};
// Drawn where it may be too big only when the aligned axes are not
// already known, then given its aligned box once its size is known.
let had_size = known.is_some();
let size = known.unwrap_or_else(|| painter.place(&self.inner, UiRegion::FULL).size());
let region = match self.align.tuple() {
(Some(x), Some(y)) => size.to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => UiRegion::new(size.x.apply_rest().align(x), UiSpan::FULL),
(None, Some(y)) => UiRegion::new(UiSpan::FULL, size.y.apply_rest().align(y)),
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
size
let placed = painter.place(&self.inner, region).size();
if had_size { placed } else { size }
}
/// The aligned box is a fraction of its own, so the child keeps its
/// length and stays against the edge it was aligned to.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+4
View File
@@ -12,4 +12,8 @@ impl Widget for LayerOffset {
}
painter.widget(&self.inner).size()
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ impl Widget for MaxSize {
fn capped(len: Len, max: Option<Len>, output: f32) -> Len {
match max {
Some(max) if len.apply_rest().to_abs(output) > max.apply_rest().to_abs(output) => max,
Some(max) if len.apply_rest().to_px(output) > max.apply_rest().to_px(output) => max,
_ => len,
}
}
+4
View File
@@ -10,4 +10,8 @@ impl Widget for Offset {
let region = UiRegion::FULL.offset(self.amt);
painter.widget_within(&self.inner, region).size()
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+12 -6
View File
@@ -12,15 +12,21 @@ impl Widget for Pad {
.size();
Size {
x: Len {
abs: inner.x.abs + self.padding.left + self.padding.right,
px: inner.x.px + self.padding.left + self.padding.right,
..inner.x
},
y: Len {
abs: inner.y.abs + self.padding.top + self.padding.bottom,
px: inner.y.px + self.padding.top + self.padding.bottom,
..inner.y
},
}
}
/// The padding is an offset from each edge, so a longer box pads the same
/// amount and the child takes the rest.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
pub struct Padding {
@@ -49,10 +55,10 @@ impl Padding {
}
pub fn region(&self) -> UiRegion {
let mut region = UiRegion::FULL;
region.x.start.abs += self.left;
region.y.start.abs += self.top;
region.x.end.abs -= self.right;
region.y.end.abs -= self.bottom;
region.x.start.px += self.left;
region.y.start.px += self.top;
region.x.end.px -= self.right;
region.y.end.px -= self.bottom;
region
}
pub fn x(amt: impl UiNum) -> Self {
+14 -11
View File
@@ -11,17 +11,20 @@ pub struct Scroll {
impl Widget for Scroll {
fn draw(&mut self, painter: &mut Painter) -> Size {
let output_len = painter.output_size().axis(self.axis);
let container_len = painter.region().axis(self.axis).len();
// Drawn in the whole container to learn its length, then placed at
// the scrolled offset.
let child = painter.widget(&self.inner).size();
let content_len = child
.axis(self.axis)
let output_len = painter.output_len(self.axis);
// Its size is its content's, whatever box that is scrolled within.
let container_len = UiScalar::px(painter.px_len_for_draw(self.axis));
// Draw in the whole container only when its scrolling-axis length is
// not already known, then place it at the scrolled offset.
let known_len = painter.known_len(&self.inner, self.axis, UiRegion::FULL);
let measured = known_len.is_none();
let child = measured.then(|| painter.place(&self.inner, UiRegion::FULL).size());
let content_len = known_len
.unwrap_or_else(|| child.unwrap().axis(self.axis))
.apply_rest()
.within_len(container_len)
.to_abs(output_len);
self.container_len = container_len.to_abs(output_len);
.to_px(output_len);
self.container_len = container_len.to_px(output_len);
self.content_len = content_len;
if self.snap_end {
@@ -31,8 +34,8 @@ impl Widget for Scroll {
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0));
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
painter.widget_within(&self.inner, region);
child
let placed = painter.place(&self.inner, region).size();
child.unwrap_or(placed)
}
}
+18 -1
View File
@@ -8,7 +8,20 @@ pub struct SetSize {
impl Widget for SetSize {
fn draw(&mut self, painter: &mut Painter) -> Size {
let child = painter.widget(&self.inner).size();
// A declared length is what the child gets, whatever box this widget
// was offered before its parent knew that. Measuring it anywhere else
// asks about a box it will not have, and the answer on the other axis
// is taken under that: a wrapping text measured in the whole width
// reports one line, and nothing revisits it once the real width
// arrives.
let mut region = UiRegion::FULL;
for (axis, len) in [(Axis::X, self.x), (Axis::Y, self.y)] {
if let Some(len) = len {
let span = region.axis_mut(axis);
span.end = span.start + len.apply_rest();
}
}
let child = painter.widget_within(&self.inner, region).size();
Size {
x: self.x.unwrap_or(child.x),
y: self.y.unwrap_or(child.y),
@@ -23,4 +36,8 @@ impl Widget for SetSize {
Axis::Y => self.y,
}
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+35 -17
View File
@@ -12,17 +12,25 @@ impl Widget for Span {
let axis = self.dir.axis;
// A length for every child before any is placed: from its own hint
// where it has one, and from drawing it where it does not.
let lens: Vec<Len> = self
.children
.iter()
.map(|child| match painter.size_hint(child, axis) {
let mut cursor = UiScalar::rel_min();
let mut lens = Vec::with_capacity(self.children.len());
for child in &self.children {
let mut span = UiSpan::new(cursor, UiScalar::rel_max());
if self.dir.sign == Sign::Neg {
span.flip();
}
let region = UiRegion::from_axis(axis, span, UiSpan::FULL);
let len = match painter.known_len(child, axis, region) {
Some(len) => len,
None => painter.widget(child).len(axis),
})
.collect();
None => painter.place(child, region).len(axis),
};
cursor.px += len.px + self.gap;
cursor.rel += len.rel;
lens.push(len);
}
let gap = self.gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::abs(gap), |sum, len| sum + *len);
let total = lens.iter().fold(Len::px(gap), |sum, len| sum + *len);
let mut start = UiScalar::rel_min();
let mut ortho = Len::ZERO;
@@ -30,34 +38,44 @@ impl Widget for Span {
let mut span = UiSpan::FULL;
span.start = start;
if len.rest > 0.0 {
let offset = UiScalar::new(total.rel, total.abs);
let offset = UiScalar::new(total.rel, total.px);
let rel_end = UiScalar::rel(len.rest / total.rest);
let end = (UiScalar::rel_max() + start) - offset;
start = rel_end.within(&start.to(end));
}
start.abs += len.abs;
start.px += len.px;
start.rel += len.rel;
span.end = start;
let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL);
if self.dir.sign == Sign::Neg {
region.flip(axis);
}
let used = painter.widget_within(child, region).size().axis(!axis);
let used = painter.place(child, region).size().axis(!axis);
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
if used.rel > 0.0 || used.rest > 0.0 {
ortho = Len::REST;
} else if ortho.rest == 0.0 {
ortho.abs = ortho.abs.max(used.abs);
ortho.px = ortho.px.max(used.px);
}
start.abs += self.gap;
start.px += self.gap;
}
let along = match total.rest == 0.0 && total.rel == 0.0 {
true => total,
false => Len::default(),
};
// Carried whole rather than collapsed to one share: a span that sizes
// from its children does not resolve `rest`, it passes the weight up,
// so nesting spans divides the same space rather than re-dividing a
// share of it. Four `rest(1)` children under two spans under one span
// get a quarter each, which collapsing to `rest(1)` per level does
// not give. Resolution happens at the nearest ancestor with a length,
// and the root always has one.
let along = total;
Size::from_axis(axis, along, ortho)
}
/// Every child is placed in fractions and offsets of the span's own box,
/// so a longer box holds the same layout and the children follow it.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
impl Span {
+4
View File
@@ -28,6 +28,10 @@ impl Widget for Stack {
}
size
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
#[derive(Default, Debug)]
+1 -5
View File
@@ -130,7 +130,6 @@ impl<'a> TextEditCtx<'a> {
pub fn set(&mut self, text: &str) {
let text = self.string(text);
self.text.view.buf.set_text(text);
self.text.view.buf.changed = true;
self.text.selection = None;
}
@@ -177,7 +176,6 @@ impl<'a> TextEditCtx<'a> {
};
let at = at.min(self.text.view.buf.text().len());
self.text.view.buf.edit().insert_str(at, text);
self.text.view.buf.changed = true;
self.set_caret(at + text.len());
}
@@ -190,7 +188,6 @@ impl<'a> TextEditCtx<'a> {
}
let range = sel.text_range();
self.text.view.buf.edit().replace_range(range.clone(), "");
self.text.view.buf.changed = true;
self.set_caret(range.start);
true
}
@@ -268,7 +265,6 @@ impl<'a> TextEditCtx<'a> {
fn delete_range(&mut self, start: usize, end: usize) {
self.text.view.buf.edit().replace_range(start..end, "");
self.text.view.buf.changed = true;
self.set_caret(start);
}
@@ -284,7 +280,7 @@ impl<'a> TextEditCtx<'a> {
}
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.text.region().top_left().to_abs(size);
let pos = pos - self.text.region().top_left().to_px(size);
let prev_sel = self.text.selection;
let prev_hit = self.text.double_hit;
+12 -23
View File
@@ -14,11 +14,8 @@ pub struct Text {
}
pub struct TextView {
pub attrs: MutDetect<TextAttrs>,
pub buf: MutDetect<TextBuffer>,
// cache
tex: Option<RenderedText>,
width: Option<f32>,
pub attrs: TextAttrs,
pub buf: TextBuffer,
pub hint: Option<StrongWidget>,
}
@@ -28,19 +25,13 @@ impl TextView {
}
pub fn wrap_width(&self) -> Option<f32> {
self.width
self.buf.wrap_width()
}
}
impl TextView {
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
Self {
attrs: attrs.into(),
buf: buf.into(),
tex: None,
width: None,
hint,
}
Self { attrs, buf, hint }
}
/// region where the text should be draw
@@ -52,22 +43,20 @@ impl TextView {
.align(self.align)
}
/// The text shaped for the width it is drawn in. The buffer keeps its
/// answers under the attrs too, so changing those asks a new question
/// rather than invalidating anything.
fn render(&mut self, painter: &mut Painter) -> &RenderedText {
let width = if self.attrs.wrap {
Some(painter.px_size().x)
Some(painter.px_len(Axis::X))
} else {
None
};
if width != self.width || self.tex.is_none() || self.attrs.changed || self.buf.changed {
self.width = width;
self.tex = Some(painter.render_text(&mut self.buf, &self.attrs, width));
self.attrs.changed = false;
self.buf.changed = false;
}
self.tex.as_ref().unwrap()
painter.render_text(&mut self.buf, &self.attrs, width)
}
pub fn tex(&self) -> Option<&RenderedText> {
self.tex.as_ref()
self.buf.rendered()
}
/// Draws the text, and says where the glyphs went and what they use.
pub fn draw(&mut self, painter: &mut Painter) -> (UiRegion, Size) {
@@ -83,7 +72,7 @@ impl TextView {
let tex = self.render(painter);
let region = tex.size.align(align);
let size = Size::abs(tex.size);
let size = Size::px(tex.size);
let within = region.within(&painter.region());
painter.glyphs(tex, within);
(region, size)
+224
View File
@@ -0,0 +1,224 @@
//! What the vertex shader's move-chain walk costs, against how deep the chain
//! is. Every active widget owns a slot, so the depth a primitive resolves
//! through is its depth in the widget tree.
//!
//! cargo test --release --test chain_cost -- --ignored --nocapture
//!
//! Timed on the GPU with timestamp queries rather than by the clock: wall time
//! here varied by 2x between runs of one unchanged binary. The pass is
//! submitted and waited on, so this is the GPU's cost and not the recording
//! loop's -- which is what `draw_cost.rs` measures instead.
//!
//! The instances are two pixels wide so that vertex work dominates; a chain
//! walk that does not show up against small quads will not show up against
//! anything.
//!
//! The instance is leaked deliberately, for the reason `draw_cost.rs` gives.
use iris::prelude::*;
use iris_core::{
MaskIdx, MoveIdx, PrimitiveInst, RectPrimitive, UiData, UiRegion, UiRenderNode, UiRenderState,
UiScalar, UiSpan,
};
use wgpu::{Color as GpuColor, *};
const SIZE: u32 = 1024;
const INSTANCES: usize = 200_000;
const FRAMES: u32 = 20;
/// Reported as the best of this many batches, since the mean moves by more
/// than the thing being measured.
const BATCHES: u32 = 8;
fn gpu() -> Option<(Device, Queue, f32)> {
let all = Instance::new(InstanceDescriptor::new_without_display_handle());
let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default()))
{
Ok(_) => all,
Err(_) => Instance::new(InstanceDescriptor {
backends: Backends::GL,
..InstanceDescriptor::new_without_display_handle()
}),
};
let instance: &'static Instance = Box::leak(Box::new(instance));
let adapter =
pollster::block_on(instance.request_adapter(&RequestAdapterOptions::default())).ok()?;
if !adapter.features().contains(Features::TIMESTAMP_QUERY) {
println!("no timestamp queries on {:?}", adapter.get_info().name);
return None;
}
println!("adapter: {:?}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&DeviceDescriptor {
required_features: Features::TIMESTAMP_QUERY,
..Default::default()
}))
.ok()?;
let period = queue.get_timestamp_period();
Some((device, queue, period))
}
fn config(format: TextureFormat) -> SurfaceConfiguration {
SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format,
color_space: SurfaceColorSpace::Auto,
width: SIZE,
height: SIZE,
present_mode: PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: CompositeAlphaMode::Auto,
view_formats: vec![],
}
}
/// A chain `depth` slots long, and instances that all resolve through its end.
fn fill(ui: &mut UiData, render: &mut UiRenderState, depth: usize) {
let kind = ui.primitives.kind::<RectPrimitive>();
let id = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id();
let mut slot = MoveIdx::NONE;
for _ in 0..depth {
slot = render.moves.push(slot, UiRegion::FULL);
}
let px = |v: f32| UiScalar { rel: 0.0, px: v };
for i in 0..INSTANCES {
let x = (i % (SIZE as usize / 2)) as f32 * 2.0;
let y = (i / (SIZE as usize / 2)) as f32;
render.layers.write(
0,
PrimitiveInst {
kind,
id,
primitive: RectPrimitive::color(UiColor::WHITE),
region: UiRegion::new(
UiSpan::new(px(x), px(x + 2.0)),
UiSpan::new(px(y), px(y + 1.0)),
),
mask_idx: MaskIdx::NONE,
move_idx: slot,
},
);
}
}
/// Nanoseconds the pass took on the GPU, best of `BATCHES`.
fn pass_cost(device: &Device, queue: &Queue, period: f32, depth: usize) -> f64 {
let format = TextureFormat::Bgra8Unorm;
let mut node = UiRenderNode::new(device, &config(format));
let mut ui = UiData::default();
let mut render = UiRenderState::new();
fill(&mut ui, &mut render, depth);
node.update(device, queue, &mut ui, &mut render);
let target = device.create_texture(&TextureDescriptor {
label: Some("chain cost"),
size: Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format,
usage: TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = target.create_view(&TextureViewDescriptor::default());
let queries = device.create_query_set(&QuerySetDescriptor {
label: Some("chain cost"),
ty: QueryType::Timestamp,
count: 2,
});
let resolved = device.create_buffer(&BufferDescriptor {
label: Some("resolved"),
size: 16,
usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&BufferDescriptor {
label: Some("readback"),
size: 16,
usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let frame = || {
let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor::default());
{
let pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
label: None,
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(GpuColor::BLACK),
store: StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: Some(RenderPassTimestampWrites {
query_set: &queries,
beginning_of_pass_write_index: Some(0),
end_of_pass_write_index: Some(1),
}),
occlusion_query_set: None,
multiview_mask: None,
});
node.draw(pass);
}
encoder.resolve_query_set(&queries, 0..2, &resolved, 0);
encoder.copy_buffer_to_buffer(&resolved, 0, &readback, 0, 16);
queue.submit(Some(encoder.finish()));
let slice = readback.slice(..);
slice.map_async(MapMode::Read, |_| {});
let _ = device.poll(PollType::Wait {
submission_index: None,
timeout: None,
});
let ns = {
let view = slice.get_mapped_range().expect("timestamps did not map");
let stamps: [u64; 2] = [
u64::from_le_bytes(view[..8].try_into().unwrap()),
u64::from_le_bytes(view[8..16].try_into().unwrap()),
];
(stamps[1].saturating_sub(stamps[0])) as f64 * period as f64
};
readback.unmap();
ns
};
frame();
let mut best = f64::MAX;
for _ in 0..BATCHES {
let mut total = 0.0;
for _ in 0..FRAMES {
total += frame();
}
best = best.min(total / FRAMES as f64);
}
best
}
#[test]
#[ignore = "measurement, not a check"]
fn chain_cost_by_depth() {
let Some((device, queue, period)) = gpu() else {
println!("no gpu with timestamps; nothing measured");
return;
};
println!("{INSTANCES} instances, {SIZE}x{SIZE}, best of {BATCHES} batches");
let mut base = None;
for depth in [1, 2, 4, 8, 16, 32, 64] {
let ns = pass_cost(&device, &queue, period, depth);
let base = *base.get_or_insert(ns);
println!(
"depth {depth:>3}: {:>9.1} us {:+6.1}% against depth 1",
ns / 1000.0,
(ns - base) / base * 100.0
);
}
}
+101
View File
@@ -0,0 +1,101 @@
//! A measurement that decides control flow.
//!
//! Comparing boxes catches a widget that moved. It does not catch a widget
//! that measured a child, believed a different answer from the one a cold
//! start would give, and took the other branch -- which is the same defect
//! arriving somewhere it cannot be ignored. A widget here branches on what it
//! measured, so a disagreement shows up as a different tree.
use iris::harness::Harness;
use iris::prelude::*;
/// Measures `probe` across `axis` and draws one of two children on the
/// answer. Its own configuration never changes, so which child is drawn is a
/// property of the layout alone.
struct BranchesOnMeasurement {
probe: StrongWidget,
wide: StrongWidget,
narrow: StrongWidget,
threshold: f32,
}
impl Widget for BranchesOnMeasurement {
fn draw(&mut self, painter: &mut Painter) -> Size {
let mut top = UiRegion::FULL;
top.y.end = top.y.start.offset(40.0);
let measured = painter.place(&self.probe, top).len(Axis::X);
let px = measured.apply_rest().to_px(painter.px_len(Axis::X));
let mut rest = UiRegion::FULL;
rest.y.start = rest.y.start.offset(40.0);
match px > self.threshold {
true => painter.place(&self.wide, rest),
false => painter.place(&self.narrow, rest),
};
Size::REST
}
}
fn plant(h: &mut Harness, threshold: f32) -> (WidgetId, WidgetId) {
let words = "the quick brown fox jumps over the lazy dog and keeps running";
let probe = wtext(words).size(16).wrap(true).add(&mut h.rsc);
let wide = rect(Color::RED).add(&mut h.rsc);
let narrow = rect(Color::BLUE).add(&mut h.rsc);
let branch = BranchesOnMeasurement {
probe: probe.add_strong(&mut h.rsc),
wide: wide.add_strong(&mut h.rsc),
narrow: narrow.add_strong(&mut h.rsc),
threshold,
}
.add(&mut h.rsc);
let side = rect(Color::GREEN).width(120).add(&mut h.rsc);
h.set_root((side, branch).span(Dir::RIGHT));
(wide.id(), narrow.id())
}
/// Which of the two branches drew, as a pair a test can compare.
fn taken(h: &Harness, wide: WidgetId, narrow: WidgetId) -> (bool, bool) {
(h.region(&wide).is_some(), h.region(&narrow).is_some())
}
#[test]
fn a_branch_taken_on_a_measurement_holds_across_repaints() {
for threshold in [0.0, 200.0, 400.0, 600.0, 779.0, 780.0, 781.0, 2000.0] {
let mut h = Harness::new((900, 600));
let (wide, narrow) = plant(&mut h, threshold);
let first = taken(&h, wide, narrow);
assert_ne!(first, (false, false), "threshold {threshold}: neither drew");
for frame in 0..4 {
h.rsc.widgets_mut().get_dyn_mut(wide);
h.rsc.widgets_mut().get_dyn_mut(narrow);
h.frame();
assert_eq!(
taken(&h, wide, narrow),
first,
"threshold {threshold}, repaint {frame}: the branch moved when nothing did"
);
}
}
}
#[test]
fn a_branch_taken_on_a_measurement_is_the_one_a_cold_start_takes() {
for threshold in [0.0, 200.0, 400.0, 600.0, 779.0, 780.0, 781.0, 2000.0] {
let mut warm = Harness::new((900, 600));
let (wide, narrow) = plant(&mut warm, threshold);
warm.resize((640, 480));
warm.frame();
warm.rsc.widgets_mut().get_dyn_mut(wide);
warm.frame();
let mut cold = Harness::new((640, 480));
let (cwide, cnarrow) = plant(&mut cold, threshold);
assert_eq!(
taken(&warm, wide, narrow),
taken(&cold, cwide, cnarrow),
"threshold {threshold}: warm and cold took different branches"
);
}
}
+5 -2
View File
@@ -22,8 +22,8 @@ use std::time::Instant;
use iris::prelude::*;
use iris_core::{
GlyphPrimitive, MaskIdx, PrimitiveInst, RectPrimitive, TextureHandle, TexturePrimitive, UiData,
UiRegion, UiRenderNode, UiRenderState,
GlyphPrimitive, MaskIdx, MoveIdx, PrimitiveInst, RectPrimitive, TextureHandle,
TexturePrimitive, UiData, UiRegion, UiRenderNode, UiRenderState,
};
use wgpu::{Color as GpuColor, *};
@@ -95,6 +95,7 @@ fn fill(
primitive: RectPrimitive::color(UiColor::WHITE),
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
render.layers.write(
@@ -111,6 +112,7 @@ fn fill(
},
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
}
@@ -123,6 +125,7 @@ fn fill(
primitive: TexturePrimitive::from(h),
region: UiRegion::FULL,
mask_idx: MaskIdx::NONE,
move_idx: MoveIdx::NONE,
},
);
}
+468
View File
@@ -0,0 +1,468 @@
//! Random trees, checked against building the same tree cold.
//!
//! A frame reaches its layout by keeping most of the last one: slots
//! rewritten, some widgets drawn again, the rest untouched. The property here
//! is that what comes out is the tree a cold start would have produced, so
//! anything the retained path carried over that it should not have shows up
//! as a difference in somebody's box.
//!
//! `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.
use std::collections::HashMap;
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Edits, Lens, Rng, SpanEdit, Tree, grow};
/// 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
/// reachable by raising this -- it buys more overlap between dependency
/// paths, not more ancestry.
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; 7] = [1, 2, 3, 5, 8, 13, 98];
const REGION_EPSILON_PX: f32 = 0.05;
fn same_coordinate(got: f32, want: f32) -> bool {
(got - want).abs() <= REGION_EPSILON_PX
}
fn same_region(got: Option<PixelRegion>, want: Option<PixelRegion>) -> bool {
match (got, want) {
(Some(got), Some(want)) => {
same_coordinate(got.top_left.x, want.top_left.x)
&& same_coordinate(got.top_left.y, want.top_left.y)
&& same_coordinate(got.bot_right.x, want.bot_right.x)
&& same_coordinate(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(Len::px(20.0 + rng.below(180) as f32)),
Some(Len::px(20.0 + rng.below(180) as f32)),
];
let sized = &mut h.rsc[tree.sized[idx]];
sized.x = lens[0];
sized.y = 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 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;
let len = |l: &Option<Len>| match l {
Some(l) => format!("{l}"),
None => "-".into(),
};
if let Some(w) = any.downcast_ref::<SetSize>() {
return format!("SetSize{{x:{},y:{}}}", len(&w.x), len(&w.y));
}
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::<Aligned>() {
let a = |v: Option<AxisAlign>| match v {
None => "-",
Some(AxisAlign::Neg) => "neg",
Some(AxisAlign::Center) => "mid",
Some(AxisAlign::Pos) => "pos",
};
return format!("Aligned{{x:{},y:{}}}", a(w.align.x), a(w.align.y));
}
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 the ones that own a slot,
/// 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 slot = match active.move_idx == active.parent_move {
true => "",
false => "*",
};
chain.push(format!("{}{slot}", describe(id, wh)));
at = active.parent;
}
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));
}
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 before: Vec<_> = grown.ids.iter().map(|id| warm.region(id)).collect();
let (spans, _held) = reshuffle(&mut warm, &mut grown, shuffle);
warm.frame();
// Or the two trees would agree for want of anything having happened.
let after = grown.ids.iter().map(|id| warm.region(id));
let moved = before.iter().zip(after).filter(|(a, b)| *a != b).count();
assert!(moved > 0, "seed {seed}: {shuffle:?} changed nothing");
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 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);
}
#[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);
}
}
}
/// 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 seven the others check"]
fn a_long_run_of_seeds_agrees() {
let seeds = 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));
for seed in seeds {
changed_size(seed);
changed_every_size(seed);
repainted_together(seed);
resized(seed);
resized_then_changed(seed);
for shuffle in SHUFFLES {
reshuffled(seed, shuffle);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Whether measuring a widget and then giving it the length it reported is a
//! fixed point, which is what a span that sizes to its children needs.
use iris::harness::Harness;
use iris::prelude::*;
#[test]
fn a_wrapping_text_in_a_span_settles_on_one_width() {
let mut h = Harness::new((900, 600));
let words = "the quick brown fox jumps over the lazy dog and keeps on running \
until it reaches the end of a rather long line of text";
let t = wtext(words).size(16).wrap(true).add(&mut h.rsc);
let filler = rect(Color::BLUE).add(&mut h.rsc);
h.set_root((t, filler).span(Dir::RIGHT));
let mut widths = Vec::new();
for _ in 0..6 {
let r = h.region(&t.id()).unwrap();
widths.push(r.bot_right.x - r.top_left.x);
// Redrawing it changes nothing about the state, so nothing may move.
h.rsc.widgets_mut().get_dyn_mut(t.id());
h.frame();
}
println!("widths over six frames: {widths:?}");
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"a repaint that changed nothing moved it: {widths:?}"
);
}
+242 -2
View File
@@ -54,7 +54,7 @@ fn a_child_drawn_twice_moves_once() {
h.set_root((left, centered).span(Dir::RIGHT));
assert_corners!(h, inner, (100, 0), (300, 200));
h.rsc[left].x = Some(Len::abs(150));
h.rsc[left].x = Some(Len::px(150));
h.frame();
assert_corners!(h, inner, (150, 0), (350, 200));
@@ -104,8 +104,248 @@ fn a_fixed_box_is_drawn_again_rather_than_stretched() {
h.set_root(stack.align(Align::TOP));
assert_corners!(h, panel, (0, 0), (400, 100));
h.rsc[leaf].y = Some(Len::abs(250));
h.rsc[leaf].y = Some(Len::px(250));
h.frame();
assert_corners!(h, panel, (0, 0), (400, 250));
}
#[test]
fn a_moved_subtree_takes_its_children_with_it() {
let mut h = Harness::new((400, 400));
let first = rect(Color::RED).height(40).add(&mut h.rsc);
let inner = rect(Color::BLUE).add(&mut h.rsc);
let row = inner.pad(10).height(40).add(&mut h.rsc);
h.set_root((first, row).span(Dir::DOWN));
assert_corners!(h, inner, (10, 50), (390, 70));
h.rsc[first].y = Some(Len::px(80));
h.frame();
// The row is the same shape somewhere else, so one slot moved it and
// `inner`'s own region was never rewritten.
assert_corners!(h, inner, (10, 90), (390, 110));
}
#[test]
fn a_fixed_length_child_keeps_it_when_the_box_around_it_grows() {
let mut h = Harness::new((400, 200));
let fixed = rect(Color::BLUE).width(50).add(&mut h.rsc);
let rest = rect(Color::GREEN).add(&mut h.rsc);
let panel = (fixed, rest).span(Dir::RIGHT).add(&mut h.rsc);
// Changing the bar's width is the only thing that changes the box the
// panel and everything under it was drawn for.
let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, panel).span(Dir::RIGHT));
assert_corners!(h, fixed, (100, 0), (150, 200));
assert_corners!(h, rest, (150, 0), (400, 200));
h.rsc[bar].x = Some(Len::px(200));
h.frame();
// The panel's box is 100 shorter, so the fixed child is the same 50 wide
// against its new start and the one taking the rest absorbs the change.
assert_corners!(h, fixed, (200, 0), (250, 200));
assert_corners!(h, rest, (250, 0), (400, 200));
}
#[test]
fn a_box_with_a_fixed_length_can_be_stretched_on_its_other_axis() {
let mut h = Harness::new((400, 200));
// The row is 40 tall whatever happens, which used to make its drawing
// impossible to take out of: recovering a fraction of a box needs a
// relative extent, and it has none on that axis.
let inner = rect(Color::BLUE).add(&mut h.rsc);
let row = inner.pad(10).height(40).add(&mut h.rsc);
let filler = rect(Color::GREEN).add(&mut h.rsc);
let column = (row, filler).span(Dir::DOWN).add(&mut h.rsc);
let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, column).span(Dir::RIGHT));
assert_corners!(h, inner, (110, 10), (390, 30));
h.rsc[bar].x = Some(Len::px(200));
h.frame();
assert_corners!(h, inner, (210, 10), (390, 30));
}
#[test]
fn only_a_container_that_places_its_children_lengthens_the_chain() {
let mut h = Harness::new((400, 200));
let leaf = rect(Color::BLUE).add(&mut h.rsc);
// Four widgets between the span and the leaf, none of which places what
// it draws, so all of them share the span's slot.
let buried = leaf.pad(4).pad(4).pad(4).pad(4).add(&mut h.rsc);
let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, buried).span(Dir::RIGHT));
let slot = h.render.active[&leaf.id()].parent_move;
assert_eq!(
h.render.moves.depth(slot),
2,
"the span above the leaf, and the root the window is held in"
);
}
/// A span that sizes from its children passes their `rest` weight up rather
/// than collapsing it to one share, so nesting divides the same space instead
/// of re-dividing a share of it.
#[test]
fn nested_spans_divide_the_space_once_however_deep_the_nesting_is() {
let mut h = Harness::new((400, 200));
let (a, b, c, d) = (
rect(Color::RED).add(&mut h.rsc),
rect(Color::BLUE).add(&mut h.rsc),
rect(Color::GREEN).add(&mut h.rsc),
rect(Color::WHITE).add(&mut h.rsc),
);
let left = (a, b).span(Dir::RIGHT).add(&mut h.rsc);
let right = (c, d).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((left, right).span(Dir::RIGHT));
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));
}
}
/// 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.
#[test]
fn an_uneven_nesting_still_gives_every_share_the_same_length() {
let mut h = Harness::new((400, 200));
let (a, b, c, d) = (
rect(Color::RED).add(&mut h.rsc),
rect(Color::BLUE).add(&mut h.rsc),
rect(Color::GREEN).add(&mut h.rsc),
rect(Color::WHITE).add(&mut h.rsc),
);
let one = (a,).span(Dir::RIGHT).add(&mut h.rsc);
let three = (b, c, d).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root((one, three).span(Dir::RIGHT));
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));
}
}
/// Where the shader puts an edge: the two parts of a scalar are floored
/// apart, so a fraction and a pixel offset snap independently.
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 edge = |s: UiScalar| (s.rel * dim).floor() + s.px.floor();
let span = region.axis(axis);
(edge(span.start), edge(span.end))
}
fn hairline(h: &mut Harness, marks: &mut Vec<WidgetId>) -> StrongWidget {
let inner = rect(Color::RED).add_strong(&mut h.rsc);
let mark = SetSize {
inner,
x: Some(Len::px(1.0)),
y: None,
}
.add_strong(&mut h.rsc);
marks.push(mark.id());
mark
}
fn share(h: &mut Harness, inner: StrongWidget, ratio: f32) -> StrongWidget {
SetSize {
inner,
x: Some(Len::rest(ratio)),
y: None,
}
.add_strong(&mut h.rsc)
}
/// Shares in weights no binary fraction lands on, a padding on one branch
/// and not the other, so an edge falls near an integer as often as it can.
fn hairlines(h: &mut Harness, depth: usize, marks: &mut Vec<WidgetId>) -> StrongWidget {
let mut span = Span::empty(Dir::RIGHT);
if depth == 0 {
let left = rect(Color::BLUE).add_strong(&mut h.rsc);
let left = share(h, left, 3.0);
span.push(left);
let mark = hairline(h, marks);
span.push(mark);
let right = rect(Color::BLUE).add_strong(&mut h.rsc);
let right = share(h, right, 7.0);
span.push(right);
return span.add_strong(&mut h.rsc);
}
let first = hairlines(h, depth - 1, marks);
let first = share(h, first, 3.0);
span.push(first);
let second = hairlines(h, depth - 1, marks);
let second = Pad {
padding: Padding {
left: 3.0,
right: 7.0,
top: 0.0,
bottom: 0.0,
},
inner: second,
}
.add_strong(&mut h.rsc);
let second = share(h, second, 5.0);
span.push(second);
span.add_strong(&mut h.rsc)
}
/// A one-pixel line is a pixel wherever it is drawn. Both edges of a fixed
/// length share their box's fraction, so composing the chain moves them
/// together and the shader's `floor` cannot round the pixel between them
/// away -- only shift it. A separator that disappeared at one window size
/// would be a defect no size comparison catches.
#[test]
fn a_one_pixel_line_keeps_its_pixel_through_a_chain() {
let mut h = Harness::new((1920, 1200));
let mut marks = Vec::new();
let root = hairlines(&mut h, 4, &mut marks);
h.state.set_root(root);
h.frame();
assert_eq!(marks.len(), 16);
for size in [(1920, 1200), (1919, 1201), (997, 1003), (1367, 733)] {
h.resize(size);
h.frame();
for mark in &marks {
let (start, end) = drawn_edges(&h, *mark, Axis::X);
assert_eq!(end - start, 1.0, "at {size:?}, mark {mark:?}");
}
}
}
/// A span short of room takes it from its shares, which go to nothing and
/// then to nothing wider; the fixed lengths between them keep their pixels.
/// Collapsing those to make room would delete a separator the caller asked
/// for, which is worse than overflowing.
#[test]
fn a_span_out_of_room_shrinks_its_shares_and_not_its_fixed_lengths() {
let mut h = Harness::new((400, 20));
let mut marks = Vec::new();
let mut span = Span::empty(Dir::RIGHT);
for _ in 0..3 {
let share_of = rect(Color::BLUE).add_strong(&mut h.rsc);
let share_of = share(&mut h, share_of, 1.0);
span.push(share_of);
let mark = hairline(&mut h, &mut marks);
span.push(mark);
}
let root = span.add_strong(&mut h.rsc);
h.state.set_root(root);
h.frame();
for width in [400, 10, 3, 1] {
h.resize((width, 20));
h.frame();
for mark in &marks {
let (start, end) = drawn_edges(&h, *mark, Axis::X);
assert_eq!(end - start, 1.0, "at {width} wide, mark {mark:?}");
}
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Retained CPU-layout diagnostics on one reproducible random tree.
//!
//! Counters and phase timers:
//!
//! cargo test --release --features layout-diagnostics \
//! --test layout_diagnostics -- --ignored --nocapture
//!
//! Uninstrumented hardware totals for one phase:
//!
//! IRIS_PHASE=resize IRIS_FRAMES=1000 perf stat \
//! -e cycles:u,instructions:u cargo test --release \
//! --test layout_diagnostics -- --ignored --nocapture
//!
//! `IRIS_PHASE` is `cold`, `repaint`, `many`, `size`, `scroll`, `resize`, or
//! `all`. `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load, and
//! `IRIS_DIRTY` how many widgets `many` marks at once.
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Edits, Tree, grow};
use std::time::Instant;
const OUTPUT: (f32, f32) = (1920.0, 1200.0);
#[cfg(feature = "layout-diagnostics")]
#[test]
fn a_selected_widget_retains_its_layout_events() {
use iris::core::layout_diagnostics::{self as diagnostics, TraceEvent};
diagnostics::clear_traced_widgets();
let _ = diagnostics::take();
let mut harness = Harness::new((400, 200));
let leaf = rect(Color::RED).add(&mut harness.rsc);
let other = rect(Color::BLUE).add(&mut harness.rsc);
let root = (leaf, other).span(Dir::RIGHT).add(&mut harness.rsc);
harness.set_root(root);
diagnostics::trace_widget(leaf.id());
let _ = diagnostics::take();
let _ = harness.rsc.widgets_mut().get_dyn_mut(root.id());
let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf.id());
harness.frame();
let report = diagnostics::take();
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::Placed { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::DrawRequest { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::SizeRead { id, .. } if *id == leaf.id()))
);
assert!(
report
.traces()
.iter()
.any(|event| matches!(event, TraceEvent::SizeReported { id, .. } if *id == leaf.id()))
);
diagnostics::clear_traced_widgets();
}
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)
}
#[cfg(feature = "layout-diagnostics")]
fn trace_selected(tree: &Tree) {
let Ok(value) = std::env::var("IRIS_TRACE_INDEX") else {
return;
};
let index = value
.parse::<usize>()
.expect("IRIS_TRACE_INDEX must be a tree.ids index");
let id = tree.ids[index];
iris::core::layout_diagnostics::trace_widget(id);
println!("tracing tree.ids[{index}] = {id:?}");
}
#[cfg(not(feature = "layout-diagnostics"))]
fn trace_selected(_: &Tree) {}
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());
harness.state.root = Some(root);
harness.frame();
println!(
"fixture: seed {seed}, depth {depth}, {} widgets, {} active",
tree.ids.len(),
harness.render.active_widgets()
);
#[cfg(feature = "layout-diagnostics")]
let _ = iris::core::layout_diagnostics::take();
(harness, tree)
}
fn report(label: &str, mut elapsed: Vec<f64>, _harness: &Harness) {
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
let frames = elapsed.len();
// The worst frame is the stutter somebody sees, so it goes beside the
// median; p99 says whether it is the load or a single interruption.
println!(
"{label}: {frames} frame(s), min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \
max {:.3} ms, total {:.1} ms",
elapsed[0],
elapsed[frames / 2],
elapsed[frames * 99 / 100],
elapsed[frames - 1],
elapsed.iter().sum::<f64>(),
);
#[cfg(feature = "layout-diagnostics")]
{
let diagnostics = iris::core::layout_diagnostics::take();
print!("{}", diagnostics.per_frame(frames));
for callsite in diagnostics.hot_text().iter().take(3) {
let mut ancestry = Vec::new();
let mut id = Some(callsite.id);
while let Some(widget) = id {
ancestry.push(_harness.rsc.widgets().label(widget).as_str());
id = _harness
.render
.active
.get(&widget)
.and_then(|active| active.parent);
}
println!(" text ancestry: {}", ancestry.join(" < "));
}
}
}
fn run(
label: &str,
frames: usize,
harness: &mut Harness,
mut change: impl FnMut(&mut Harness, usize),
) {
let mut elapsed = Vec::with_capacity(frames);
for frame in 0..frames {
change(harness, frame);
let start = Instant::now();
harness.frame();
elapsed.push(start.elapsed().as_secs_f64() * 1_000.0);
}
report(label, elapsed, harness);
}
#[test]
#[ignore = "measurement, not a check"]
fn layout_cost() {
let seed = env("IRIS_SEED", 1_u64);
let depth = env("IRIS_DEPTH", 7_usize);
let frames = env("IRIS_FRAMES", 100_usize);
assert!(frames > 0, "IRIS_FRAMES must be greater than zero");
let phase = env("IRIS_PHASE", String::from("all"));
assert!(
["all", "cold", "repaint", "many", "size", "scroll", "resize"].contains(&phase.as_str()),
"unknown IRIS_PHASE {phase:?}"
);
let selected = |name| phase == "all" || phase == name;
if selected("cold") {
let mut harness = Harness::new(OUTPUT);
let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default());
harness.state.root = Some(root);
println!(
"fixture: seed {seed}, depth {depth}, {} widgets",
tree.ids.len()
);
trace_selected(&tree);
#[cfg(feature = "layout-diagnostics")]
let _ = iris::core::layout_diagnostics::take();
run("cold", 1, &mut harness, |_, _| {});
drop(tree);
}
if selected("repaint") {
let (mut harness, tree) = warm(seed, depth);
trace_selected(&tree);
let leaf = tree.ids[0];
run("repaint", frames, &mut harness, move |harness, _| {
let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf);
});
}
if selected("many") {
let (mut harness, tree) = warm(seed, depth);
trace_selected(&tree);
// Spread through the tree rather than taken from one subtree, so the
// dependency paths the frame settles overlap.
let wanted = env("IRIS_DIRTY", 32_usize).max(1);
let step = (tree.ids.len() / wanted).max(1);
let dirty: Vec<_> = tree.ids.iter().copied().step_by(step).collect();
println!("marking {} of {} widgets", dirty.len(), tree.ids.len());
run("many", frames, &mut harness, move |harness, _| {
for &id in &dirty {
harness.rsc.widgets_mut().get_dyn_mut(id);
}
});
}
if selected("size") {
let (mut harness, tree) = warm(seed, depth);
trace_selected(&tree);
let sized = tree.sized[0];
run("size", frames, &mut harness, move |harness, frame| {
harness.rsc[sized].x = Some(Len::px(100.0 + (frame % 2) as f32 * 40.0));
});
}
if selected("scroll") {
let (mut harness, tree) = warm(seed, depth);
trace_selected(&tree);
let scroll = tree.scrolls[0];
run("scroll", frames, &mut harness, move |harness, frame| {
harness.rsc[scroll].scroll(if frame % 2 == 0 { 12.0 } else { -12.0 });
});
}
if selected("resize") {
let (mut harness, tree) = warm(seed, depth);
trace_selected(&tree);
run("resize", frames, &mut harness, |harness, frame| {
harness.resize((OUTPUT.0 - ((frame + 1) % 2) as f32 * 8.0, OUTPUT.1));
});
drop(tree);
}
}
-27
View File
@@ -1,27 +0,0 @@
//! What a drawing can be taken out of, and what it cannot.
use iris::core::{Remap, UiRegion, UiScalar, UiSpan};
/// A box `size` tall whose top is `rel` of the way down the window.
fn fixed(rel: f32, size: f32) -> UiRegion {
UiRegion::new(
UiSpan::FULL,
UiSpan::new(UiScalar { rel, abs: 0.0 }, UiScalar { rel, abs: size }),
)
}
#[test]
fn a_fixed_box_can_be_carried_but_not_stretched() {
let from = fixed(0.0, 164.0);
assert!(Remap::new(from, UiRegion::FULL).is_none());
assert!(Remap::new(from, fixed(0.5, 164.0)).is_some());
assert!(Remap::new(from, fixed(0.0, 98.0)).is_none());
}
#[test]
fn a_relative_box_can_be_stretched_to_any_other() {
let remap = Remap::new(UiRegion::FULL, fixed(0.0, 98.0)).expect("relative boxes remap");
// A part that filled the window keeps filling what replaced it, which is
// exactly what `outside` could not say for a box of a fixed length.
assert_eq!(remap.apply(UiRegion::FULL), fixed(0.0, 98.0));
}
+43
View File
@@ -0,0 +1,43 @@
//! What re-placing a subtree costs per frame, as a load for a counter rather
//! than a check. A span of 200 fixed-height rows, five primitives each, with
//! the row above them changing height every frame, so every row below is
//! offered a box the same shape somewhere else.
//!
//! cargo test --release --test replace_cost -- --ignored
//! perf stat -e instructions:u target/release/.../replace_cost-* --ignored
//!
//! Wall time is the wrong number here; see `draw_cost.rs`. Measured on
//! 2026-09-14 at 1.98M instructions per frame, against 2.38M for rewriting
//! each row's regions instead and 7.13M for redrawing them.
use iris::harness::Harness;
use iris::prelude::*;
const ROWS: usize = 200;
const FRAMES: usize = 200;
#[test]
#[ignore = "measurement, not a check"]
fn replacing_rows_every_frame() {
let mut h = Harness::new((1920, 1200));
let first = rect(Color::RED).height(40).add(&mut h.rsc);
let mut span = Span::empty(Dir::DOWN);
span.push(first.add_strong(&mut h.rsc));
for i in 0..ROWS {
let row = (
rect(Color::BLUE.darker(i as f32 / (ROWS * 2) as f32)),
rect(Color::GREEN).pad(2),
wtext("row").size(16).pad(2),
)
.span(Dir::RIGHT)
.pad(4)
.height(40)
.add(&mut h.rsc);
span.push(row.add_strong(&mut h.rsc));
}
h.set_root(span);
for i in 0..FRAMES {
h.rsc[first].y = Some(Len::px(40.0 + (i % 2) as f32));
h.frame();
}
}
+230 -11
View File
@@ -79,10 +79,9 @@ fn a_leaf_that_depends_on_its_box_is_drawn_again_when_the_box_changes() {
h.rsc[first].size = Size::from((150, 200));
h.frame();
// Twice: once for the span to measure it, once for its real box. A child
// that can hint its length is spared the first, and a smaller number here
// means someone has made that cheaper rather than broken it.
assert_eq!(draws.get(), settled + 2);
// The preceding fixed child makes the remaining box this child's real
// box, so measuring it also draws it in its final place.
assert_eq!(draws.get(), settled + 1);
assert_corners!(h, second, (150, 0), (400, 200));
}
@@ -116,6 +115,22 @@ fn a_span_relays_out_when_a_child_it_measured_changes() {
assert_corners!(h, second, (250, 0), (400, 200));
}
#[test]
fn a_repaint_that_keeps_its_size_does_not_relay_out() {
let mut h = Harness::new((400, 200));
let (first, draws) = counted(&mut h, Size::from((100, 200)), OnResize::Translate);
let (second, _) = counted(&mut h, Size::REST, OnResize::Translate);
h.set_root((first, second).span(Dir::RIGHT));
let settled = draws.get();
// Taking mutable access is the ordinary content-change signal. This
// widget returns the same size, so the parent has nothing to lay out.
let _ = h.rsc.widgets_mut().get_dyn_mut(first.id());
h.frame();
assert_eq!(draws.get(), settled + 1);
}
#[test]
fn a_placed_child_survives_the_next_frame() {
let mut h = Harness::new((400, 200));
@@ -141,7 +156,7 @@ impl Widget for FromHint {
fn draw(&mut self, painter: &mut Painter) -> Size {
let len = painter.size_hint(&self.inner, Axis::Y).unwrap();
let mut region = UiRegion::FULL;
region.y.end = region.y.start.offset(len.abs);
region.y.end = region.y.start.offset(len.px);
painter.widget_within(&self.inner, region);
Size::REST
}
@@ -158,7 +173,7 @@ fn a_parent_that_only_read_a_hint_relays_out_when_the_hint_changes() {
h.set_root(parent);
assert_corners!(h, inner, (0, 0), (400, 80));
h.rsc[inner].y = Some(Len::abs(120));
h.rsc[inner].y = Some(Len::px(120));
h.frame();
assert_corners!(h, inner, (0, 0), (400, 120));
@@ -172,14 +187,34 @@ struct ReadsOutput {
impl Widget for ReadsOutput {
fn draw(&mut self, painter: &mut Painter) -> Size {
self.draws.set(self.draws.get() + 1);
Size::abs(painter.output_size() / 4.0)
Size::px(painter.output_size() / 4.0)
}
}
/// Reads the output across one axis only, and says so: its drawing follows
/// a taller box on its own, so only a wider one is worth a draw.
struct ReadsWidth {
draws: Rc<Cell<usize>>,
}
impl Widget for ReadsWidth {
fn draw(&mut self, painter: &mut Painter) -> Size {
self.draws.set(self.draws.get() + 1);
Size::px((painter.output_len(Axis::X) / 4.0, 20.0).into())
}
fn on_resize(&self, axis: Axis) -> OnResize {
match axis {
Axis::X => OnResize::Redraw,
Axis::Y => OnResize::Scale,
}
}
}
#[test]
fn a_resize_does_not_redraw_what_the_shader_can_move() {
let mut h = Harness::new((400, 200));
let (leaf, draws) = counted(&mut h, Size::REST, OnResize::Redraw);
let (leaf, draws) = counted(&mut h, Size::REST, OnResize::Scale);
h.set_root(leaf);
let settled = draws.get();
@@ -190,11 +225,28 @@ fn a_resize_does_not_redraw_what_the_shader_can_move() {
assert_eq!(
draws.get(),
settled,
"its box is the same fraction of a different output"
"a scaling drawing follows its box, and the output is one"
);
assert_corners!(h, leaf, (0, 0), (800, 100));
}
/// The output is the root of the box chain, so a resize is a box that changed
/// length and `OnResize` answers for it -- there is not a second rule for the
/// window. A drawing that does not scale is redrawn whichever box moved.
#[test]
fn a_resize_redraws_what_does_not_scale() {
let mut h = Harness::new((400, 200));
let (leaf, draws) = counted(&mut h, Size::REST, OnResize::Redraw);
h.set_root(leaf);
let settled = draws.get();
h.resize((800, 100));
h.frame();
assert_eq!(draws.get(), settled + 1, "its box is a different length");
assert_corners!(h, leaf, (0, 0), (800, 100));
}
#[test]
fn a_resize_redraws_what_read_the_output() {
let mut h = Harness::new((400, 200));
@@ -212,6 +264,83 @@ fn a_resize_redraws_what_read_the_output() {
assert_eq!(draws.get(), settled + 1);
}
#[test]
fn a_resize_only_redraws_read_output_axes() {
let mut h = Harness::new((400, 200));
let draws = Rc::new(Cell::new(0));
let leaf = ReadsWidth {
draws: draws.clone(),
}
.add(&mut h.rsc);
h.set_root(leaf);
let settled = draws.get();
h.resize((400, 300));
h.frame();
assert_eq!(draws.get(), settled, "height was never read");
h.resize((800, 300));
h.frame();
assert_eq!(draws.get(), settled + 1, "width changes its answer");
}
#[test]
fn subpixel_resize_changes_accumulate_from_the_last_layout() {
let mut h = Harness::new((400, 200));
let draws = Rc::new(Cell::new(0));
let leaf = ReadsWidth {
draws: draws.clone(),
}
.add(&mut h.rsc);
h.set_root(leaf);
let settled = draws.get();
for width in [400.02, 400.04, 400.05] {
h.resize((width, 200.0));
h.frame();
assert_eq!(draws.get(), settled);
}
h.resize((400.06, 200.0));
h.frame();
assert_eq!(draws.get(), settled + 1);
}
#[test]
fn subpixel_box_changes_accumulate_from_the_last_draw() {
let mut h = Harness::new((400, 200));
let (first, draws, _) = pair(&mut h, OnResize::Redraw);
let settled = draws.get();
for width in [100.02, 100.04, 100.05] {
h.rsc[first].size.x = Len::px(width);
h.frame();
assert_eq!(draws.get(), settled);
}
h.rsc[first].size.x = Len::px(100.06);
h.frame();
assert_eq!(draws.get(), settled + 1);
}
#[test]
fn reporting_the_same_output_size_does_not_start_a_resize() {
let mut h = Harness::new((400, 200));
let draws = Rc::new(Cell::new(0));
let leaf = ReadsOutput {
draws: draws.clone(),
}
.add(&mut h.rsc);
h.set_root(leaf);
let settled = draws.get();
h.resize((400, 200));
assert!(!h.needs_redraw());
h.frame();
assert_eq!(draws.get(), settled);
}
#[test]
fn narrowing_the_output_reflows_text_and_relays_out_around_it() {
let mut h = Harness::new((600, 400));
@@ -239,14 +368,104 @@ fn a_change_two_levels_under_its_reader_still_reaches_it() {
// Every wrapper up to the outer pad read the size below it, so the outer
// pad is what draws again -- and the span it hands the box to is the same
// size as before, which is what lets a draw reuse its way past the leaf.
let (leaf, _) = counted(&mut h, Size::abs((100, 100).into()), OnResize::Redraw);
let (leaf, _) = counted(&mut h, Size::px((100, 100).into()), OnResize::Redraw);
let padded = leaf.pad(10).add(&mut h.rsc);
let below = rect(Color::RED).add(&mut h.rsc);
h.set_root((padded, below).span(Dir::DOWN).pad(12));
assert_corners!(h, below, (12, 132), (388, 388));
h.rsc[leaf].size = Size::abs((100, 200).into());
h.rsc[leaf].size = Size::px((100, 200).into());
h.frame();
assert_corners!(h, below, (12, 232), (388, 388));
}
/// Claims its drawing survives its box changing length, and has a child so
/// that the walk looking for what does not has one to reach.
struct Stretchy {
inner: StrongWidget,
draws: Rc<Cell<usize>>,
}
impl Widget for Stretchy {
fn draw(&mut self, painter: &mut Painter) -> Size {
self.draws.set(self.draws.get() + 1);
painter.widget(&self.inner).size()
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
#[test]
fn stretching_a_subtree_carries_the_children_in_it() {
let mut h = Harness::new((400, 400));
let first = rect(Color::RED).height(40).add(&mut h.rsc);
let inner = rect(Color::BLUE).add(&mut h.rsc);
let draws = Rc::new(Cell::new(0));
let outer = Stretchy {
inner: inner.add_strong(&mut h.rsc),
draws: draws.clone(),
}
.add(&mut h.rsc);
h.set_root((first, outer).span(Dir::DOWN));
let settled = draws.get();
assert_corners!(h, inner, (0, 40), (400, 400));
h.rsc[first].y = Some(Len::px(80));
h.frame();
assert_eq!(
draws.get(),
settled,
"its drawing follows its box, rather than being made again"
);
assert_corners!(h, outer, (0, 80), (400, 400));
assert_corners!(h, inner, (0, 80), (400, 400));
}
#[test]
fn a_widened_row_redraws_what_reads_its_length_and_nothing_else() {
let mut h = Harness::new((400, 200));
// What a transcript row is: something whose shaping depends on the width
// it is given, beside something that only has to be the right shape.
let (wraps, wrap_draws) = counted(&mut h, Size::REST, OnResize::Redraw);
let (backing, back_draws) = counted(&mut h, Size::REST, OnResize::Scale);
let row = (backing, wraps).span(Dir::RIGHT).add(&mut h.rsc);
let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, row).span(Dir::RIGHT));
let (settled_wrap, settled_back) = (wrap_draws.get(), back_draws.get());
h.rsc[bar].x = Some(Len::px(200));
h.frame();
// The span reads every child's size, so redrawing one takes the span
// with it -- and the span then measures and places the redrawn child.
assert!(wrap_draws.get() > settled_wrap, "reads the width it got");
assert_eq!(back_draws.get(), settled_back, "only has to be the shape");
assert_corners!(h, backing, (200, 0), (300, 200));
assert_corners!(h, wraps, (300, 0), (400, 200));
}
#[test]
fn a_declared_length_child_is_not_redrawn_when_the_box_around_it_grows() {
let mut h = Harness::new((400, 200));
// Its box is a fixed 80 wherever the row's edges end up, so drawing it
// again would be for a width it does not have. The declared width is what
// lets the span say that without drawing it: a width the span learnt by
// drawing the child in its own box is only an answer for that box.
let (counter, draws) = counted(&mut h, Size::from((80, 200)), OnResize::Redraw);
let fixed = counter.width(80).add(&mut h.rsc);
let (rest, _) = counted(&mut h, Size::REST, OnResize::Scale);
let row = (fixed, rest).span(Dir::RIGHT).add(&mut h.rsc);
let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, row).span(Dir::RIGHT));
let settled = draws.get();
h.rsc[bar].x = Some(Len::px(200));
h.frame();
assert_eq!(draws.get(), settled, "its own length did not change");
assert_corners!(h, fixed, (200, 0), (280, 200));
}
+199
View File
@@ -0,0 +1,199 @@
//! What a resize frame costs and what it holds, on a tree the revision before
//! #16 also builds.
//!
//! Deliberately written in the API subset `43ce8c7` and this branch share, so
//! the same source can be dropped into an old worktree and measured there:
//! that is the only like-for-like comparison with the code the retained
//! layout replaced. The random tree cannot carry one, because the generator
//! itself changed with the work.
//!
//! ROWS=40 FRAMES=500 cargo test --release --test revision_cost \
//! -- --ignored --nocapture resize_cost
//! ROWS=2000 cargo test --release --test revision_cost \
//! -- --ignored --nocapture text_memory
//!
//! Wall time on this machine varies with CPU frequency; take the number from
//! `perf stat -e instructions:u` on the test binary directly.
use iris::harness::Harness;
use iris::prelude::*;
use std::time::Instant;
/// xorshift64, so one seed is one set of paragraphs on any machine.
struct Rng(u64);
impl Rng {
fn bits(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.bits() % n as u64) as usize
}
}
const WORDS: [&str; 24] = [
"wrapping",
"shapes",
"one",
"source",
"into",
"as",
"many",
"lines",
"as",
"the",
"box",
"leaves",
"room",
"for",
"paragraph",
"height",
"answer",
"setting",
"container",
"width",
"before",
"knows",
"measured",
"again",
];
/// A run of its own words, so nothing here is fast for two texts being the
/// same string.
fn words(rng: &mut Rng, least: usize, most: usize) -> String {
let words = least + rng.below(most - least);
let mut out = String::new();
for _ in 0..words {
if !out.is_empty() {
out.push(' ');
}
out.push_str(WORDS[rng.below(WORDS.len())]);
}
out
}
const OUTPUT: (f32, f32) = (900.0, 1200.0);
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)
}
/// A row of a fixed-width rect beside a column of one wrapping and one
/// overflowing text: the shape that makes a container measure a child in a
/// box it will not keep.
fn build(h: &mut Harness, rows: usize) -> Vec<WidgetId> {
let mut rng = Rng(1);
let mut paragraphs = Vec::new();
let mut col = Span::empty(Dir::DOWN);
for _ in 0..rows {
let mut row = Span::empty(Dir::RIGHT);
row.push(rect(Color::RED).width(Len::px(40.0)).add_strong(&mut h.rsc));
let mut body = Span::empty(Dir::DOWN);
let para = wtext(words(&mut rng, 12, 52))
.size(16)
.wrap(true)
.add_strong(&mut h.rsc);
paragraphs.push(para.id());
body.push(para);
body.push(
// Short, or its unwrapped width decides the row and the
// paragraph beside it never wraps.
wtext(words(&mut rng, 2, 6))
.size(16)
.wrap(false)
.add_strong(&mut h.rsc),
);
row.push(body.add_strong(&mut h.rsc));
col.push(row.add_strong(&mut h.rsc));
}
let root = col.add(&mut h.rsc);
h.set_root(root);
paragraphs
}
#[test]
#[ignore = "measurement, not a check"]
fn resize_cost() {
let rows = env("ROWS", 40_usize);
let frames = env("FRAMES", 500_usize);
let mut h = Harness::new(OUTPUT);
let paragraphs = build(&mut h, rows);
// What it cost is only half the comparison: the old code is cheaper
// partly because it wraps at the container's whole width rather than the
// part left beside the rect, and draws past the edge of the output.
println!("output width {}", OUTPUT.0);
for (at, id) in paragraphs.iter().enumerate().take(3) {
println!("paragraph {at}: {:?}", h.region(id));
}
// Two widths in turn is the friendly case for anything that remembers an
// answer, so `SWEEP=1` never repeats one -- a drag rather than a toggle.
let sweep = env("SWEEP", 0_usize) != 0;
let mut elapsed = Vec::with_capacity(frames);
for frame in 0..frames {
let narrower = match sweep {
true => (frame % 256) as f32,
false => ((frame + 1) % 2) as f32 * 8.0,
};
h.resize((OUTPUT.0 - narrower, OUTPUT.1));
let start = Instant::now();
h.frame();
elapsed.push(start.elapsed().as_secs_f64() * 1000.0);
}
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!(
"resize: {frames} frames, min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \
max {:.3} ms, total {:.1} ms",
elapsed[0],
elapsed[frames / 2],
elapsed[frames * 99 / 100],
elapsed[frames - 1],
elapsed.iter().sum::<f64>()
);
}
fn kb(field: &str) -> u64 {
std::fs::read_to_string("/proc/self/status")
.unwrap()
.lines()
.find(|line| line.starts_with(field))
.and_then(|line| line.split_whitespace().nth(1)?.parse().ok())
.unwrap()
}
fn report(label: &str) {
println!(
"{label:24} rss {:>7} kB peak {:>7} kB",
kb("VmRSS:"),
kb("VmHWM:")
);
}
/// Run this one on its own: the figures are the whole process's.
#[test]
#[ignore = "measurement, not a check"]
fn text_memory() {
let rows = env("ROWS", 2000_usize);
report("before");
let mut h = Harness::new(OUTPUT);
let paragraphs = build(&mut h, rows);
report("after cold frame");
for frame in 0..40 {
h.resize((OUTPUT.0 - ((frame + 1) % 2) as f32 * 8.0, OUTPUT.1));
h.frame();
}
report("after 40 resizes");
// Settled: the output holds still and one leaf repaints per frame.
for _ in 0..10 {
let _ = h.rsc.widgets_mut().get_dyn_mut(paragraphs[0]);
h.frame();
}
report("after settling");
}
+525
View File
@@ -0,0 +1,525 @@
//! A property test that shrinks 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.
//!
//! 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.
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Branch, Rng};
/// 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.",
];
const ONE_LINE: &str = "one line, overflowing whatever it is given";
const OUTER: (f32, f32) = (1920.0, 1200.0);
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<Len>, Option<Len>, 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>>,
) -> 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))).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: *gap,
}
.add(&mut h.rsc);
spans.push(handle);
handle.add_strong(&mut h.rsc)
}
Node::Stack(kids) => {
let children = kids.iter().map(|k| k.build(h, out, spans)).collect();
Stack {
children,
size: StackSize::Child(0),
}
.add_strong(&mut h.rsc)
}
Node::Pad(p, kid) => {
let inner = kid.build(h, out, spans);
Pad {
padding: Padding {
left: *p,
right: *p,
top: *p,
bottom: *p,
},
inner,
}
.add_strong(&mut h.rsc)
}
Node::Aligned(x, y, kid) => {
let inner = kid.build(h, out, spans);
Aligned {
inner,
align: Align {
x: axis_align(*x),
y: axis_align(*y),
},
}
.add_strong(&mut h.rsc)
}
Node::Sized(x, y, kid) => {
let inner = kid.build(h, out, spans);
SetSize {
inner,
x: *x,
y: *y,
}
.add_strong(&mut h.rsc)
}
Node::Scroll(down, kid) => {
let inner = kid.build(h, out, spans);
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);
let wide = a.build(h, out, spans);
let narrow = b.build(h, out, spans);
Branch {
probe,
wide,
narrow,
threshold: *at,
}
.add_strong(&mut h.rsc)
}
};
out.push(id.id());
id
}
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(Len::px(20.0 + rng.below(180) as f32)),
1 => Some(Len::REST),
_ => 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(Len::px(20.0 + rng.below(180) as f32)),
1 => Some(Len::REST),
2 => Some(Len::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,
}
/// 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 root = node.build(&mut warm, &mut warm_ids, &mut warm_spans);
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();
}
// What the warm tree was moved into, grown that way from the start.
let want = match case {
Case::Reorder => reordered(node),
_ => node.clone(),
};
let mut cold = Harness::new(INNER);
let mut cold_ids = Vec::new();
let mut cold_spans = Vec::new();
let root = want.build(&mut cold, &mut cold_ids, &mut cold_spans);
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));
let same = match (got, want) {
(Some(g), Some(c)) => {
let d = |a: f32, b: f32| (a - b).abs() <= 0.05;
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 {
loop {
let Some(next) = node
.smaller()
.into_iter()
.find(|small| diverges(small, case).is_some())
else {
return node;
};
node = next;
}
}
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)
}
#[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,
_ => Case::Resize,
};
for seed in 1..=seeds {
let node = grow(&mut Rng::new(seed), depth);
let Some(how) = diverges(&node, case) else {
continue;
};
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");
}
let sizes: Vec<usize> = (1..=seeds)
.map(|seed| grow(&mut Rng::new(seed), depth).size())
.collect();
let total: usize = sizes.iter().sum();
println!(
"{seeds} trees at depth {depth} agree: {} widgets total, largest {}",
total,
sizes.iter().max().copied().unwrap_or(0)
);
}
+155
View File
@@ -0,0 +1,155 @@
//! Traces the six-widget tree in `unsettled.rs`, to see what box its text is
//! actually drawn in on a first frame against a settled one.
#![cfg(feature = "layout-diagnostics")]
use iris::core::layout_diagnostics::{self as diag, TraceEvent};
use iris::harness::Harness;
use iris::prelude::*;
fn plant(h: &mut Harness) -> Vec<WidgetId> {
let plain = wtext("Wrapping").size(16).wrap(false).add(&mut h.rsc);
let wrapped = wtext("Wrapping shapes").size(16).wrap(true).add(&mut h.rsc);
let sized = SetSize {
inner: wrapped.add_strong(&mut h.rsc),
x: Some(Len::px(76.0)),
y: None,
}
.add(&mut h.rsc);
let aligned = Aligned {
inner: sized.add_strong(&mut h.rsc),
align: Align {
x: Some(AxisAlign::Pos),
y: Some(AxisAlign::Pos),
},
}
.add(&mut h.rsc);
let stack = Stack {
children: vec![plain.add_strong(&mut h.rsc), aligned.add_strong(&mut h.rsc)],
size: StackSize::Child(0),
}
.add(&mut h.rsc);
let root = (stack,).span(Dir::RIGHT).add(&mut h.rsc);
h.state.root = Some(root.add_strong(&mut h.rsc));
vec![
plain.id(),
wrapped.id(),
sized.id(),
aligned.id(),
stack.id(),
root.id(),
]
}
fn dump(label: &str, report: &diag::Report, text: WidgetId) {
println!("--- {label} ---");
for event in report.traces() {
match event {
TraceEvent::DrawRequest {
id,
region,
pixel_size,
..
} if *id == text => {
println!(
" draw in {:.2}x{:.2} region {region:?}",
pixel_size.x, pixel_size.y
)
}
TraceEvent::SizeReported { id, size } if *id == text => {
println!(" reported {size}")
}
TraceEvent::SizeRead { id, reader, size } if *id == text => {
println!(" size read by {reader:?}: {size}")
}
TraceEvent::Placed { id, parent, region } if *id == text => {
println!(" placed by {parent:?} at {region:?}")
}
TraceEvent::Reuse { id, outcome } if *id == text => println!(" reuse: {outcome:?}"),
_ => {}
}
}
}
#[test]
#[ignore = "a diagnostic, not a check"]
fn what_box_the_text_is_drawn_in() {
diag::clear_traced_widgets();
let _ = diag::take();
let mut h = Harness::new((640, 900));
let ids = plant(&mut h);
let text = ids[1];
diag::trace_widget(text);
let _ = diag::take();
h.frame();
dump("first frame", &diag::take(), text);
for _ in 0..2 {
for &id in &ids {
h.rsc.widgets_mut().get_dyn_mut(id);
}
let _ = diag::take();
h.frame();
dump("repaint", &diag::take(), text);
}
diag::clear_traced_widgets();
}
fn plant_fixed(h: &mut Harness) -> Vec<WidgetId> {
let words = "Wrapping shapes one source into as many lines as the box leaves";
let text = wtext(words).size(16).wrap(true).add(&mut h.rsc);
let aligned = Aligned {
inner: text.add_strong(&mut h.rsc),
align: Align {
x: Some(AxisAlign::Neg),
y: None,
},
}
.add(&mut h.rsc);
let inner = (aligned,).span(Dir::RIGHT).add(&mut h.rsc);
let sized = SetSize {
inner: inner.add_strong(&mut h.rsc),
x: Some(Len::px(189.0)),
y: Some(Len::px(176.0)),
}
.add(&mut h.rsc);
let filler = rect(Color::RED).add(&mut h.rsc);
let root = (filler, sized).span(Dir::RIGHT).add(&mut h.rsc);
h.state.root = Some(root.add_strong(&mut h.rsc));
vec![
text.id(),
aligned.id(),
inner.id(),
sized.id(),
filler.id(),
root.id(),
]
}
#[test]
#[ignore = "a diagnostic, not a check"]
fn what_box_the_fixed_text_is_drawn_in() {
diag::clear_traced_widgets();
let _ = diag::take();
let mut h = Harness::new((1920, 1200));
let ids = plant_fixed(&mut h);
let text = ids[0];
diag::trace_widget(text);
let _ = diag::take();
h.frame();
dump("first frame at 1920", &diag::take(), text);
h.resize((640, 900));
h.frame();
dump("after resize to 640", &diag::take(), text);
let mut cold = Harness::new((640, 900));
let cids = plant_fixed(&mut cold);
diag::clear_traced_widgets();
diag::trace_widget(cids[0]);
let _ = diag::take();
cold.frame();
dump("cold at 640", &diag::take(), cids[0]);
diag::clear_traced_widgets();
}
+298
View File
@@ -0,0 +1,298 @@
//! The smallest trees that laid out differently warm than cold, each shrunk
//! by `tests/shrink.rs` from hundreds of widgets. The first two are a cold
//! 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.
use iris::harness::Harness;
use iris::prelude::*;
/// 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
/// taken again -- so no box may move, and a warm frame has to land where a
/// cold one does.
fn plant(h: &mut Harness) -> Vec<WidgetId> {
let plain = wtext("Wrapping").size(16).wrap(false).add(&mut h.rsc);
let wrapped = wtext("Wrapping shapes").size(16).wrap(true).add(&mut h.rsc);
let sized = SetSize {
inner: wrapped.add_strong(&mut h.rsc),
x: Some(Len::px(76.0)),
y: None,
}
.add(&mut h.rsc);
let aligned = Aligned {
inner: sized.add_strong(&mut h.rsc),
align: Align {
x: Some(AxisAlign::Pos),
y: Some(AxisAlign::Pos),
},
}
.add(&mut h.rsc);
let stack = Stack {
children: vec![plain.add_strong(&mut h.rsc), aligned.add_strong(&mut h.rsc)],
size: StackSize::Child(0),
}
.add(&mut h.rsc);
let root = (stack,).span(Dir::RIGHT).add(&mut h.rsc);
h.set_root(root);
vec![
plain.id(),
wrapped.id(),
sized.id(),
aligned.id(),
stack.id(),
root.id(),
]
}
/// The first frame does not reach the layout a second one does, so "cold" is
/// not a fixed point and comparing against it compares against a tree that
/// has not settled.
#[test]
fn one_frame_is_enough() {
let mut h = Harness::new((640, 900));
let ids = plant(&mut h);
let first = h.region(&ids[1]).unwrap();
for _ in 0..3 {
for &id in &ids {
h.rsc.widgets_mut().get_dyn_mut(id);
}
h.frame();
}
let settled = h.region(&ids[1]).unwrap();
println!(
"first frame {} tall, settled {} tall",
first.bot_right.y - first.top_left.y,
settled.bot_right.y - settled.top_left.y
);
assert_eq!(
first.bot_right.y - first.top_left.y,
settled.bot_right.y - settled.top_left.y,
"the first frame had not finished laying out"
);
}
#[test]
fn repainting_everything_moves_nothing() {
let mut warm = Harness::new((640, 900));
let ids = plant(&mut warm);
for &id in &ids {
warm.rsc.widgets_mut().get_dyn_mut(id);
}
warm.frame();
let mut cold = Harness::new((640, 900));
let cold_ids = plant(&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"));
}
/// Six widgets, shrunk from 905. Everything inside the declared 189x176 box
/// is the same size whatever the output is, so a resize may not change any of
/// it -- but the text comes out 3.92px narrower warm than cold.
fn plant_fixed(h: &mut Harness) -> Vec<WidgetId> {
let words = "Wrapping shapes one source into as many lines as the box leaves";
let text = wtext(words).size(16).wrap(true).add(&mut h.rsc);
let aligned = Aligned {
inner: text.add_strong(&mut h.rsc),
align: Align {
x: Some(AxisAlign::Neg),
y: None,
},
}
.add(&mut h.rsc);
let inner = (aligned,).span(Dir::RIGHT).add(&mut h.rsc);
let sized = SetSize {
inner: inner.add_strong(&mut h.rsc),
x: Some(Len::px(189.0)),
y: Some(Len::px(176.0)),
}
.add(&mut h.rsc);
let filler = rect(Color::RED).add(&mut h.rsc);
let root = (filler, sized).span(Dir::RIGHT).add(&mut h.rsc);
h.state.root = Some(root.add_strong(&mut h.rsc));
vec![
text.id(),
aligned.id(),
inner.id(),
sized.id(),
filler.id(),
root.id(),
]
}
#[test]
fn a_resize_does_not_reach_inside_a_box_of_declared_pixels() {
let mut warm = Harness::new((1920, 1200));
let ids = plant_fixed(&mut warm);
warm.frame();
warm.resize((640, 900));
warm.frame();
let mut cold = Harness::new((640, 900));
let cold_ids = plant_fixed(&mut cold);
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"));
}
/// Four widgets, shrunk from 486. A span's two children are swapped: warm by
/// moving them, cold by growing them that way. Same widgets, same sizes, one
/// ends up 29.9px from where the other does.
fn plant_pair(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, WeakWidget<Span>) {
let wrapped = wtext("Wrapping shapes one source into as many lines")
.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 first: StrongWidget = wrapped.add_strong(&mut h.rsc);
let second: StrongWidget = plain.add_strong(&mut h.rsc);
let children = match swapped {
true => vec![second, first],
false => vec![first, second],
};
let span = Span {
children,
dir: Dir::RIGHT,
gap: 0.0,
}
.add(&mut h.rsc);
let span_handle = span;
let aligned = Aligned {
inner: span.add_strong(&mut h.rsc),
align: Align {
x: Some(AxisAlign::Center),
y: None,
},
}
.add(&mut h.rsc);
h.state.root = Some(aligned.add_strong(&mut h.rsc));
(
vec![wrapped.id(), plain.id(), span.id(), aligned.id()],
span_handle,
)
}
#[test]
fn swapping_two_children_lands_where_growing_them_that_way_does() {
let mut warm = Harness::new((640, 900));
let (ids, span) = plant_pair(&mut warm, false);
warm.frame();
warm.rsc[span].children.rotate_left(1);
warm.frame();
let mut cold = Harness::new((640, 900));
let (cold_ids, _) = plant_pair(&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"));
}
/// Eight widgets, shrunk from 80. The scroll decides how wide to make its
/// content from what the content says, and hands that box down through a
/// pass-through; the span under it was placed once, in that box, so nothing
/// at its own edge says the box was its own answer.
fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget<Span>; 2]) {
let words = "Wrapping shapes one source into as many lines as the box leaves room for,";
let text = wtext(words).size(16).wrap(true).add(&mut h.rsc);
let filler = rect(Color::RED).add(&mut h.rsc);
let mut inner_children: Vec<StrongWidget> =
vec![text.add_strong(&mut h.rsc), filler.add_strong(&mut h.rsc)];
if swapped {
inner_children.rotate_left(1);
}
let inner = Span {
children: inner_children,
dir: Dir::RIGHT,
gap: 0.0,
}
.add(&mut h.rsc);
let block = rect(Color::RED).add(&mut h.rsc);
let fixed = SetSize {
inner: block.add_strong(&mut h.rsc),
x: Some(Len::px(87.0)),
y: None,
}
.add(&mut h.rsc);
let mut outer_children: Vec<StrongWidget> =
vec![fixed.add_strong(&mut h.rsc), inner.add_strong(&mut h.rsc)];
if swapped {
outer_children.rotate_left(1);
}
let outer = Span {
children: outer_children,
dir: Dir::RIGHT,
gap: 0.0,
}
.add(&mut h.rsc);
let through = SetSize {
inner: outer.add_strong(&mut h.rsc),
x: None,
y: None,
}
.add(&mut h.rsc);
let scroll = Scroll::new(through.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
h.state.root = Some(scroll.add_strong(&mut h.rsc));
(
vec![
text.id(),
filler.id(),
inner.id(),
block.id(),
fixed.id(),
outer.id(),
through.id(),
scroll.id(),
],
[inner, outer],
)
}
#[test]
fn a_span_placed_once_in_a_box_its_answer_decided() {
let mut warm = Harness::new((640, 900));
let (ids, spans) = plant_scrolled(&mut warm, false);
warm.frame();
for span in spans {
warm.rsc[span].children.rotate_left(1);
}
warm.frame();
let mut cold = Harness::new((640, 900));
let (cold_ids, _) = plant_scrolled(&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"));
}