Compare commits

...
406 Commits
Author SHA1 Message Date
iris-aiandClaude Opus 5 40b89c1f79 Say from_axes, which is what a constructor is called
`per_axis` on `PlaceDesc` and `Declared` builds a pair by asking for each
axis. `from_axis` beside it already names the three-argument form, so the
plural is the one that takes a function, and both follow Rust's convention
for a constructor rather than an invented word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:53:58 -04:00
iris-aiandClaude Opus 5 4d42f1c8ca Say on_axis for the lift, so it is not indexing's word
`PlaceDescAxis::axis(axis)` shared its name with `PlaceDesc`'s extraction,
which is now `Index<Axis>` and reads `place[axis]`. The two go opposite
directions, so they get different words: `on_axis` pairs with the
`from_axis` it is the shorthand for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:35:22 -04:00
iris-aiandClaude Opus 5 2807a925af Make a declared length one that cannot carry a share
`declared_lens` filtered `leftover` out of both its sources and every
consumer then re-dropped it, so the rule lived in two filters and a comment.
A declaration is a `Len`: `LayoutLen::declared` states the rule once and both
sources go through it, and `Declared` replaces the bare two-element array on
`ActiveData` and in four signatures.

The two sources stay one value deliberately. A rule decides the child's box;
a hint only promises what it will report -- but `size_hint` is by contract an
exact answer with no painter context, and `hints_agree` fails a widget that
draws something else, so narrowing the box to a hint cannot change what is
drawn. Every consumer asks about the length, never which said it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:06:31 -04:00
iris-aiandClaude Opus 5 55df32a33c Say layout's operations by name, and index a pair by its axis
Four rounds over the same idea: an expression that needed a comment to say
what it computed wanted to be a named operation.

The placement description is built by chaining off the value that says it.
`UiSpan::within_desc`/`shifted_desc` and `Len::as_desc` replace the
`PlaceDescAxis::` constructors, `PlaceDescAxis::axis` lifts one axis into a
pair with the whole box across it, and `PlaceDesc::per_axis` covers the case
where the two axes differ. `beside` is dropped: `from_axis` already said it.

Seven module-level functions become methods on the value each took first --
`Widgets::declared_lens`, `LayoutLen::fills`, `PlaceDesc::placement` and
`::rel_base_and_region`, `Size::within_box`, `UiRegion::at_origin` and
`::as_translation`.

`UiSpan::place` is the aligned-placement rule, which was written out three
times; `LayoutLen::without_leftover` is the sibling `apply_leftover` never
had, at six sites; `is_px` and `is_only_leftover` name field comparisons the
surrounding comments had to translate; `Holds::covers` was interval
containment spelled out by hand. A span's `shared` loses the two arguments
that did not vary across its loop.

`LayoutHolds` was four two-element arrays where every other pair here is a
struct of two per-axis values, so nothing it did could be written once.
It becomes `AxisHolds` on `x` and `y`, and `and`, `covers` and `contains`
lose their loops.

Every pair gets `Index<Axis>`/`IndexMut<Axis>` through one macro, and the
eighteen `axis`/`axis_mut` methods go. `const_index` keeps the accessors
usable in const context.

Cold layout is unchanged: `layout_dump` over 400 depth-5 trees is identical
to 58ce74d byte for byte, across all 34,492 boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:56:59 -04:00
iris-ai 58ce74dd7d One argument says where a child goes and what its fractions are of
`Place` was a product written as a sum -- a `Part` and a fill flag -- and
`Part` named three operations the geometry already had, under words that did
not match them. `Of` was `UiSpan::within`, `From` was `UiSpan::shift`, and
`Sized` was `placement`'s own body with the length given rather than
reported. Both enums are gone.

`PlaceDescAxis` says one axis, named after the operation it performs:
`within`, `shifted`, `sized`, and `WHOLE`. What is optional is a builder --
`fills` and `rel_base` -- so a caller writes only what it decided, and the
rel base it does not write follows the constructor: a span composed into the
caller's box narrows it, a span along a cursor does not, a decided length is
it. That was the one rule a caller could get wrong with nothing failing.

`PlaceDesc` says both axes with named fields, so `axis`, `axis_mut` and
`from_axis` work the way they do on every other pair here, and the joint
work -- resolving a region, reading the fill flags -- is written once rather
than per axis. `widget_at` and `place_at` take `impl Into<PlaceDesc>`, so a
wrapper passes a `UiRegion` and says nothing else. `widget_within` and
`ActiveData::narrow_rel_base` are deleted; `asked` and `placed` carry the
rel base their ask stated.

Cold layout is byte-identical to `84dad21`.
2026-09-19 17:59:26 -04:00
iris-ai c55be21761 Put place before the rel base, and say what a Place decides
An argument that is usually `None` goes last. So `widget_at` and `place_at`
take the place first, and `narrow_rel_base` after it.

`Place` and `Part` also now say which of the two boxes they decide, since
that is the question a caller has to answer to pick between them: `Part` is
the child's region, said as a part of the caller's own, and `Within`/`Fill`
is what becomes of the placement in it.
2026-09-19 17:16:41 -04:00
iris-ai beb138632a Say narrow_rel_base, and let a container pass None for it
`narrow` said what the argument did to a value it never named, so a reader
had to go and find out which value. It is `narrow_rel_base`, and the
resolved one stays the bare `rel_base` -- which is also the only one in
`Painter`, `Placing` and `LayoutHolds`, where there is nothing to tell it
apart from.

It takes `impl Into<Option<[Option<Len>; 2]>>`, so a container that does not
narrow anything writes `None` rather than `[None; 2]`, and `Span` builds the
one case that does with a `then` instead of a mutable array.

Cold layout is byte-identical to `84dad21`.
2026-09-19 16:55:48 -04:00
iris-ai aeb60e50f5 Say rel base, and give containers back a box to hand over
`frame` named a length, not a rectangle, which was the one word in the
layout vocabulary that lied about its own shape. It is `rel_base`: what a
fraction a widget declares or reports is a fraction of.

Three API changes with it, all for containers that do one simple thing:

- `widget_within(id, region)` returns, taking a box in the widget's own
  coordinates and deriving the child's rel base from it. `Offset` and `Pad`
  are one call each again. `Offset` also stops reading `region_len`, which
  pinned its drawing to a box length it does not care about.
- `place_at` takes the rel base, returns the answer, and asks the child
  where there is no answer to re-express. Which of the two happens is the
  painter's to work out, so `Span`'s second pass is one call and its
  `drawn_across` bookkeeping is gone.
- `Part::All` is a `Part::WHOLE` constant rather than a variant, since it
  was exactly `Of(UiSpan::FULL)` and bought a separate arm in two matches.
  Measured at 0.07% of instructions retired against 0.04% run-to-run noise.

Cold layout is byte-identical to `84dad21` over 400 depth-5 trees.
2026-09-19 16:33:49 -04:00
iris-ai a904cf4f36 TODO: transforms on a move entry, for stretch and rotation 2026-09-19 15:07:51 -04:00
iris-ai 5642f2010a Say region and placement, not extent
The split box was named `region` and `placement` on 2026-09-17; `frame`
came back as a length and survived, `extent` did not. It stayed as the
name for both halves, distinguished only by prose: `draw_at` bound the
caller's `part` to a parameter called `extent`, and `ActiveData` held two
`UiRegion`s that `draw_at` wrote `part: extent` from.

The box a parent asks a widget in is now the region, and where its
drawing ends up is its placement. `Painter`'s four holds accumulators
become the one `LayoutHolds` they were assembled into, which also drops
the name mapping between them.

The cold dump of 400 depth-5 trees is byte-identical across the change.
2026-09-19 14:49:56 -04:00
iris-ai 84dad211f5 Avoid repeated plan generation and unused diagnostics in layout fuzzers 2026-09-19 13:43:11 -04:00
iris-ai add6774980 Keep retained masks and reparented drawings alive, and advance collapsed slots 2026-09-19 13:43:11 -04:00
iris-ai cadfba05dd Keep only what a room drawing is still needed for
A span measuring a child in the room kept its whole `Size`, of which the
along axis is already in `lens` and only the across one is read again when
the drawing is placed. Keep that length alone, which also retires the
rebinding of the match's result and the one in the placing loop. The
placement comment already says what becomes of a drawing made in the room,
so the measuring pass no longer says it a second time.
2026-09-19 02:51:08 -04:00
iris-ai 38b3a81053 Pin a frame by the fraction the child declared
`size_hint` resolves a child's hint against the asking widget's frame and
pins that frame, so a later draw cannot reuse a resolution made against a
different one. It asked the *resolved* hint whether it still had a fraction,
which is false whenever the frame is itself pixels -- a slot of a row, or the
box a stack's sizing child decided -- and the pin was dropped there. Ask the
declared hint, which is what made this draw depend on the frame, and what
`ruled` in `render_state` already asks for a rule.

No generated tree distinguishes the two: the fuzzer grows no `rel` rules, and
a frame that changes almost always changes a box the other pins catch. Kept
for the reason the `frame_len` pin beside it is kept -- "these two
invalidations always coincide" is an assumption nothing states.
2026-09-19 02:51:08 -04:00
iris-ai f6242aa33c Take a span child's length from its hint, and ask it once in its slot
A share child was drawn in the measuring room and again in its slot, and
one record holding two questions made every local change under it defer
to the span. Where a rule or a hint gives the length along the span, the
first ask answers nothing the rule does not, so the child is asked once,
in its slot; the widgets that always report the whole of their box now
say so. A hint with a fraction resolves against the frame and pins it.

The dump rig prints every cold layout so a change to it shows in a diff.
2026-09-19 02:09:41 -04:00
iris-ai a888717ee9 Say window where these comments still say frame
Lengths became lengths of the window when the frame did, and `Part::From`'s
own documentation still described its spans as frame lengths -- which is
what the scroll above read them as.
2026-09-19 01:23:34 -04:00
iris-ai e8a5792dcb Place a scroll's fitting content in the viewport, not in the window
A scroll that has not been scrolled and whose content fits asked for its
content box as `Part::From(UiSpan::FULL)`. A `Part::From` span is in window
lengths, so `rel(1.0)` in one is the whole window rather than the whole box,
and the content landed in a window-tall box anchored at the viewport's
start -- 50 px low for a 300 px viewport in a 400 px window.

Saying the whole of the box as `Part::All` is the one expression that cannot
mean anything else, and it is also the place the child was already asked in,
so the placement becomes a no-op.
2026-09-19 01:23:34 -04:00
iris-aiandClaude Opus 5 a30971e4c5 Call the record's boxes what they are
The offer names are from the protocol before this one, where a widget was
drawn twice and the record had to say which drawing was the question. It
is asked once now, so offer_part is the part it was asked in, offer_place
the place it was asked at, and place where its drawing was put: part,
asked and placed. LayoutHolds::frame is a range on the window since the
frame became a length of one, and the frame's own entry is the frame_len
pin beside it, so it is window; Painter::frame_own goes with it.
answers_at had one caller and said less than the line that replaces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:40:23 -04:00
iris-aiandClaude Opus 5 adbedaf264 Say what the fuzzer's branching widget branched on
Branch reads a measurement in pixels and draws a different subtree either
side of a threshold, and it left that read as a pin on the window, so
every one of them redrew on every resize: at depth 8 that was seed 1's
resize going from 40 widget draws to 131 and seed 13's from nothing to
828. It now states the range it actually branched on, the way Span states
the one that decides whether its shares have room. A fixture that redraws
everything on a resize cannot tell a change that reuses well from one
that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:24:10 -04:00
iris-aiandClaude Opus 5 23523eea29 Take a window read where a length is resolved against it
A widget that resolves a window length in pixels depends on that window
wherever the length is a fraction of it, and nothing was recording that:
Painter::to_px replaces window_px_len and pins the window it read, while
a length that is only pixels is that many pixels in any window and pins
nothing. Span still states the range it actually branched on, which
replaces the pin with something wider.

Scroll is where it showed: its content's answer is a window length now,
so a viewport whose own box does not change with the window -- 40 px of
a branch's box -- kept an end-snapped offset from the window before.
Seed 942 at depth 6 under resize, pinned as
unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:21:11 -04:00
iris-aiandClaude Opus 5 1512d8418b Make the frame a length of the window and the box a region
There is one coordinate unit, the window. Every box in the tree is a
region in window units and a widget's frame is a length in the same
units, which is only what fractions resolve against, so the box need not
be the frame and padding can take from both without either becoming the
other. A region node's entry is a translation -- a rel 1 region anchored
where its box starts -- rather than a box, so nothing composes a frame
back up a chain and a node that moves is one entry write.

Padding is then an inset of both: its pixels come off the frame, so
rel(1.0) under it fills the padded widget rather than overflowing it,
and off the box, so what is drawn sits inside. A length a container
decides for a child's frame is a length of the window like everything
else here -- a row's slot, padding's frame less its pixels, or the box a
stack's sizing child decided, which arrives as Part::Sized -- because a
slot of a row is not a fraction of anything the row can name, the same
reason a node entry is a translation. A declaration is a fraction of
whichever of those reached it, and is the only one that also places the
box.

Frame validity is a pin beside the box's, not a range: a range of window
pixels cannot say which frame an answer is a fraction of, since two
frames are different lengths at the same window size. A widget pins its
frame by reading it or by being answered with it under a fractional
rule, and the pin composes up wherever a length of this frame is what
reached the child.

Also here, because the diagnosis needed them: the shrinker reports the
shrunk tree's own divergence with each level's frame, ask, box and size
warm against cold, and there is a size-resize case -- a change and then a
resize, the order that shows an answer kept as a fraction of the wrong
length, which every other case compares at the window it was made at.

Three defects the reports found, each pinned: a rule changed over two
pads relocated the column under them instead of dividing it again (seed
59, depth 5, resize-size), a share inside padding had the padding taken
off twice, and a root resolved its own rule twice.

fmt and clippy clean with and without layout-diagnostics, 121 suite, 20
core, 11 generated, the 400-seed depth-5 shrinker over all sixteen cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:14:46 -04:00
iris-aiandClaude Fable 5.1 0ef87ebfcf Defer a twice-asked widget's local redraw to its parent
A span asks a share child twice in one draw: in the room, whose answer
its slots rest on, and in the decided slot, whose cross-axis answer it
reads. The record keeps only the second question, so a local redraw that
found that answer unchanged never told the row that the first had --
seed 946 at depth 6, where emptying a fixed-height column turns it from
a share into a fixed width as wide as the row. A widget its parent asked
more than once in one draw now defers to that parent, like one whose
declared length changed. Pinned as
unsettled::emptying_a_column_the_row_asked_twice_asks_the_row_again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:29:58 -04:00
iris-aiandClaude Fable 5.1 3091fb86df Ask each child once and place its answer by re-expression
A widget draws in the box it is asked in and its answer is placed inside
that box by re-expressing the drawing; nothing is drawn again in a box an
answer chose. The offer machinery, whose job was to tell a measuring draw
from a placing one, goes with the placing draw. A span measures each child
from its cursor and moves fixed children to their slots with place_at; a
share child is asked once more in its decided slot with its frame narrowed
to it. A stack asks non-sizing children in the box its sizing child
decided. A scroll asks its content once and moves it to the scrolled
offset. A local redraw asks the retained question again and puts the
answer back where the parent placed it.

A symbolic length a child pinned composes through Part::Of exactly where
the part is the whole box less pixels, and pins the parent's own length
otherwise; dropping it let a pad reuse a drawing across a narrowed frame
of the same pixel length (shrinker seeds 60, 248 and 384 at depth 5).

Suite 114/114 including the two decided-box pins, fast oracle 11/11,
shrinker 400 seeds at depth 5 over all fifteen cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:20:57 -04:00
iris-ai 4328eac756 Keep leftover shares inside scroll viewports 2026-09-18 14:11:02 -04:00
iris-ai b842e4f474 Pin decided-box warm/cold failures 2026-09-18 14:09:00 -04:00
iris-ai 49cec82c1b Say which box a pin reaches the parent through, and derive the offer
The review pass over the two commits before it:

- `LayoutHolds`'s comment said a pin does not compose into the parent. It
  does, where the box it pinned is the parent's own box, which is the one
  case where the parent's own length is what was pinned.
- `DrawInfo::offer` was stored beside the two fields it is computed from.
  It is a method now, with the open question written where it is asked
  rather than only in the handoff.
- `Painter::own` is `frame_own` beside `extent_own`, since a widget's own
  box is the extent and the frame is what it is a part of.
- One expression for the length a rule gives a frame (`narrowed_by`) and
  one for what a widget answered (`ActiveData::measured`), each of which
  had two.
- The counter said "the placement it was pinned to" for what is now a
  length; the deferral in `redraw` named the seeds that made it necessary
  before the last commit rather than the ones that do now; three comments
  claimed an inset that does not exist yet.

No behaviour change: 108 suite tests, 20 core, the 11 generated cases, the
same two shrinker seeds failing at 400/5, and `view` and `tabs` byte-identical
to the renders taken before it.
2026-09-18 01:21:03 -04:00
iris-ai 0954770ceb Say what a child gets of a container's box in that box's own lengths
Three things the measurements asked for, all about how much a box that came
from an answer costs.

**A part in the box's own coordinates.** Saying "less eleven pixels at the
end" in frame lengths from the box's start means reading how long the box is,
and a container whose box is its own answer then depends on its own answer:
`Pad` drew sixty-four times in one resize frame at seed 13, chasing its own
width. `Part::Of` says the same thing as a part of the box, which composes
without a length -- pixels are pixels wherever the box lands -- and what a
child under it holds for maps back through that part onto the container's
own box rather than onto the frame.

**One axis of the box at a time.** `extent_len` pinned both axes, so a span
dividing one of them held for one length of the other as well, and a resize
broke every span whose cross-axis answer moved.

**No lazy placement.** Leaving a child's answer to be placed at the end of
the parent's draw, rather than as the child answers, was meant to save a
recomposition. It costs one instead: the drawing is put in the part first
and in the answer's box after, and where it does not hold for both that is
two drawings rather than one. Seed 1 at depth 8 went from 391 widget draws
on a resize to 29 with it gone. The test that pinned three draws for a
numeric leaf in a span goes with it.

Seed 1 at depth 8, widget draws / distinct widgets / update, against #18's
head and against the commit this branch started from:

| phase   | e44dea3      | 34cafb6      | here          |
| ---     | ---          | ---          | ---           |
| cold    | 369/261/10.6 | 463/274/13.3 | 516/288/12.0  |
| repaint | 1            | 1            | 1             |
| many    | 157/95/0.33  | 263/108/0.59 | 187/119/0.52  |
| size    | 16/12/0.018  | 3/3          | 3/3/0.010     |
| scroll  | 2/0.002      | 1            | 1/0.004       |
| resize  | 13/13/0.019  | 22/15/0.032  | 24/76/0.090   |

Seed 13 at depth 8 is where the protocol still costs: `many` 1091 draws
against #18's 524, and `resize` 2215 against a frame #18 does not draw at
all. Both are the same shape -- an answer measured in one box and drawn in
another -- and the handoff says where that comes from.

Checked: fmt, clippy with -D warnings, 108 suite tests, 20 core tests, the
11 generated cases, and the shrinker at 400 trees of depth 5, which fails
seeds 2 (repaint) and 108 (reorder).
2026-09-18 01:06:05 -04:00
iris-ai 1956be3f3d Lay out in a frame that passes through and a box placed in it
A widget is asked in two boxes rather than one. Its frame is what a fraction
it declares or reports is a fraction of, and it passes through a span, a
stack and a scroll unchanged, so `rel(0.5)` is half the same area however
many containers sit between: a frame is narrowed only by what is decided
above the widget -- a declared length, the root. Its extent is where the
drawing goes, given as a `Place` per axis: a part of the parent's own box,
measured in frame lengths from where that box starts, which the child either
fills or has its answer placed inside.

What that buys is that nothing under a container depends on where the
container sits. A container reads `extent_len` for the length it divides and
nothing about the start, so moving it re-places its children by re-adding
that start and draws nobody again; and a fraction is resolved once, against
the frame, rather than once per box it is composed through -- a stack sized
by a child that reports `rel(0.5)` no longer takes half of half.

`Place` replaces `DrawRegion`, `ExtentPlacement`, `widget_within`,
`measure_len`, `region()`, `placement()` and `box_of`. Primitives and masks
are written in the widget's own box's coordinates alone, so the drawing has
one reference rather than two. The placement pin goes with them: reading the
extent's length pins that length symbolically, and pins compose only where a
child's box is its parent's own.

Placing an answer waits for the end of the parent's draw or for the next ask
of that child in it, so a span child is one drawing and one move rather than
two moves.

`Pad` is transparent: its padding goes around what it pads and its child
keeps the outer frame, which is where `Outset` was going anyway. A fraction
under a pad is now a fraction of the frame rather than of the inset box.

Checked: fmt, clippy with -D warnings, 109 suite tests and 20 core tests in
debug, the 11 generated cases, and the shrinker at 400 trees of depth 5 over
all fifteen cases -- which still finds seed 108 under `reorder`, where a
wrapping text measured in one box and drawn in another settles differently
warm than cold. `redraw` therefore keeps the baseline's deferral for a box
that is not as long as the one the widget was measured in; the plan's step
6 is not done, and the next commit message or the handoff says why.
2026-09-18 00:40:59 -04:00
iris-aiandClaude Opus 5 34cafb6edc Read the marks rather than the queue to decide the walk is done
Review of the two commits above. The queue was the walk's only record of
what was left, so a mark that reached `needs_redraw` without going through
`mark` -- an `on_undraw` handler is the reachable one -- would have waited
for the next frame. The set is read again once the queue drains, which is
what the scan it replaced did for free. `pop_last` takes the deepest entry
in one step rather than reading and then removing it.

The rest is comments: nine lines shorter, and the arm that takes an
ordinary ask said only what it does for a declared length.

Unchanged by all of it: 109 suite and 20 core tests, the four fuzzer runs
(100 seeds, 400 trees at depth 5, 1000 at depth 6, 2000 at depth 4), the
five reference renders and the resized `tabs`, and every counter on the
diagnostics rig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:31:29 -04:00
iris-aiandClaude Opus 5 3bf22935ce Take the deepest dirty widget from an ordered queue, not by scanning
The walk found the next widget to settle with `max_by_key` over the whole
`needs_redraw` set, and `depth` is a hash lookup, so a frame did a lookup
per marked widget per pop -- 131 depth reads for nine marks at seed 1
depth 8, 1,314 for 34, and 14,611 for 145. The set is scanned once now and
kept in a `BTreeSet` keyed by depth, and every mark made while the walk
runs goes through `mark`, which puts itself in place. The same three
counts become 57, 160 and 436.

Two things the scan gave for free are paid for explicitly: a widget that
was settled inside an ancestor's draw, or deferred to one, is dropped when
its entry comes up, and an entry whose widget has since changed depth --
a subtree that moved under a new parent -- is re-queued at the depth it now
has. What is drawn does not change: widget draws are identical at every
load measured.

Median frame at seed 1, depth 8: 0.955 -> 0.843 ms with 145 marks, 0.668 ->
0.666 with 34, and seed 13's default load 5.19 -> 4.86 ms. Ties between
equal depths now break by widget id rather than by hash order, which makes
the walk deterministic; nothing in the order within one depth was ever
relied on, since a widget at the same depth as another cannot contain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:16:40 -04:00
iris-aiandClaude Opus 5 e6ba570d07 Give a child a part of the container's extent rather than its raw box
`Pad` and `Stack` read `Painter::placement` to put their children inside
their own drawing, and reading it is what says the drawing holds for that
placement alone. So a pad or a stack anywhere in a row was drawn again --
with its whole subtree -- the moment an earlier sibling changed length,
however little else had moved.

`widget_within` now takes a `DrawRegion`, and `DrawRegion::Extent(part)`
gives the child a part of the extent without reading it. What is retained
is the part rather than the box it resolved to, so moving the extent
re-places the child through the same rule instead of redrawing the parent:
`inherited_children` becomes `extent_children`, carrying `Inherit` for the
wrapper case `Painter::widget` already had and `Within(part)` for the new
one.

The dependency that goes up is a range on the container's extent rather
than on its frame, since only the part's *length* reaches the child and
where the part sits is re-placed. A declared length is unchanged: it is a
length of the frame wherever the box it sits in came from. What still pins
the placement is a report with a fraction in it -- the same fraction of a
different extent is a different length -- and that pin is on the answer,
which `extent_frames_keep_fractional_reports_and_numeric_dependencies_valid`
fails without.

Three tests from the first attempt at this come with it, and the
diagnostics rig now says which of the three contracts refused a reuse,
which is what found the above.

Measured, seed 1 at depth 8, median frame: `many` 0.667 -> 0.613 ms and
`resize` 48 -> 32 us; seed 13's `many` 6.35 -> 5.15 ms. Green: fmt, clippy,
109 suite and 20 core tests, the oracle at 100 seeds, the shrinker at 400
trees of depth 5, 1000 seeds at depth 6, and 2000 seeds at depth 4 over all
fifteen cases. The five reference renders are byte-identical to `0e107f0`
on Venus, as are `tabs` resized to 900x1200 and `random` to 1280x800
against cold renders there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:15:17 -04:00
iris-ai 0e107f0e89 Keep valid layout guarantees when a redraw widens their range 2026-09-17 17:24:53 -04:00
iris-ai f860f716e6 Separate measured-answer dependencies from retained drawing validity 2026-09-17 17:10:55 -04:00
iris-ai c44bd198ee Retain child frames relative to the container extent 2026-09-17 16:40:54 -04:00
iris-ai a7307d95fd Resolve a text draw's glyph origin once 2026-09-17 16:17:47 -04:00
iris-ai 7601aa2a5d Measure container children without intermediate placement 2026-09-17 15:53:24 -04:00
iris-ai c330ecec2b Skip inverse arithmetic for unrestricted layout validity 2026-09-17 15:53:24 -04:00
iris-ai 39f7b08c6c Honor fixed child alignment inside parent-selected slots 2026-09-17 15:53:24 -04:00
iris-ai 2ed5503717 Recompose retained frames exactly and preserve text width validity
Keep each widget's original local frame and replay the same composition
order on reuse. Remove inverse region remapping, including its fixed-frame
fallback that forced otherwise valid subtrees to draw again.

Require exact pixel-region equality in the shared generated oracle. Check
primitive and mask geometry as well as draw reuse when fixed frames resize.
Publish text's retained line-break range, with no upper bound when there
are no soft breaks, and cover widening, explicit newlines, and empty text.

Compared with efb416b, the depth-8 diagnostic rig performs 7-9% fewer widget
evaluations in the affected phases. Uninstrumented release runs use 3.5%
fewer instructions for size changes and 5.0% fewer for resize. Repaint and
scroll use 0.7% and 0.6% more instructions. Container updates remain substantially more expensive than the e44dea3 baseline;
this is still an experimental continuation, not a production replacement.
2026-09-17 15:11:03 -04:00
iris-ai efb416bbc3 Retain frame and extent dependencies independently
Keep the original measurement placement separate from the assigned slot.
Validate frame and extent lengths before reusing an answer or drawing, and
represent hint-only records as having no measured answer.

Retain primitive and mask coordinates with their frame/extent reference.
Forwarded children follow a reused wrapper's placement without rerunning
valid draw bodies. Keep the single Widget::draw API.

Restore the eight failing suite cases from the region/placement prototype,
with regressions for mixed coordinate references, a changed inherited
extent, the sizing-stack fraction, and an undrawn share becoming visible.

This remains experimental: nested container updates do substantially more
work than e44dea3 despite restoring the leaf and wrapper reuse guarantees.
Do not merge it as a performance improvement.
2026-09-17 14:50:09 -04:00
iris-ai 5fcace1bfa WIP: a widget's region stays put and its placement moves in it
The protocol split: `region` is the box a parent gives a widget -- what a
fraction it declares or reports is a fraction of, and the coordinates
every region it writes composes within -- and it is the same box on the
ask that measures and the ask that places. `placement` is what of that
region the drawing takes, chosen by the parent per axis or by the
widget's own answer and alignment.

That is what stops a fraction being resolved twice: the placing ask no
longer hands the widget its own answer as its box, so nothing under it
re-resolves against a box that came from its own report. `reports_of`
and `decided` are gone, folded into the two regions; `box_of` is gone;
`declared_box` becomes `ask_box`, which gives a rule the region's length
and takes the position from the placement.

84 of 92 suite tests pass. Five text and region-node cases still diverge
warm against cold, and three count a second widget draw where a span's
measuring ask and its placing ask give different placements.
2026-09-17 13:53:14 -04:00
iris-ai e44dea34b4 Record which widget is drawing a subtree that changed hands
A subtree can be reused whole under a different parent -- same box, same
layer, same region node, clean -- and nothing in the drawing says it
moved. Two things read who its parent is, and both were wrong after one
of these.

The old parent still listed it as a child, and a parent's next draw
undraws whatever is missing from that list: two spans under one root,
with the root swapping which of them it holds, drew the subtree under
the new span and then erased it when the old one drew. The move is
recorded on both sides where `draw_inner` already writes what the ask
decided, rather than guarded at each reader.

Its depth was also the one it had under the old parent, which is what
the settling walk orders by, so a change made under it afterwards
settled at the wrong point in the frame. `try_reuse` re-walks the
subtree's depths, and only where the top of it moved, which is what
makes that free in the ordinary case.

Two tests: one shape where the subtree's box does not move and the span
it left erases it, one where it changes depth and the change made under
it has to reach the span it moved to. Each fails without one half.
2026-09-17 13:05:15 -04:00
iris-ai a0693acc56 Let a resize settle through the walk, and drop the stale-answer guard
A resize drew the root outside `redraw_updates`, top-down over a tree
with dirty widgets still in it, which is the one entry point
`dirty_size_under` was guarding: since `a92c6ac` settles a frame strictly
bottom-up, no fuzzer could tell whether that guard still did anything
anywhere else. Closing the entry point retires the guard rather than
keeping a check for a hole reasoned rather than measured.

The root is marked instead, and only where the new output falls outside
what its answer holds for. That range is the intersection of everything
under it, so admitting the new output says the whole tree still stands,
and nothing above the root moved -- the window is no entry to rewrite.
Marking it unconditionally would have cost the root its own `Holds`: a
leaf root that scales with its box was drawn again on every resize.

`dirty_size_under` goes at both call sites. `resize` takes `Widgets`
because a mark is what it now leaves behind.
2026-09-17 13:00:32 -04:00
iris-ai 25e456e0b5 Say what the fuzzers can no longer tell about the stale-answer guard
Dropping `dirty_size_under` from it now passes every run there is. It stays
for the one entry the bottom-up ordering does not reach -- `update` draws
the root for a resize before `redraw_updates` runs -- which is a hole
reasoned rather than measured, and the note says which.
2026-09-17 05:12:44 -04:00
iris-ai 53b00c68e9 Find a span's leftover boundary through the inverse it already has
The decision used a rounded division, `total.px.div(fixed)`, where the room
the children get is a floored multiply, so the boundary and the drawing it
guards were two expressions for one length and disagreed at the edge of it.
`room` is that length as a `Len`, `room.to_px` is the multiply, and
`Holds::through` is its exact preimage -- so ask `room` whether anything is
left and hand the answer back through the same expression.

The three branches go with the division. They were the sign of `1 - rel`:
the fixed parts growing slower than the box, faster, or exactly with it, and
`through` reads that sign already. Forty lines become twelve, one `div`
leaves layout, and the boundary is the drawing's own.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6 and 120 in debug, and 2000 seeds at depth 4 over all
fifteen cases. `tabs`, `view`, `minimal` and `random` byte-identical.
2026-09-17 05:02:45 -04:00
iris-ai a92c6acdbf Settle a frame strictly bottom-up rather than escalating into a parent
The queue was already deepest-first, but a widget that could not settle
where it was called `redraw` on its parent from inside itself. That drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending, and a parent drawing over a subtree that has not settled reads
answers about to move: the one that settles does so inside the parent's
draw, where its mark comes off and nothing compares what it now answers.
Seed 564 was exactly that, and it is the second time this shape has been
found.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:46:23 -04:00
iris-aiandClaude Opus 5 490918b789 Ask whether a rule gives the length, not whether there is one
`Painter::ruled` answered "is there a rule beside me on this axis", which is
the same question as "is my report moot" only while `Exact` is the only rule
there is. `Min`, `Max` and `Clamp` are queued, and under one of those the
answer is still the widget's to give and a span across itself still has to
read its children -- so the name would have been true and the meaning wrong,
which is the worst way for a predicate to age.

It is `has_exact_size` now, over `SizeRule::exact` rather than `known`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:51 -04:00
iris-aiandClaude Opus 5 a8898aaa54 Give a length with no share in it its own type again
`UiScalar` was `Len` without the `leftover` weight, which is the separation
canonical `main` already had as `Len` beside `LayoutLen` and this branch
collapsed. It is needed back for the queued clamp: a cap may not contain a
share, because a cap has to read the report a rule otherwise makes moot, and
a share puts the container's division into the same equation -- two
self-consistent assignments, which is the multiple-fixed-point failure
generated seed 13 punished for orthogonal sizing. `min(report, cap)` is not
a `LayoutLen` either: it is a sum of parts, and the smaller of two of them
is not one.

So `UiScalar` is `Len`, what was `Len` is `LayoutLen`, and the two say in
their docs which is which: a `Len` is pixels plus a fraction of a box -- a
position being the length from the box's start, which is why a span is two
of them -- and a `LayoutLen` is a `Len` plus a claim only a container
dividing its room can answer. `From<Len> for LayoutLen` is the one-way step
between them.

Names only; the shader's `UiScalar` is renamed with them. Checked: fmt,
clippy, 105 tests, and `tabs`, `minimal`, `view`, `text` and `random`
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:15 -04:00
iris-aiandClaude Opus 5 4f5e27cba9 Do not divide by one to remap a box that spans its parent
A retained part is re-expressed as a fraction of its new box by dividing by
the old box's extent, and that extent is one whenever the box spans the whole
of its parent's -- which is the common shape. An integer division is the most
expensive thing in `apply_scalar` and it ran twice per span.

`perf stat -e instructions:u` over 500 frames: `many` 1,938,264,572 to
1,886,265,821, `scroll` 452,517,906 to 444,792,314.

Tried first and reverted: short-circuiting a fraction of nought or one, at
either end of the box. That is not the common case, and the two comparisons
cost 17% more than the divisions they were meant to save.

Checked: fmt, clippy, 105 tests, three shrinker cases at 300 seeds, 100
generated seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:13:51 -04:00
iris-aiandClaude Opus 5 11c55bcef9 Put a glyph's offset on the grid where it is placed
A placed glyph's offset is whole pixels by construction -- a floored pen
position plus the entry's integer bearing -- and `Painter::glyphs` was
converting it, and the entry's width and height, from `f32` on every frame
that drew the glyph. It is a `PxVec2` now, converted once when the text is
placed, and the size is two integer shifts.

Measured with `perf stat -e instructions:u`, since the difference is smaller
than this machine's clock: the `many` phase went from 2,013,099,594
instructions to 1,938,264,572 over 500 frames, 3.7% less. `scroll` and
`repaint` are unchanged to within noise, which is right -- they do not redraw
glyphs.

Checked: fmt, clippy, 105 tests, the reorder fuzzer at 300 seeds, and `tabs`,
`text` and `random` byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:11:07 -04:00
iris-aiandClaude Opus 5 f11f5f4825 Divide twice in a range's inverse, not four times
Which end of the answer each bound comes from is known from the sign of the
fraction before dividing; taking the min and max of four divisions asked the
question twice. A division is the most expensive thing in that function and
it runs per child per axis.

`many` 0.283 ms a frame to 0.278. Small, and strictly less work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:06:46 -04:00
iris-aiandClaude Opus 5 97cc8b32ed Measure a child on the layer it draws on, not twice on two
Fixed point cost 3x in layout: `many` went from 0.179 ms a frame to 0.544,
and `scroll` from 0.011 to 0.030. The counters said why -- eight more "placed
by redrawing" a frame -- and the reason was mine rather than the grid's. A
retained drawing belongs to the layer it was made on, which `4e28f10` started
enforcing, and `Stack` measures the child that sizes it by drawing it on its
own layer and then draws it again on the child layer. So every stacked child
redrew twice a frame, forever.

`Painter::child_layer_at` addresses a child's layer rather than walking to
it, and `Stack` measures on the layer that child ends up on. The second ask
is then a reuse. Its glyphs are written once rather than once under the
background and once over it.

Measured on the same fixture: `scroll` 0.031 ms to 0.020, `many` 0.570 to
0.283, and the scroll phase's counters are back to what they were before
fixed point -- 4 widget draws and 12 draw requests a frame, exactly. What is
left above that baseline is not this.

`ReuseOutcome` could not say "another layer" or "the region-node choice
changed"; both returned without a counter, which is why the first look at
this said nothing. They have counters now.

Checked: fmt, clippy, 105 tests, five shrinker cases at 300 seeds, 100
generated seeds, and the examples byte-identical but for 36 pixels of
`random` at one level -- edges that were being drawn twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:04:31 -04:00
iris-aiandClaude Opus 5 95fb4f962c Hold a clipping widget to its box, and check that it is
`Scroll` reports `LEFTOVER` on both axes because it clips its content to its
box: it can neither take less of one nor honestly ask for more. `Masked` is
the other widget that clips and was passing its inner's size up, so a mask
over something taller than its box asked to be placed at the length it had
just cut off. It reports its box now, for the same reason.

The `debug_assert` the handoff has been asking for is the one that would have
caught both, narrowed to what is actually true: a widget that set a mask this
draw has to report inside the box it drew in. Reported as "does not exceed
the box" it fires on ordinary overflow instead -- measured, a hundred fuzzer
trees produce thousands of them, every one a text too tall for the box it was
offered, which is what a text is meant to say.

`tests/cases/scroll.rs` has a clipping widget that reports its content, to
show the assertion catches it.

Checked: fmt, clippy, 104 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:48:23 -04:00
iris-aiandClaude Opus 5 bdab55824f Take two roundings out of where a box comes from
Traced what was left of the warm-against-cold difference after fixed point.
It is not accumulation and not one place: it is the same box reached two
ways, and each way rounds where the other does not.

`Scroll` was writing a box it had been given back out as its own length in
pixels. That is the same box in another form, and centring a part in `rel 1`
lands a step from centring it in `px 900`, because halving a difference is
not halving each part of it. Content that fills the viewport and has not been
scrolled is now handed back as it came, which makes the shrinker's `repaint`
and `resize-repaint` cases agree exactly rather than within a step.

`Span` placed each child a step from where the last one ended, so the
rounding of every share was carried along the row. A position is now the
fixed parts before it -- a sum, exact -- plus one rounded share of the room.
Measured: two hundred equal shares of a 1000 px row ended at 999.999 and now
end at 1000, and `tests/cases/layout.rs` pins it at 2, 3, 7, 64 and 200.

What is left is a step per level of nesting between the two ways, which is
what the fuzzers now allow: four of the five shrinker cases pass at one step
and the fifth is five spans deep. Closing it needs one way of asking where a
box is, which is a bigger change than this.

Checked: fmt, clippy, 103 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:42:09 -04:00
iris-aiandClaude Opus 5 9d8415d65f Delete OrthoSize, and run the seeds in parallel
A span is as long across itself as its longest child, unless a rule beside it
already says how long it is -- and then reading the children answers nothing
and only makes its size depend on theirs. `OrthoSize::Full` was that second
case written twice, once as an enum on the span and once as the rule that
actually decides; `Painter::ruled` lets the span ask which it is in. The
widget under a rule still does not learn what the rule says, only that its
answer for that axis is not wanted.

The fuzzers grow, lay out and drop a tree within one seed, so the seeds share
nothing and take a thread each, one short of every core. Measured here: the
generated oracle's hundred seeds went from 68 s to 10 s, and a shrinker case
at 300 seeds from 18 s to 3.5 s. A seed that fails still shrinks and panics
on its own thread, and `std::thread::scope` carries that out.

The shrinker now allows the two steps the oracle already did -- the deeper
trees these grow reach a second composition, and a step is a thousandth of a
pixel.

Checked: fmt, clippy, 102 tests, all five shrinker cases at 300 seeds, 100
generated seeds, and five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:18:49 -04:00
iris-aiandClaude Opus 5 cb955f1023 Link the ordinary tests once, and keep their debug info to line tables
Eleven `tests/*.rs` were eleven binaries, each linking the whole graph --
`wgpu` and all -- to run a handful of cases. They are modules of one target
now, under `tests/cases/`, and `cargo test --test suite layout::` still picks
one out. The fuzzers and the `*_cost` measurements stay their own targets:
they are run on their own and want to be selectable without building the
rest.

`profile.test` takes `debug = "line-tables-only"`, which is what a backtrace
here actually reads; the type and variable information was the bulk of what
the linker was writing.

Measured on this machine, rebuilding `iris`'s test targets after a change to
the crate: 14.3 s before, 9.8 s with one target, 7.7 s with both. `target/`
went from 45 GB to 13 GB. The suite still passes 102 tests, and the binary
still carries `.debug_line`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:00:43 -04:00
iris-aiandClaude Opus 5 39e4ca20e6 Decide layout on the grid end to end, and delete the tolerance
`Px` and `PxVec2` reach the last places a pixel was a float: the window, the
box a widget reads, the box it is compared against, and `PixelRegion`. A
pointer, a wheel notch and a shaped glyph advance still arrive as floats,
and each is put on the grid where it arrives.

`Holds` is an interval of `Px`. `HOLDS_EPSILON_PX` is gone with the
`exact`/tolerant split it existed for: `at` is the length a widget read, an
open end is the next step along, and `same_px` is equality. `Span`'s margin
from `5ed9e87` goes too -- the box a parent hands back and the sum of what
its children asked for are counts of the same step, so the boundary decides
the same way from either side.

Three things had to be true for that, and were not:

`Holds::through` inverts `px + rel * box`, which rounds -- so a part of a
given length came from a range of boxes, and inverting the length alone gave
a point that need not contain the box the part was drawn in. It now maps the
half step either side, and one more for a length composed down the chain
against the same length measured against the window.

`RegionRemap` translates when a box only moved, rather than dividing to find
each part's fraction and multiplying to place it again. Two roundings landed
a step from where growing the tree that way does; a move is exact on a grid,
which is the whole reason `tests/drift.rs` was written.

A pixel is `1/1024` rather than `1/64`. At `1/64` the residue of a length
reached two ways was one step, and one step was 0.016 px -- enough to move
a box. `PX_SHIFT` and `REL_SHIFT` are the only statement of the grid now,
and the shader's copy is prepended from them rather than written twice.

Checked: fmt, clippy, 102 tests, 100 generated seeds in 75 s, all five
shrinker cases at 300 seeds, and `tabs`, `view`, `minimal`, `text` and
`random` byte-identical at 1920x1200.

What the fuzzers ask for is now a step, not a twentieth of a pixel: the
shrinker's five cases agree within one (`resize` exactly), and the oracle's
two-operation cases within two. The residue is a single rounding either way
-- it scales with the grid rather than accumulating, which is why it is a
thousandth of a pixel now. Closing it means one way of asking how long a box
is, rather than a chain composed down and a length measured against the
window; that is a bigger change than this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:56:48 -04:00
iris-aiandClaude Opus 5 bd6de71a55 Put lengths, padding, gaps and alignment on the grid too
`Len` is `Px` beside `Rel` beside `Weight`, so the seam `4e28f10` left in
`Span` -- a float length added to a fixed-point cursor -- is gone, and the
sum a span compares against its box is exact.

`Weight` is its own scale, `Fixed<16>`, because a share of what is left over
is not a fraction of anything: a list divides its room by the total of them,
so the range has to hold a whole list's worth while the precision only has to
tell two weights apart. `Rel::ratio` turns two weights into a share on the
finer grid, which is what a span needs and what dividing them on their own
grid would round away.

`AxisAlign` holds a `Rel` rather than a float, which is what the layout was
reading out of it anyway. `Padding` and `Span::gap` hold `Px`, converted
where they are built instead of on every frame. `RegionAlign::rel` is gone;
its one caller wanted a position, and now builds one.

`Fixed` gains `from_num` for a number as it is written in source, `mul_int`
for a length repeated a whole number of times, and `ratio`.

Checked: fmt, clippy, 101 tests, 100 generated seeds in 86 s, all five
shrinker cases at 300 seeds, and all five examples byte-identical at
1920x1200 against `4e28f10`.

With the fuzzer comparing for equality rather than within 0.05 px, four of
the five cases now pass 100 seeds -- `resize-repaint` joins the other three.
`reorder` still fails one seed by one step, so the last of it is in what a
box is measured *in*: `px_len` and the window are still floats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:40:42 -04:00
iris-aiandClaude Opus 5 4e28f1047e Put positions on the grid, and decode them in the shader
`UiScalar` is `Rel` beside `Px` rather than two floats, so composing a
position down a chain of boxes adds exactly and rounds only at the two
multiplies `within` makes. `UiSpan`, `UiRegion` and `UiVec2` follow it, the
hand-written `Hash` goes away with the bits it hashed, and `impl_op!` grows a
`same` form for a type whose fields are not the same kind of number.

`Len` is still floats, so the seam converts: `Px::from_f32` where a span adds
a child's length to its cursor, and `to_f32` where something outside layout
wants pixels. Those go when `Len` follows.

The GPU reads what the CPU wrote: the instance attributes are `Sint32x2` and
the shader decodes by `1/64` and `1/2^24`, both exact in `f32`, then composes
the move chain in floats as before. It has to agree with itself frame to
frame rather than with the CPU to the last bit.

Two things fell out of making the numbers exact.

`floor` at the rasteriser was picking the pixel below wherever a fraction
divided a window exactly. A fifth of 1920 is 383.99998 through a rounded
`Rel` -- and was 384.0 through an `f32` that happened to round up -- so five
tabs each lost their last column. `snap_floor` takes a coordinate within half
a step of a boundary to be on it, which is the same rule as everywhere else
here: decide where values do not land.

A widget measured on one layer and drawn again on another kept the first
layer, because `try_reuse` compared everything about a retained drawing
except which list it sits in. `Stack` does exactly that for its background,
so every panel's text went under its own background. It only worked before
because the two asks differed by a rounding and forced a redraw;
`tests/retained.rs` pins it now, and `ReuseOutcome` can say `WrongLayer`.

Checked: fmt, clippy, 100 tests, 100 generated seeds in 70 s, all five
shrinker cases at 300 seeds. `tabs`, `view` and `minimal` render
byte-identical at 1920x1200; `random` differs in 36 pixels by one level;
`text` differs where glyph origins moved onto the grid -- same positions,
same spacing, different subpixel coverage, checked at 6x against the old
render.

Measured on the way: with the fuzzer comparing for *equality* rather than
within 0.05 px, `resize`, `repaint` and `size-change` already pass 100 seeds.
`reorder` fails one seed by exactly one step, which is the `Len` seam above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:22:12 -04:00
iris-aiandClaude Opus 5 7548139861 Add a fixed-point number for layout to decide on
Layout reaches one place by more than one route -- a box composed down the
chain, and the same box summed from what its children asked for -- and the
two land a few bits apart in floats. Where that decides something structural
rather than something positional, a warm tree disagrees with a cold one:
`5ed9e87` is the instance, and its margin is a patch over the representation
rather than a fix to it.

`Fixed<SHIFT>` is a count of `1 / 2^SHIFT`s in an `i32`. Adding and
subtracting are exact, a multiply rounds once back onto the same steps, and
two routes that come within half a step land on the same number -- so the
comparisons downstream can ask for equality rather than for nearness.
`Px = Fixed<6>` and `Rel = Fixed<24>`: a sixty-fourth of a pixel is finer
than a display and still exact in `f32` up to 262,144 px, and twenty-four
bits of fraction matches `f32` at a half, beats it above one where anchors
sit, and leaves +/-128 of range to sum relative children in.

Nothing uses it yet. The arithmetic saturates rather than wrapping, because
a clamped coordinate keeps the ordering a wrapped one inverts, and the ends
are what an unbounded interval will be written with.

Checked: fmt, clippy, 99 tests including ten for this type -- the round trip
through `f32`, halves rounding away from zero either side, saturation at both
ends, and 20,000 additions landing exactly where the count says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:34:01 -04:00
iris-aiandClaude Opus 5 5ed9e874a3 Keep a span's leftover decision off the box its parent hands back
The shrinker's `reorder` case had two red seeds at depth 5, and neither was
about reordering. A span asks whether anything is left over by comparing its
box in pixels with what its fixed and relative children fill. Where the
parent sized that box from this span's own answer those are the same number,
and the box returns through the chain a few bits off, so 0.00003 px decided
it: warm rounded under and left a leftover-only child undrawn, cold rounded
over and drew it at zero length. Both are stable, and the pixels are the
same either way, which is why nothing but the oracle could see it.

The room to divide is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX`
of it is now none. That moves the boundary off the length boxes land on
rather than making the comparison tolerant: the validity range is still
split at the boundary exactly, as generated seed 16 requires, and what it
gives up is a share of under a twentieth of a pixel. The same margin answers
the `fixed == 0` arm, where the only room is what negative pixels leave.

`tests/unsettled.rs` gets the six-widget tree, shrunk from 266. It needs the
span above the one that divides: without a box composed through it both
trees round the same way and the boundary is never crossed.

Checked: fmt, clippy, 89 tests, 100 generated seeds agreeing in 68.5 s, and
all five shrinker cases at 1000 seeds of depth 6 (159,024 widgets each).
`tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
1920x1200 against the same worktree without the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:48:19 -04:00
iris-ai d3b0ebf90c Make alignment a widget property 2026-09-15 23:12:16 -04:00
iris-ai 8220a78d4a Carry a length as a rule beside a widget, not a widget around it
`.width()` built a `SetSize` whose whole job was to answer `size_hint`, so
every declared length cost a widget, an `ActiveData` and a link of chain to
say one number. It is now a `SizeRule` per axis on `WidgetData`, beside
`region_node`, resolved by `Painter` where the widget is drawn. `SetSize`
and `MaxSize` are gone; `MaxSize` had no caller but its own builders.

That settles which of two answers is the size. A rule wins on the axis it
names and the `Size` returned by `draw` answers the rest, applied once in
`draw_inner` rather than by each widget that could carry one -- so the
widget under a rule never learns of it. `Painter::size_hint` reads the rule
first for the same reason: a rule that beats what a widget would draw has
to beat what it says about itself.

`declared_lens` still falls back to a non-leftover `size_hint`, which is
how an image or a gap gets its own pixel size rather than the whole offer.
That is the offer's business rather than a declaration's, and it falls away
when a widget occupies its reported size inside the box it was offered.

`known` and `declared` are separate because a share is a length to whoever
divides one and not to whoever composes a box: `.width(leftover(3))` is
known without drawing but cannot narrow anything.

Checked: fmt, clippy, 85 tests, and 100 generated seeds agreeing warm
against cold in 67.6 s. `minimal`, `text` and `view` render byte-identical
at 1920x1200; `tabs` differs only in the widget count it prints about
itself, which is two wrapper types smaller.
2026-09-15 19:44:21 -04:00
iris-ai 0283c9d6c7 Pin that a moved subtree does not drift from a cold layout
A move rewrites a retained subtree's stored regions, and those stores are
the only record of where it is. So a move that works from the last answer
integrates its own rounding with nothing to correct it, while one that
re-expresses each part as the same fraction of the new box is anchored to
that box and cannot.

Nothing was checking which of those `try_reuse` does. Replacing the fraction
with an offset added to both endpoints -- which is cheaper, and looks like it
should be exact for a translation -- shortens this fixture's row by 0.071
over 20,000 moves and by 0.712 over 200,000, growing with the count rather
than settling. That is five minutes of scrolling at 60Hz to pass the 0.05
physical pixels layout treats as the same place, and it keeps going. Placing
the far end from the near one instead of offsetting both leaves 0.069, since
the length is re-derived from the endpoints either way.

The existing warm-against-cold checks did not reach it: the generated oracle
compares within 0.05, and `unsettled.rs` compares exactly but only over a
handful of frames, where the drift is still 6e-5.
2026-09-15 19:26:25 -04:00
iris-ai 71c9c39523 Replace placement calls with region nodes 2026-09-15 18:02:28 -04:00
iris-ai f437495309 Add explicit orthogonal span sizing 2026-09-15 16:40:09 -04:00
iris-ai 29c7881c8a Track retained layout validity explicitly 2026-09-15 16:03:53 -04:00
iris-aiandClaude Opus 5 691e3eb23c Rename rest to leftover
The length kind that asks for a part of what is left once the fixed
lengths are taken is called leftover: Len::leftover(2), Len::LEFTOVER,
Size::LEFTOVER, Len::leftover the field, and apply_leftover. It says
what it is where "rest" reads as "the remainder of the list" as often as
"the remaining space", and every agent who has touched this has reached
for a third word for it.

Locals called rest that meant a region or a widget are renamed with it,
since the word now names something else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:22:52 -04:00
iris-aiandClaude Opus 5 9644971daf Inline the write a glyph goes through
Whether the inliner took DrawLayers::write into Painter::glyphs turned
out to depend on unrelated code elsewhere in iris-core: adding the
declared-length resolution pushed it out, and a call per glyph cost 12%
of a resize frame with every counter -- widget draws, primitive writes,
text renders -- unchanged. Saying so directly leaves the two decisions
independent. The random-tree rig is 12.59B instructions where it was
13.25B before either change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:37:34 -04:00
iris-aiandClaude Opus 5 de9ddc0ad4 Resolve a declared length where the widget is drawn, not inside it
SetSize took its declared length out of UiRegion::FULL, which is the box
it was already given, so a span that had sized that box from the same
hint had the fraction taken twice: .width(rel(0.5)) in a 400-wide span
drew its child 100 wide. It held under a Pad or the root, which do not
honour a hint, and hid under px, where 200 of a 200-wide box is all of
it. The text example was 111,923 pixels from upstream/main because of
it.

Whoever draws a widget now takes its declared length, in its own box,
which is what a fraction of one means, and is the identity for a caller
that already reserved the space. rest is not taken: a share of what is
left over is only a length to the widget dividing one, so it passes up
in the size as it does out of a span. SetSize keeps only what it
declares.

A declared length is then part of the box its parent decided, so
changing one has to redraw the parent; the lengths resolved into a box
are kept beside it and compared. Assuming instead that any dirty widget
which declares a length needs its parent costs 17% of a frame that
dirties 130 of 260 widgets, and buys nothing.

All five reference renders, the resize render and the image replay are
byte-identical to upstream/main, the 100-seed sweep passes, and the
resize fixture is 1.286 ms against 1.289 before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:37:34 -04:00
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: #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
iris-ai f9423855e1 Size a widget while drawing it, not in a pass of its own (#16)
Reviewed-on: #16
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: iris-ai <4+iris-ai@noreply.localhost>
2026-09-14 02:48:02 -04:00
iris-ai 43ce8c7d02 Route pointer input per kind, so a scroll falls through a hovered button (#12)
Reviewed-on: #12
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 22:05:02 -04:00
iris-aiandiris c8ac669f95 Run a ui without a window, and test one (#15)
Small, and disjoint from #12 — this touches `task.rs`, `harness.rs` and `render_state.rs`, none of which #12 goes near.

`Tasks` held an `Arc<Window>` only to call `request_redraw` when a task finished, which made the task queue, and so `DefaultRsc`, impossible to build without a window. It now takes an `Arc<dyn WakeTaskQueue>`, and `Window` implements it.

Waking also moves from *the task ended* to *an update was sent*, which is when there is actually something for the host to apply. A task that keeps running after sending one no longer holds it until it finishes, and a task that sends none no longer asks for a frame nothing needs.

`iris::harness` is what that buys. `UiRenderState` already does layout, hit testing and primitive building with no surface, so a test can build a tree, run frames, move a pointer and read back where widgets landed. `tests/harness.rs` covers span layout, resize relayout, press routing, hover start and end, wheel scrolling with its clamp, and a task update reaching the tree. None of them could be written before, since the only way into layout was a window.

It does not draw. A claim about pixels still needs a real surface — I checked this one against the rig rather than asserting it: `examples/task` under headless sway, centre pixel `ff0000` before the click and `0000ff` after, so the windowed path still applies task updates under the new wake.

The only core change is `UiRenderState::output_size()`, so that a host reading back the size it set does not have to keep a second copy.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: #15
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 21:53:54 -04:00
iris-aiandiris 32b10383d8 Rename the Sized widget to SetSize (#14)
`Sized` shadowed the marker trait, so a `?Sized` bound in any crate that imports the prelude failed to resolve -- a compile error in someone else's code that nothing here would have caught. It was already biting inside iris: `default/mod.rs`, `widget/ptr.rs` and `widget/text/build.rs` all imported `std::marker::Sized` explicitly to get out from under it, which they no longer need.

`SetSize` rather than `FixedSize` because the size it sets need not be fixed -- `width(rest(2))` (a flex weight) and `width(rel(0.5))` (half the parent) build the same widget, and both are more common than `sized((100, 100))`. It also pairs with the `MaxSize` beside it in that module: one sets a length, the other caps it. The builders are unchanged.

`tests/prelude_bounds.rs` is a compile-level guard -- it fails to build if the prelude shadows `Sized` again, which I checked by reverting `src/` under it:

```
error[E0404]: expected trait, found struct `Sized`
 --> tests/prelude_bounds.rs:8:22
  |
8 | fn takes_unsized<T: ?Sized>(_: &T) {}
  |                      ^^^^^ not a trait
```

The pad tab of the tabs example -- the one built out of `sized` and the flexible widths -- renders pixel-identical to before the rename.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: #14
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 20:16:17 -04:00
iris-aiandiris 00d2230b84 Build on wgpu 30 (#13)
Two majors, and the renderer is under everything else left to extract -- so it goes before the slices that would otherwise be written against wgpu 28 and then again against 30. `image` 0.25.6 -> 0.25.10 rides along. `winit` stays on 0.30.12, since 0.31 is only a prerelease and nothing here needs it; `parley` 0.11.1 is current.

What the API asked for, beyond the version:

- **An instance takes the display it will present on**, and GLES on Wayland needs it, so the window the surface is made from is handed over with it. That one matters for Android rather than for this machine.
- **`get_current_texture` returns a status rather than a `Result`**, which replaced an `unwrap` that would have panicked on a resize or an occluded window: reconfigure when the surface is outdated, lost or suboptimal, and skip the frame when there is nothing to draw into.
- **Presenting moved to the queue**, still after `pre_present_notify`.
- **Bind group and vertex buffer layouts are sparse**, so each slot states `Some(layout)`.

Verified the same way as #11: the tabs example with two runtime-added images, an image alone in a layer, and glyphs from a four-page atlas all render identically. `tests/draw_cost.rs` gives 33.6/167/587/2855 us per frame at 8/64/256/1024 layers, against 33.3/161/588/2903 on wgpu 28 -- no change.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: #13
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 19:07:47 -04:00
iris-aiandiris b234497d21 Draw the glyph atlas as an array texture and images with their own bind groups + primitive rendering overhaul
Replaces the bindless `binding_array<texture_2d<f32>>` the renderer bound every texture through. That array needs `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack, so the old shape did not run there at all.

The two things being bound want opposite treatment, so they are now split:

- **Glyph atlas pages become layers of one `texture_2d_array`.** A glyph primitive carries a `layer` instead of a view/sampler index pair. A layer index is an ordinary sampling operand, so this needs nothing beyond plain Vulkan 1.0 / GLES. Growing the atlas recreates the array with headroom and `copy_texture_to_texture`s the old layers across, no readback.
- **A standalone image gets its own texture and its own bind group,** and draws in its own call. It no longer needs a per-instance entry in `PrimitiveData`: the bind group has already picked the texture.

`Primitives` keeps images in a list of their own as a result, with `PrimitiveChange::is_image` naming which list a renumbering belongs to -- the two have independent index spaces, so `(layer, inst_idx)` alone would collide between them.

Two notes on judgement calls, since this slice was rebuilt on top of `main` rather than transplanted:

- The source version renamed `GlyphEntry::is_colored` to `is_color` and added a second `IS_COLOR` flag constant beside the existing `GlyphEntry::IS_COLORED`. Both dropped: #10's naming and its `flags()` are kept, and UVs stay `Vec2` rather than going back to `[f32; 2]`.
- `ImageGpu` no longer holds the `Texture` behind its view, which removes an `#[allow(dead_code)]`. A `TextureView` keeps its own reference to the texture, checked by rendering rather than assumed -- see below.

### Verification

```
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
```

All clean; the 4 text-edit tests pass. The only clippy output is the pre-existing future-incompatibility notice about `naga`/`wgpu`/`winit`.

Because this is a rendering change, it was also run for real rather than only compiled. The `tabs` example was rendered on this machine's GPU -- Venus onto an RX 7900 XT, confirmed from the loaded ICD (`libvulkan_virtio.so` on `/dev/dri/renderD128`) rather than assumed, since a failed Vulkan init here silently falls back to llvmpipe and would make the screenshots meaningless.

Screenshots before and after the change are **byte-identical** (same md5) in two scenes: the default tab, which exercises text (the atlas path) and rects, and the image tab with a standalone image pushed at startup, which exercises the per-image bind group. The image-tab scene needed a temporary local edit to the example to push the image without a click; that edit is not part of this branch. The same comparison, re-run after dropping the `Texture` field, is still byte-identical -- which is the check that the view alone keeps it alive.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: #11
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 18:56:59 -04:00
iris-aiandiris 0f6a28b4dd Move text layout and rendering to Parley (#10)
Replace the cosmic-text path with Parley layout and Swash rasterization, backed by shared glyph-atlas pages. Shaping, editing, rasterization, and glyph rendering move together because they share the text buffer and rendered-glyph types; splitting them further would require a temporary renderer that is immediately removed.

This is reconstructed rather than replayed from the extraction history. It also fixes issues found during review:

- texture binding changes remain set when an atlas patch follows a new page
- pressing an empty field places a caret and accepts input
- selection motion delegates collapse behavior to Parley
- character deletion follows logical clusters rather than visual neighbors
- the unused root-level Swash dependency is omitted

Four public-behavior integration tests live in `tests/text_edit.rs`: empty-field input, multibyte IME preedit replacement, UTF-8-safe backspace, and selection replacement. The old twelve-test inline block and implementation-restating cases are omitted.

Every added source comment was manually reviewed. Comments that narrated implementation or history were removed; retained comments document cache/rasterization keys, GPU upload constraints, focus representation, bidi geometry, or IME semantics.

Known limitation: atlas pages currently grow without eviction. Each page is 4 MiB on CPU and GPU. An arbitrary cap would leave cached rendered-text UVs pointing at reused glyph slots, so bounding this safely needs a later generation/invalidation change.

This changes public text types and signatures. GPU glyph rendering is covered by compilation rather than a live-surface test.

Verified with:

- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test --workspace` (four integration tests pass)

Cargo still reports inherited future-incompatibility notices for existing wgpu/winit dependencies; there are no current clippy warnings.

---------

Co-authored-by: iris <2+iris@noreply.localhost>
Reviewed-on: #10
Reviewed-by: iris <2+iris@noreply.localhost>
Co-authored-by: AIris <4+iris-ai@noreply.localhost>
2026-09-13 03:39:23 -04:00
iris b90c855cf5 Merge pull request 'Preserve primitive count recursion' (#9) from iris-ai/iris:split/08-primitive-count into main
Reviewed-on: #9
2026-09-13 01:11:15 -04:00
iris 3b96324333 Remove the redundant macro comment 2026-09-13 01:10:37 -04:00
iris 4767384b08 Preserve primitive count recursion 2026-09-13 01:07:52 -04:00
iris 0191f2081b Merge pull request 'Keep unsafe reference helpers internal' (#7) from iris-ai/iris:split/06-restrict-unsafe-utils into main
Reviewed-on: #7
2026-09-13 01:05:38 -04:00
iris 472736a292 Keep the unsafe helper change minimal 2026-09-13 01:04:10 -04:00
iris 6e271e8aee Merge pull request 'Initialize the window uniform from the surface' (#8) from iris-ai/iris:split/07-initialize-window-uniform into main
Reviewed-on: #8
2026-09-13 01:01:04 -04:00
iris a1ff76776c Keep unsafe reference helpers internal 2026-09-13 00:58:56 -04:00
iris cb9cad38f2 Initialize the window uniform from the surface 2026-09-13 00:58:56 -04:00
iris db9b0f21d5 Merge pull request 'Notify winit before presenting frames' (#6) from iris-ai/iris:split/05-pre-present-notify into main
Reviewed-on: #6
2026-09-13 00:54:58 -04:00
iris 2b6a6ab378 Notify winit before presenting frames 2026-09-13 00:53:16 -04:00
iris ec2b5d4c1d Merge pull request 'Use vsync by default' (#5) from iris-ai/iris:split/04-vsync-default into main
Reviewed-on: #5
2026-09-13 00:52:03 -04:00
iris 780ac82b27 Use a vsynced presentation mode by default 2026-09-13 00:51:20 -04:00
iris 465e43075e Merge pull request 'Decouple iris-core from winit' (#4) from iris-ai/iris:split/03-core-window-independence into main
Reviewed-on: #4
2026-09-13 00:50:33 -04:00
iris 0c9a39fd06 Remove redundant resize documentation 2026-09-13 00:49:09 -04:00
iris 936fbdd8ce Merge pull request 'Request a frame after resize' (#3) from iris-ai/iris:split/02-resize-redraw into main
Reviewed-on: #3
Reviewed-by: iris <2+iris@noreply.localhost>
2026-09-13 00:46:56 -04:00
iris 3eaded125e Merge branch 'split/02-resize-redraw' into split/03-core-window-independence 2026-09-13 00:45:37 -04:00
iris 23270e49fb Drop the redundant redraw predicate test 2026-09-13 00:45:26 -04:00
iris bc6cdd13c9 Decouple iris-core from winit 2026-09-13 00:38:29 -04:00
iris 072f1e31ad Keep the redraw invariant concise 2026-09-13 00:36:23 -04:00
iris 42753141b7 Merge pull request 'Build Iris on the current nightly' (#2) from iris-ai/iris:split/01-toolchain into main
Reviewed-on: #2
Reviewed-by: iris <2+iris@noreply.localhost>
2026-09-13 00:33:51 -04:00
irisandClaude Opus 5 6884160bfe Make iris ask for the frame a resize needs
`update` redrew everything when `resized` was set, but `needs_redraw` --
which is what decides whether to request a frame at all -- did not know
about `resized`. A condition in one and not the other is a frame nobody
asks for and a stale window. The two share one `needs_redraw_all` now.

Latent on Wayland, because winit requests a redraw after a resize by
itself; a resize changes neither the root nor any widget, so nothing else
here would have asked. It stops being latent on Android, where the
surface work will not have winit underneath it and every rotation and
keyboard open is a resize.

This is not a fix for the startup defect recorded in RUST.md, where the
window keeps its pre-configure layout: that reproduces with this change
in place, and the frame it needs is requested and drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:26:19 -04:00
iris fae21a1991 Build on current nightly 2026-09-13 00:24:29 -04:00
iris 7b54aaf3c4 readme 2026-01-29 16:39:19 -05:00
iris 17c436d944 stuff 2026-01-22 23:33:46 -05:00
iris a592318a6f impl idlike for widgetview 2026-01-20 18:11:21 -05:00
iris 796bc41752 didn't actually remove state on widget remove (mem leak) 2026-01-19 21:39:13 -05:00
iris 7bafb04a34 widget state 2026-01-19 20:39:58 -05:00
iris 06dd015092 finished moving out render_state 2026-01-19 18:00:24 -05:00
iris 79813db3ba work 2026-01-12 18:40:27 -05:00
iris a9c76e4326 lol bounds 2026-01-05 17:50:18 -05:00
iris 32e45e9238 griefed last commit 2026-01-05 17:20:11 -05:00
iris 2fadfe4b82 app event stuff bruh 2026-01-05 17:16:56 -05:00
iris d11107f965 widget view 2026-01-05 17:01:09 -05:00
iris 07de7c8722 update deps 2026-01-04 14:45:18 -05:00
iris f2ac6f195f remove typed stuff / just specify rsc if needed 2026-01-03 18:06:05 -05:00
iris 59901b6580 tasks initial impl (still working on task_on trait method) 2026-01-03 16:26:23 -05:00
iris 5da1e9e767 separate state from rsc 2026-01-01 22:18:08 -05:00
iris 462c0e6416 move event data out of widgetdata 2025-12-23 15:42:04 -05:00
iris 54534c4c34 bruh vsync griefs my laptop 2025-12-20 18:25:22 -05:00
iris f0fe671bd8 fix illegal instruction during handle ptr creation in release mode 2025-12-20 17:58:10 -05:00
iris 1ccf947220 convenience methods for span 2025-12-20 01:38:56 -05:00
iris c00ded78c0 switch away from handles to refs that must be upgraded once 2025-12-20 01:36:07 -05:00
iris 32ca4ec5a6 warning 2025-12-20 00:27:08 -05:00
iris fabc7d0b90 abuse macros.. 2025-12-20 00:26:08 -05:00
iris bae17235c6 better global state structure? 2025-12-19 21:54:48 -05:00
iris 30bc55c78e remove state generic from a lot of things 2025-12-17 21:37:55 -05:00
iris 7e6369029f trust + fix redraw bug 2025-12-17 01:16:28 -05:00
iris 70ac0fbcb2 typed stuff 2025-12-17 00:55:36 -05:00
iris 1363f31fcd FIX SIZE CACHE 2025-12-17 00:09:20 -05:00
iris ecbb9e56e2 small QOL 2025-12-16 20:24:21 -05:00
iris ac9571b29f small changes 2025-12-16 18:38:06 -05:00
iris 71f3beaf94 refactor painter 2025-12-16 00:48:14 -05:00
iris 2183fbd3cb make painter not stupid (size ctx is kinda tho) 2025-12-16 00:26:25 -05:00
iris 486ed0ffd7 prelude macro visibility 2025-12-15 23:15:52 -05:00
iris 8d1a810483 macro goodness 2025-12-15 23:11:32 -05:00
iris 0b8a93c5ce RE ADD CONTEXT 2025-12-15 21:50:53 -05:00
iris dc2be7f688 refactor out typemap 2025-12-15 16:25:12 -05:00
iris 9d8ca8fa72 refactor events 2025-12-12 02:02:54 -05:00
iris a2a32b4322 ctx inference holy 2025-12-12 01:50:08 -05:00
iris 37b1987aa8 remove modules and have single event manager (atomics feature parity + preparation for local state) 2025-12-12 01:46:24 -05:00
iris a708813ce7 idk work (r + h) 2025-12-11 23:05:27 -05:00
iris a70d09e162 lol 2025-12-11 16:24:07 -05:00
iris 966b6a2ac2 proper widgetid + slot vec instead of map 2025-12-11 16:23:14 -05:00
iris 2dad409300 handles (tuple) 2025-12-11 07:30:59 -05:00
iris 36668c82f4 strong & weak widgets 2025-12-11 07:16:06 -05:00
iris a85e129026 move everything out of layout 2025-12-11 05:48:29 -05:00
iris 174c447706 switch to macro 2025-12-11 05:31:34 -05:00
iris 2dc5b0f62c refactor project structure (start of redoing atomic branch without atomics) 2025-12-11 05:25:58 -05:00
iris 38266debb6 stuff 2025-12-07 00:32:38 -05:00
iris 62aa02847a redo event fn signature & add event_ctx macro 2025-12-06 20:48:10 -05:00
iris f6b1143665 nothing 2025-12-04 15:01:31 -05:00
iris 28d17c49c6 shift code 2025-12-04 14:54:40 -05:00
iris 23ae5b246e remove default window attrs (oops) 2025-12-04 14:53:08 -05:00
iris db888416b6 add minimal example 2025-12-04 14:46:34 -05:00
iris f7b100e00c add default winit framework 2025-12-04 14:31:07 -05:00
iris e5d0a7e592 fix on_id widget refcount leak (-> memory leak) 2025-12-04 02:59:05 -05:00
iris 84c460a91f app work 2025-12-03 22:51:33 -05:00
iris d6a9711ceb fix mask render bug (didn't recreate bind group) 2025-11-28 16:09:23 -05:00
iris ee0616885f single line textedit 2025-11-22 22:04:46 -05:00
iris 14a9da0553 hold shift to select text 2025-11-22 21:38:16 -05:00
iris 8e08f67627 ctrl x 2025-11-22 21:15:50 -05:00
iris 84b3bf9078 fix zalgotext highlight 2025-11-22 21:14:41 -05:00
iris bf3ade840b triple click to select line + fix highlighting 2025-11-22 20:49:38 -05:00
iris 2aa5719166 added text edit history / undo (ctrl-z) 2025-11-22 20:37:37 -05:00
iris 90c579d734 oopsie (orderlerss -> ordered rendering) 2025-11-22 20:22:26 -05:00
iris d757e805e8 rename z offset to layer offset 2025-11-22 18:45:01 -05:00
iris 9deba3d9d7 fix wrapping text selection 2025-11-22 18:29:44 -05:00
iris c24c517c60 word selection 2025-11-22 15:33:28 -05:00
iris fc89826794 ctrl a & word movement 2025-11-22 15:01:22 -05:00
iris 140be50baa fix layer mismatch with apply free in renderer 2025-11-22 03:15:21 -05:00
iris 1c6fc99f57 idek bruh 2025-11-22 00:44:38 -05:00
iris 1cec56e847 TEXT SELECTION 2025-11-21 23:56:31 -05:00
iris 246caffb34 fix warning 2025-11-21 20:25:55 -05:00
iris 31ff17c21a hint gaming 2025-11-21 20:18:39 -05:00
iris 23c5abe5a9 crop text images that are too big 2025-11-21 18:18:28 -05:00
iris 97b284e81e store size in tex instead of bot_right 2025-11-21 14:38:16 -05:00
iris c428de8fd5 change default text align and fix scroll drawing 2025-11-21 13:31:49 -05:00
iris 5785352ac0 max size + better scrolling size fn 2025-11-21 02:44:59 -05:00
iris 172e7157be FINALLY FIXED STUPID TEST UI ISSUES (true painter.rs moment) + scrolling 2025-11-21 01:40:13 -05:00
iris e3b1ddc993 add comments 😱 2025-11-20 23:27:30 -05:00
iris 5aef8c2201 lol comments 2025-11-20 23:06:59 -05:00
iris acd67179b7 fix mask coords... might wanna change cpu output? 2025-11-20 23:01:01 -05:00
iris dff72d2c43 I love control flow 2025-11-20 22:48:08 -05:00
iris f6f9ebbe51 tuple gaming 2025-11-20 15:56:00 -05:00
iris 6251c23d37 the great orientation refactor (move to x & y UiScalars/Spans) + don't call full size in align 2025-11-20 15:44:39 -05:00
iris 96ef0c529b remove debug prints 2025-11-20 00:19:45 -05:00
iris a952b34a72 mistakes were fixed and sins were committed 2025-11-20 00:18:30 -05:00
iris db248de8f4 fix span sizing (still some layout tho) 2025-11-18 17:52:45 -05:00
iris 9febd03067 comment 2025-11-18 01:13:24 -05:00
iris 38d7ca3090 todo update 2025-11-18 01:09:58 -05:00
iris 126c442706 todo update 2025-11-18 01:08:17 -05:00
iris 6b7719539e better text rendering 2025-11-18 01:05:51 -05:00
iris bc829397c8 stuff for ime positioning 2025-11-17 22:49:22 -05:00
iris 4981bd739a unused imports 2025-11-17 22:04:32 -05:00
iris 7e257fd042 make shaping a const 2025-11-17 21:38:40 -05:00
iris c7b255be4f fix full redraw modules not cleaning up 2025-11-17 21:11:35 -05:00
iris 955e6b7588 ptr 2025-11-17 18:33:03 -05:00
iris f5f4547537 add readme 2025-11-17 17:01:04 -05:00
iris 3425eb7b80 finisth editing todo 2025-11-17 16:39:59 -05:00
iris 681efe1e2b edit todo 2025-11-17 16:39:09 -05:00
iris ef448ec870 fix awful desired size cache 2025-11-17 16:30:25 -05:00
iris f74c4dc6e2 perf 2025-11-17 16:08:29 -05:00
iris b3d0dc3871 more retained size fixes 2025-11-17 14:11:33 -05:00
iris b6ece4a5ee back to retained... 2025-11-17 13:55:49 -05:00
iris 2914d7968f IP 2025-11-15 02:22:50 -05:00
iris 8896c64445 update imports 😂 2025-11-15 02:10:12 -05:00
iris bd0805dbac rename repo to iris + z offset 2025-11-15 01:01:18 -05:00
iris ed87b7c336 arst 2025-11-14 15:42:08 -05:00
iris 448f348356 remove debug prints 2025-11-14 14:44:16 -05:00
iris 182b1d4729 add detail on what the problem is in todo 2025-11-14 14:43:36 -05:00
iris b4947db850 I give up on retained for now lmao 2025-11-14 14:39:08 -05:00
iris 218b3f14ed app work? 2025-11-14 13:49:01 -05:00
iris e2690fa611 span builder 2025-11-13 16:59:31 -05:00
iris 125fca4075 rename spacing to gap 2025-11-13 14:29:03 -05:00
iris 73afea8c35 switch to element defined span lens + better size fn 2025-11-13 14:27:31 -05:00
iris 8755c04feb sort of bandaid patch over resizing 2025-11-11 14:45:06 -05:00
iris afabdc52a2 app work 2025-11-11 14:34:56 -05:00
iris deaf730901 app work 2025-11-11 13:55:36 -05:00
iris 92db1264a6 beginning actual app 2025-11-11 01:35:59 -05:00
iris 379eec771a stuff 2025-11-10 22:14:27 -05:00
iris ebff93bec9 new const trait syntax 2025-11-10 22:10:38 -05:00
iris 1c49db1b89 initial mask impl 2025-11-10 14:45:22 -05:00
iris 5c2022396a update todo 2025-09-29 14:54:47 -04:00
iris db0d11cacb ing prefix gives off bad vibes 2025-09-29 14:01:34 -04:00
iris 337af3e18c positioning dir in core 2025-09-29 14:00:54 -04:00
iris 628840d5cd text update for more code reuse + much better caching 2025-09-29 13:45:48 -04:00
iris c98a43f94d update px dependent on resize + move painter data into struct in ui 2025-09-28 13:14:51 -04:00
iris 61df088cc7 initial text wrapping impl (resizing will break) 2025-09-28 01:32:10 -04:00
iris b2950566af add axis to flip so spans w negative sign work correctly 2025-09-27 23:47:42 -04:00
iris dc9340b26c renaming & comments 2025-09-27 22:16:42 -04:00
iris 8afe2c68e8 add scroll fn to traits 2025-09-27 21:32:27 -04:00
iris 5445008528 add offset / scrolling + clipboard support 2025-09-27 21:13:00 -04:00
iris 95f049acb4 move widgets on draw if region size is same 2025-09-27 16:11:30 -04:00
iris 5f2dffc189 unleash the sizes 2025-09-25 21:30:26 -04:00
iris 06cfeaac6b no branching allowed 2025-09-25 21:19:47 -04:00
iris 6d829dbe81 stack & padding fix sorta, preparing for scroll areas 2025-09-25 19:59:18 -04:00
iris 273a92d1f7 decide it's better to leave them separate 2025-09-25 14:32:20 -04:00
iris 552d66d90f move ctx in event to be on module so run event and stuff can be used easily 2025-09-25 13:59:39 -04:00
iris fe42092556 jugando 2025-09-25 13:00:06 -04:00
iris cfd5cda0b2 clean up a bit 2025-09-25 12:43:11 -04:00
iris 21f15fb9c5 event system!!! 2025-09-25 12:37:06 -04:00
iris 4deeabe611 stop doing option transmuting bruh 2025-09-25 00:39:22 -04:00
iris 51f9908103 specify generics for transmute 2025-09-25 00:36:59 -04:00
iris 6e5cce2617 safety comment formatting 2025-09-25 00:35:20 -04:00
iris 055aaf757c HEHEHAW (fixes last commit which panics cause of unsafe UB) 2025-09-25 00:30:00 -04:00
iris b14aafca30 indices iterator for layers 2025-09-25 00:26:02 -04:00
iris 8829878f2e sanity 2025-09-25 00:07:53 -04:00
iris 57bfd2d348 make layer iter reversible 2025-09-25 00:04:01 -04:00
iris 443e13f094 make run sensors sane and adjust on_edit to just use ui as ctx (so two run calls needed) 2025-09-24 22:46:55 -04:00
iris 3463682d62 delete old run_sensors 2025-09-24 17:42:31 -04:00
iris 719bee4b31 remove context from ui (again) and create weird trait for it 2025-09-24 17:41:25 -04:00
iris 26c248dcba add module system and move sensor into core with it 2025-09-24 16:11:39 -04:00
iris 2adf7a43a1 preload text by default 2025-09-24 12:33:02 -04:00
iris 70d3027bfb move widgets out of ui 2025-09-21 17:51:10 -04:00
iris c1f0b16f20 switch to fxhash 2025-09-21 16:27:36 -04:00
iris bc9a273831 name lol 2025-09-20 19:55:29 -04:00
iris 01cec31da0 add darken and brighten color fns 2025-09-20 17:30:53 -04:00
iris 20b044865c we love post fix 2025-09-20 13:42:47 -04:00
iris 3653f24e06 store color in linear 2025-09-20 13:34:04 -04:00
iris e35e72402f add info back in 2025-09-20 13:09:18 -04:00
iris 949c9df0a0 cache text buf 2025-09-20 12:49:55 -04:00
iris 2d7484a631 prev isn't used atm 2025-09-20 02:00:08 -04:00
iris fee03fddc8 sensors are now normal 2025-09-20 01:46:55 -04:00
iris 8ecd8bb171 layers initial impl (no sensors) 2025-09-20 00:50:58 -04:00
iris 7651699743 actually use drawing 2025-09-17 12:52:08 -04:00
iris e880acca66 clear textures in remove so not needed outside 2025-09-17 12:49:41 -04:00
iris 1162ba4c10 Option<Id>.duplicate 2025-09-16 17:34:19 -04:00
iris f9097807a2 sizing actually working correctly now 2025-09-16 17:31:54 -04:00
iris b48acccb8d sense specific buttons 2025-09-15 22:22:52 -04:00
iris 21aa2b3501 remove not hovering lol 2025-09-15 21:23:06 -04:00
iris 90cbc2524a sensors now run in correct order 2025-09-15 21:13:23 -04:00
iris 2700c31c13 cursor finally working properly and removed from render_text 2025-09-15 20:30:26 -04:00
iris 9d659b6afd actually use the text library for text editing (fully working I think but code isn't cleanest) 2025-09-15 14:34:57 -04:00
iris e9853120ce sort of fix text editing (better but still bad) 2025-09-11 17:12:11 -04:00
iris 242c3b992e IDC FINALLY OH MY GOD (I think like ctx + resize propagation + some other stuff) 2025-09-11 00:59:26 -04:00
iris 709a2d0e17 preparation 2025-09-09 21:53:32 -04:00
iris 15cc91d92a update todo 2025-09-07 23:45:24 -04:00
iris 2b5965e2e9 add to todo 2025-09-07 23:44:55 -04:00
iris 09f4de619e better & more fine grained redraw system (should allow movement) 2025-09-07 23:33:36 -04:00
iris d4690401eb rename widget fn macros 2025-08-29 00:11:41 -04:00
iris 42f5a8d01b btext 2025-08-28 22:58:01 -04:00
iris 4b1ee21e94 text new fn 2025-08-28 22:51:58 -04:00
iris 55bee4b25e rect fn 2025-08-28 22:49:58 -04:00
iris 3df76d926c rename a bit 2025-08-28 22:21:43 -04:00
iris 97f2f67dee small alignment test 2025-08-28 21:57:46 -04:00
iris 1204e3728e alignment!!! 2025-08-28 21:55:34 -04:00
iris 46c7d8ba26 maybe fix relative len for sized span 2025-08-28 18:28:14 -04:00
iris a0e6623abe sized spans! 2025-08-28 18:25:59 -04:00
iris d7d67e4ed3 more test stuff 2025-08-28 01:52:00 -04:00
iris 28935e33e9 clean up 2025-08-28 01:36:26 -04:00
iris 834182ffe8 sized widgets! 2025-08-28 01:35:43 -04:00
iris d4d0b3b580 testing stuff 2025-08-26 01:57:32 -04:00
iris d0bed07ee0 more testing stuff 2025-08-25 23:13:29 -04:00
iris e85b503127 testing stuff 2025-08-25 23:11:46 -04:00
iris 94a3ba5837 make name longer 😔 2025-08-25 22:51:33 -04:00
iris 9780724126 senses are now bitflags 2025-08-25 22:36:38 -04:00
iris e9037cdc14 contextless gaming 2025-08-25 19:21:39 -04:00
iris e8b255c8f9 remove context generic 2025-08-25 18:53:21 -04:00
iris d4b1a56467 comments 2025-08-25 16:30:06 -04:00
iris 325e13c01f auto generate label (TODO: should be moved into widgets now that all have one) 2025-08-25 16:23:34 -04:00
iris 7b21b0714d static ids 2025-08-25 16:12:49 -04:00
iris 41103f2732 comments 2025-08-25 14:21:25 -04:00
iris 9e751d4161 clean up 2025-08-24 22:44:21 -04:00
iris 880d7eca50 clean up after text fix 2025-08-24 22:40:33 -04:00
iris 5cb84047b9 text fix? 2025-08-24 22:40:12 -04:00
iris 8f02a358a4 fix view count 2025-08-24 22:31:51 -04:00
iris 44a8b1cbeb fix view leak and add view count 2025-08-24 22:13:02 -04:00
iris 74d01d14d4 fix reactivity 😭 + visual widget counter 2025-08-24 22:02:50 -04:00
iris 6bb6db32a6 REACTIVITY 2025-08-24 20:34:19 -04:00
iris 50ccf7393d snap text on shader 2025-08-23 22:16:00 -04:00
iris 5ce6fca275 initial text impl 2025-08-23 21:15:39 -04:00
iris abcbc267b5 I forgot why I did it the other way lol (revert) 2025-08-23 15:39:43 -04:00
iris 2ffb09bef0 made painter actually how I wanted it (draw now takes in an owned painter) 2025-08-23 15:20:25 -04:00
iris 6fbdf9fbc8 texture freeing + render updates done a bit nicer 2025-08-23 13:02:00 -04:00
iris 5fe63e311c update todo 2025-08-22 23:10:32 -04:00
iris 7dbdcbba42 added underdeveloped but working image support (no freeing or samplers) 2025-08-22 23:07:31 -04:00
iris bde929b05a typed primitive buffers + macro for creation 2025-08-21 19:37:50 -04:00
iris b7f83b58a9 sensor ctx 2025-08-20 13:09:03 -04:00
iris 1482e5d67c comments 2025-08-20 12:18:44 -04:00
iris 368826fe05 refcount ids and delete unused 2025-08-16 19:15:21 -04:00
iris b2acbcc189 oops 2025-08-16 14:43:36 -04:00
iris b0fe7310eb please rust analyzer with macros 2025-08-16 02:34:44 -04:00
iris 166394d8d9 remove comment 2025-08-16 01:53:02 -04:00
iris dd39db847c bruh rust analyzer sucks 2025-08-16 01:52:44 -04:00
iris 11188f2951 actually sane sensor handling 2025-08-16 00:56:37 -04:00
iris f4aef3a983 idek stuff like stack 2025-08-15 22:59:58 -04:00
iris a7dfacb83e REAL SENSORS 2025-08-15 21:42:35 -04:00
iris 9f1802f497 clean up buttons 2025-08-15 16:22:28 -04:00
iris 78ea738b8e SENSORS 2025-08-15 15:48:00 -04:00
iris c5aa0a02e2 clean up 2025-08-14 15:05:55 -04:00
iris e41970287d TAG TECHNOLOGY 2025-08-14 12:21:26 -04:00
iris 4d68fa476d stuff 2025-08-13 03:15:50 -04:00
iris 9e80a32a4b convert rest of base 2025-08-13 02:10:53 -04:00
iris f4975df57b widget fn ret macro 2025-08-13 02:07:35 -04:00
iris c7e3225c5f gaming 2025-08-13 01:35:09 -04:00
iris 23a5ccd05e span direction (sign) now works 2025-08-11 02:41:14 -04:00
iris fa930180c1 spans now good (other than direction) + refactor 2025-08-11 02:26:28 -04:00
iris 95a07786bb spans now good (other than direction) + refactor 2025-08-11 02:24:27 -04:00
iris 132113f09e center w size 2025-08-10 20:40:14 -04:00
iris f2cbf90d1d center anchors on 0 0 2025-08-10 20:29:16 -04:00
iris 848347e6b3 clean up 2025-08-10 19:10:26 -04:00
iris 577d62b1da small stuff 2025-08-10 14:35:01 -04:00
iris b6e43c157b assoc types 2025-08-10 05:19:00 -04:00
147 changed files with 23144 additions and 1926 deletions

No files matched your search

+1
View File
@@ -1 +1,2 @@
/target
perf.data*
Generated
+1952 -552
View File
File diff suppressed because it is too large. Load diff
+47 -7
View File
@@ -1,13 +1,53 @@
[package]
name = "gui"
version = "0.1.0"
edition = "2021"
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]
pollster = "0.4.0"
winit = "0.30.11"
wgpu = "26.0.1"
bytemuck = "1.23.1"
iris-core = { workspace = true }
iris-macro = { workspace = true }
parley = { workspace = true }
winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] }
pollster = { workspace = true }
wgpu = { workspace = true }
image = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
[dev-dependencies]
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
[workspace]
members = ["core", "macro", "rig-input"]
[profile.dev]
debug = 1
[profile.test]
debug = "line-tables-only"
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
pollster = "0.4.0"
winit = "0.30.12"
wgpu = "30.0.1"
bytemuck = "1.23.1"
image = "0.25.10"
parley = "0.11.1"
swash = "0.2.10"
fxhash = "0.2.1"
log = "0.4.29"
arboard = "3.6.1"
iris-core = { path = "core" }
iris-macro = { path = "macro" }
tokio = "1.49.0"
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
+26
View File
@@ -0,0 +1,26 @@
images
settings (sampler)
consider typed TextureHandle<T> variants for distinct texture uses
WidgetRef<W> or smth instead of Id
enum that's either an Id or an actual concrete instance of W
painter takes them in instead of (or in addition to) id
then type wrapper widgets to contain them
allows for compile time optimization if a widget wrapper's inner is known at compile time
and the id of inner is not needed anywhere
maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
vecs for each widget type?
POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
transforms on a move entry (scale + rotation)
an entry is a translation today; composing through one scales the rel
part and passes px through untouched, so fixed-size content and glyphs
do not follow a shortened entry
want a real transform per entry, resolved in resolve_move the way the
translation already is, so a whole subtree transforms with one buffer
write and no redraw
wanted for compose-style stretch at the end of a scroll area, and for
rotation generally
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "iris-core"
version.workspace = true
edition.workspace = true
[features]
layout-diagnostics = []
[dependencies]
wgpu = { workspace = true }
bytemuck ={ workspace = true }
image = { workspace = true }
parley = { workspace = true }
swash = { workspace = true }
fxhash = { workspace = true }
log = { workspace = true }
+23
View File
@@ -0,0 +1,23 @@
use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike};
pub trait WidgetAttr<Rsc, W: ?Sized> {
type Input;
fn run(rsc: &mut Rsc, id: WeakWidget<W>, input: Self::Input);
}
pub trait Attrable<Rsc, W: ?Sized, Tag> {
fn attr<A: WidgetAttr<Rsc, W>>(self, input: A::Input) -> impl WidgetIdFn<Rsc, W>;
}
impl<Rsc: UiRsc, WL: WidgetLike<Rsc, Tag>, Tag> Attrable<Rsc, WL::Widget, Tag> for WL {
fn attr<A: WidgetAttr<Rsc, WL::Widget>>(
self,
input: A::Input,
) -> impl WidgetIdFn<Rsc, WL::Widget> {
|rsc| {
let id = self.add(rsc);
A::run(rsc, id, input);
id
}
}
}
+18
View File
@@ -0,0 +1,18 @@
use crate::{HasEvents, WeakWidget, Widget};
pub struct EventCtx<'a, Rsc: HasEvents, Data> {
pub state: &'a mut Rsc::State,
pub data: Data,
}
pub struct EventIdCtx<'a, Rsc: HasEvents, Data, W: ?Sized> {
pub widget: WeakWidget<W>,
pub state: &'a mut Rsc::State,
pub data: Data,
}
impl<Rsc: HasEvents, Data, W: Widget> EventIdCtx<'_, Rsc, Data, W> {
pub fn widget<'a>(&self, rsc: &'a mut Rsc) -> &'a mut W {
&mut rsc.ui_mut().widgets[self.widget]
}
}
+162
View File
@@ -0,0 +1,162 @@
use crate::{
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId,
WeakWidget, WidgetEventFn, WidgetId,
util::{HashMap, HashSet, TypeMap},
};
use std::{any::TypeId, rc::Rc};
pub struct EventManager<Rsc> {
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
types: TypeMap<dyn EventManagerLike<Rsc>>,
}
impl<Rsc> Default for EventManager<Rsc> {
fn default() -> Self {
Self {
widget_to_types: Default::default(),
types: Default::default(),
}
}
}
impl<Rsc: HasEvents + 'static> EventManager<Rsc> {
pub fn get_type<E: EventLike>(&mut self) -> &mut TypeEventManager<Rsc, E::Event> {
self.types.type_or_default()
}
pub fn register<I: IdLike + 'static, E: EventLike>(
&mut self,
id: I,
event: E,
f: impl for<'a> WidgetEventFn<Rsc, <E::Event as Event>::Data<'a>, I::Widget>,
) {
let i = id.id();
self.get_type::<E>().register(id, event, f);
self.widget_to_types
.entry(i)
.or_default()
.insert(Self::type_key::<E>());
}
pub fn type_key<E: EventLike>() -> TypeId {
TypeId::of::<TypeEventManager<Rsc, E::Event>>()
}
}
pub trait EventsLike {
fn remove(&mut self, id: WidgetId);
fn draw(&mut self, active: &ActiveData);
fn undraw(&mut self, active: &ActiveData);
}
impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> {
fn remove(&mut self, id: WidgetId) {
for t in self.widget_to_types.get(&id).into_flat_iter() {
self.types.get_mut(t).unwrap().remove(id);
}
}
fn draw(&mut self, active: &ActiveData) {
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().draw(active);
}
}
fn undraw(&mut self, active: &ActiveData) {
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
self.types.get_mut(t).unwrap().undraw(active);
}
}
}
pub trait EventManagerLike<State> {
fn remove(&mut self, id: WidgetId);
fn draw(&mut self, data: &ActiveData);
fn undraw(&mut self, data: &ActiveData);
}
type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>>);
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
// TODO: reduce visiblity!!
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
pub global: E::Global,
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
}
impl<Rsc: HasEvents, E: Event> EventManagerLike<Rsc> for TypeEventManager<Rsc, E> {
fn remove(&mut self, id: WidgetId) {
self.map.remove(&id);
for layer in self.active.values_mut() {
layer.remove(&id);
}
}
fn draw(&mut self, data: &ActiveData) {
self.active
.entry(data.layer)
.or_default()
.entry(data.id)
.or_default();
}
fn undraw(&mut self, data: &ActiveData) {
if let Some(layer) = self.active.get_mut(&data.layer) {
layer.remove(&data.id);
}
}
}
impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
fn default() -> Self {
Self {
active: Default::default(),
global: Default::default(),
map: Default::default(),
}
}
}
impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
fn register<I: IdLike + 'static>(
&mut self,
widget: I,
event: impl EventLike<Event = E>,
f: impl for<'a> WidgetEventFn<Rsc, E::Data<'a>, I::Widget>,
) {
let event = event.into_event();
self.map.entry(widget.id()).or_default().push((
event,
Rc::new(move |ctx, rsc| {
f(
EventIdCtx {
widget: WeakWidget::new(widget.id()),
state: ctx.state,
data: ctx.data,
},
rsc,
);
}),
));
}
pub fn run_fn<'a>(
&mut self,
id: impl IdLike,
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) -> bool + 'a {
let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
move |ctx, rsc| {
let mut consumed = false;
for (e, f) in fs {
if let Some(data) = e.should_run(&ctx.data) {
consumed |= e.consumes(&data);
f(
EventCtx {
state: ctx.state,
data,
},
rsc,
)
}
}
consumed
}
}
}
+53
View File
@@ -0,0 +1,53 @@
mod ctx;
mod manager;
mod rsc;
pub use ctx::*;
pub use manager::*;
pub use rsc::*;
pub trait Event: Sized + 'static + Clone {
type Data<'a>: Clone = ();
type State: Default = ();
/// State the whole event type keeps, rather than one copy per widget.
type Global: Default = ();
#[allow(unused_variables)]
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
Some(data.clone())
}
/// Whether having run on this data uses up whatever triggered it, so
/// nothing further should see it.
#[allow(unused_variables)]
fn consumes(&self, data: &Self::Data<'_>) -> bool {
false
}
}
pub trait EventLike {
type Event: Event;
fn into_event(self) -> Self::Event;
}
impl<E: Event> EventLike for E {
type Event = Self;
fn into_event(self) -> Self::Event {
self
}
}
pub trait EventFn<Rsc: HasEvents, Data>: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static {}
impl<Rsc: HasEvents, F: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static, Data> EventFn<Rsc, Data>
for F
{
}
pub trait WidgetEventFn<Rsc: HasEvents, Data, W: ?Sized>:
Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static
{
}
impl<Rsc: HasEvents, F: Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static, Data, W: ?Sized>
WidgetEventFn<Rsc, Data, W> for F
{
}
+35
View File
@@ -0,0 +1,35 @@
use crate::{
Event, EventCtx, EventLike, EventManager, IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
};
pub trait HasState: 'static {
type State;
}
pub trait HasEvents: Sized + UiRsc + HasState {
fn events(&self) -> &EventManager<Self>;
fn events_mut(&mut self) -> &mut EventManager<Self>;
fn register_event<W: Widget + ?Sized, E: EventLike>(
&mut self,
id: WeakWidget<W>,
event: E,
f: impl for<'a> WidgetEventFn<Self, <E::Event as Event>::Data<'a>, W>,
) {
self.events_mut().register(id, event, f);
}
}
pub trait RunEvents: HasEvents {
/// Whether anything that ran used up what triggered it.
fn run_event<E: EventLike>(
&mut self,
id: impl IdLike,
data: <E::Event as Event>::Data<'_>,
state: &mut Self::State,
) -> bool {
let f = self.events_mut().get_type::<E>().run_fn(id);
f(EventCtx { state, data }, self)
}
}
impl<T: HasEvents> RunEvents for T {}
+574
View File
@@ -0,0 +1,574 @@
use crate::{UiNum, util::Vec2};
use std::{
fmt::{Debug, Display, Formatter},
ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign},
};
/// A number held as a whole count of `1 / 2^SHIFT`.
///
/// Layout reaches one place by more than one route -- a box composed down the
/// chain, and the same box summed from what its children asked for -- and has
/// to decide whether the two are the same place. In floats they land a few
/// bits apart, which is a defect wherever the answer changes what is drawn
/// rather than where. Here adding and subtracting are exact, a multiply
/// drops to the step below, and a conversion between grids takes the nearest
/// one, so two routes to one place land on one number and everything
/// downstream compares for equality instead of for nearness.
///
/// `SHIFT` is the number of fractional bits, which is what makes the steps
/// divide a whole number: a power of two also converts to `f32` without
/// rounding while the value fits in its mantissa.
///
/// Arithmetic wraps at the ends of the range, the way the `i32` underneath
/// does. Saturating instead was measured at a twelfth of layout's
/// instructions -- five per add against one -- to keep the ordering of
/// coordinates two million pixels out, where nothing draws anyway. A value
/// off the end is a defect either way; wrapping makes it an obvious one.
/// Only [`Self::from_f32`] clamps, since a float has further to come from.
#[repr(transparent)]
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, bytemuck::Pod, bytemuck::Zeroable,
)]
pub struct Fixed<const SHIFT: u32>(i32);
/// A length or a coordinate in pixels, in steps of `1/1024`. Finer than
/// anything a display can show, and exact in `f32` up to 16,384 px, which is
/// what lets the same number reach the GPU.
pub type Px = Fixed<PX_SHIFT>;
/// How many bits of a pixel a [`Px`] keeps. One place, because [`PxVec2`]
/// and the shader's own decoding are the same grid or nothing lines up.
pub const PX_SHIFT: u32 = 10;
/// A share of what a box has left over, which is a weight beside its
/// siblings rather than a fraction of anything: a list divides its room by
/// the total of these, so the range has to hold a whole list's worth and the
/// precision only has to tell two weights apart.
pub type Weight = Fixed<16>;
/// A fraction of a box. Twenty-four bits of it, which matches `f32` around a
/// half and beats it above one -- where anchors actually sit -- and leaves
/// +/-128 of range, enough to sum a hundred children each asking for a whole
/// box. A `leftover` weight is not one of these: it is a share of what is
/// left rather than a fraction of anything, and it sums over a whole list.
pub type Rel = Fixed<REL_SHIFT>;
/// How many bits of a box a [`Rel`] keeps, beside [`PX_SHIFT`] and for the
/// same reason.
pub const REL_SHIFT: u32 = 24;
impl<const SHIFT: u32> Fixed<SHIFT> {
pub const ZERO: Self = Self(0);
pub const ONE: Self = Self::one();
/// The gap between neighbouring values, which is also how far apart two
/// numbers can be and still mean the same place.
pub const STEP: Self = Self(1);
/// Also what stands in for an unbounded end: compared against, never
/// added to, since arithmetic wraps past it.
pub const MIN: Self = Self(i32::MIN);
pub const MAX: Self = Self(i32::MAX);
const fn one() -> Self {
assert!(SHIFT < 31, "a Fixed needs a bit for the whole part");
Self(1 << SHIFT)
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
/// The count of steps, for a caller that needs the representation rather
/// than the number.
pub const fn raw(self) -> i32 {
self.0
}
pub const fn from_int(v: i32) -> Self {
Self(v.wrapping_mul(Self::one().0))
}
/// Rounds to the nearest step, and clamps to the ends of the grid rather
/// than wrapping: this is where a number from outside arrives, and a float
/// has the range to be anywhere. A NaN has no nearest step and becomes
/// zero, which is a caller's mistake rather than a value worth carrying.
///
/// Half-away is written out rather than called through `f32::round`,
/// which is not `const`: a layout constant has to stay a constant.
pub const fn from_f32(v: f32) -> Self {
debug_assert!(!v.is_nan(), "a NaN has no place on the grid");
let scaled = v * Self::one().0 as f32;
// Above 2^23 an `f32` has no fractional part left to round, and
// adding a half there rounds the number itself up instead. The cast
// saturates at both ends and sends NaN to zero, which is the
// behaviour wanted at both.
const WHOLE: f32 = (1 << 23) as f32;
Self(match (scaled >= WHOLE, scaled <= -WHOLE, scaled < 0.0) {
(true, _, _) | (_, true, _) => scaled as i32,
(_, _, true) => (scaled - 0.5) as i32,
_ => (scaled + 0.5) as i32,
})
}
/// The first step at or above `v`, where [`Self::from_f32`] takes the
/// nearest one and is below it half the time. For a bound that has to
/// admit the value it came from: a measurement rounded down is a bound
/// that leaves out the thing it was measured from.
pub const fn ceil_from_f32(v: f32) -> Self {
let nearest = Self::from_f32(v);
match nearest.to_f32() < v {
true => nearest.next_up(),
false => nearest,
}
}
/// From a number as it is written in source -- `16`, `1.5` -- which is
/// the other place a value enters the grid.
pub fn from_num(v: impl UiNum) -> Self {
Self::from_f32(v.to_f32())
}
pub const fn to_f32(self) -> f32 {
self.0 as f32 / Self::one().0 as f32
}
/// The same value on another grid, rounded where the new one is coarser.
pub const fn to_scale<const TO: u32>(self) -> Fixed<TO> {
Fixed(match TO >= SHIFT {
true => self.0 << (TO - SHIFT),
false => shift_round(self.0 as i64, SHIFT - TO) as i32,
})
}
pub const fn add(self, rhs: Self) -> Self {
Self(self.0.wrapping_add(rhs.0))
}
pub const fn sub(self, rhs: Self) -> Self {
Self(self.0.wrapping_sub(rhs.0))
}
pub const fn neg(self) -> Self {
Self(self.0.wrapping_neg())
}
/// Scaled by a number on any grid, which is how a length takes a fraction
/// of itself and keeps being a length: the product is measured in the
/// receiver's steps.
///
/// Dropped to the step below rather than taken to the nearest one
/// (Bryan, 2026-09-16), which costs a share a thousandth of a pixel of
/// its row -- less than an even number of pixels draws. Toward negative
/// infinity on both sides of zero, since that is a shift and nothing
/// else: a value and its negation therefore land different distances
/// from where they came, so a flipped span can sit a step from its
/// mirror image.
pub const fn mul<const BY: u32>(self, by: Fixed<BY>) -> Self {
Self(((self.0 as i64 * by.0 as i64) >> BY) as i32)
}
/// Repeated a whole number of times, which no grid rounds.
pub const fn mul_int(self, by: i32) -> Self {
Self(self.0.wrapping_mul(by))
}
/// Divided into a whole number of parts, rounded to the nearest step.
pub const fn div_int(self, by: i32) -> Self {
debug_assert!(by != 0, "no part of nothing");
if by == 0 {
return Self::ZERO;
}
Self(div_round(self.0 as i64, by as i64) as i32)
}
/// Divided by a number on any grid. A zero divisor is a caller bug -- a
/// box of no length has no fraction of itself -- and answers with the end
/// of the range so that a release build lays out something absurd rather
/// than dying.
pub const fn div<const BY: u32>(self, by: Fixed<BY>) -> Self {
debug_assert!(by.0 != 0, "dividing by a length of zero");
if by.0 == 0 {
return match self.0 < 0 {
true => Self::MIN,
false => Self::MAX,
};
}
Self(div_round((self.0 as i64) << BY, by.0 as i64) as i32)
}
/// `num / den` on *this* grid rather than on theirs, for weights coarser
/// than the share they divide.
pub const fn ratio<const OF: u32>(num: Fixed<OF>, den: Fixed<OF>) -> Self {
debug_assert!(den.0 != 0, "no part of a whole of nothing");
if den.0 == 0 {
return Self::ZERO;
}
Self(div_round((num.0 as i64) << SHIFT, den.0 as i64) as i32)
}
/// `from` and `to` a fraction of the way apart, the fraction being the
/// receiver -- the argument order [`crate::util::LerpUtil`] already uses.
pub const fn lerp<const OF: u32>(self, from: Fixed<OF>, to: Fixed<OF>) -> Fixed<OF> {
from.add(to.sub(from).mul(self))
}
pub const fn min(self, other: Self) -> Self {
match self.0 < other.0 {
true => self,
false => other,
}
}
pub const fn max(self, other: Self) -> Self {
match self.0 > other.0 {
true => self,
false => other,
}
}
pub const fn abs(self) -> Self {
Self(self.0.wrapping_abs())
}
pub const fn clamp(self, lo: Self, hi: Self) -> Self {
debug_assert!(lo.0 <= hi.0, "an empty clamp has no answer");
self.max(lo).min(hi)
}
/// The next value along, for an interval that must not admit its own
/// boundary. The step is the whole gap, so there is nothing to exclude
/// between this and the boundary itself.
pub const fn next_up(self) -> Self {
Self(self.0.wrapping_add(1))
}
pub const fn next_down(self) -> Self {
Self(self.0.wrapping_sub(1))
}
}
/// Back to a single step, rounding halves away from zero so that a value and
/// its negation round to the same distance.
const fn shift_round(v: i64, bits: u32) -> i64 {
let half = (1i64 << bits) >> 1;
match v < 0 {
true => -((-v + half) >> bits),
false => (v + half) >> bits,
}
}
const fn div_round(num: i64, den: i64) -> i64 {
let (q, rem) = (num / den, num % den);
match rem.unsigned_abs() * 2 >= den.unsigned_abs() {
true => match (num < 0) == (den < 0) {
true => q + 1,
false => q - 1,
},
false => q,
}
}
/// Toward positive infinity when `up`, toward negative infinity otherwise.
pub(crate) const fn div_toward(num: i64, den: i64, up: bool) -> i64 {
let (q, rem) = (num / den, num % den);
if rem == 0 {
return q;
}
match (rem < 0) == (den < 0) {
true => q + up as i64,
false => q - !up as i64,
}
}
/// Clamped to the ends, unlike a [`Fixed`]'s own arithmetic: a range of box
/// lengths that runs past `i32` really is unbounded.
pub(crate) const fn narrow(v: i64) -> i32 {
if v > i32::MAX as i64 {
return i32::MAX;
}
if v < i32::MIN as i64 {
return i32::MIN;
}
v as i32
}
const impl<const SHIFT: u32> Add for Fixed<SHIFT> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Fixed::add(self, rhs)
}
}
const impl<const SHIFT: u32> Sub for Fixed<SHIFT> {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Fixed::sub(self, rhs)
}
}
const impl<const SHIFT: u32> Neg for Fixed<SHIFT> {
type Output = Self;
fn neg(self) -> Self {
Fixed::neg(self)
}
}
const impl<const SHIFT: u32> AddAssign for Fixed<SHIFT> {
fn add_assign(&mut self, rhs: Self) {
*self = Fixed::add(*self, rhs);
}
}
const impl<const SHIFT: u32> SubAssign for Fixed<SHIFT> {
fn sub_assign(&mut self, rhs: Self) {
*self = Fixed::sub(*self, rhs);
}
}
const impl<const SHIFT: u32, const BY: u32> Mul<Fixed<BY>> for Fixed<SHIFT> {
type Output = Self;
fn mul(self, rhs: Fixed<BY>) -> Self {
Fixed::mul(self, rhs)
}
}
const impl<const SHIFT: u32, const BY: u32> Div<Fixed<BY>> for Fixed<SHIFT> {
type Output = Self;
fn div(self, rhs: Fixed<BY>) -> Self {
Fixed::div(self, rhs)
}
}
impl<const SHIFT: u32> Display for Fixed<SHIFT> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.to_f32(), f)
}
}
/// Prints the number rather than the count of steps: a failing layout test
/// reports boxes, and `1126` is not a height anybody can read.
impl<const SHIFT: u32> Debug for Fixed<SHIFT> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.to_f32(), f)
}
}
/// Two of them, for the places a size or a position needs both axes: a
/// window, a box in pixels, a pointer. Held apart from [`crate::util::Vec2`]
/// because that one is what the GPU and the platform speak.
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct FixedVec2<const SHIFT: u32> {
pub x: Fixed<SHIFT>,
pub y: Fixed<SHIFT>,
}
pub type PxVec2 = FixedVec2<PX_SHIFT>;
impl<const SHIFT: u32> FixedVec2<SHIFT> {
pub const ZERO: Self = Self::splat(Fixed::ZERO);
pub const fn new(x: Fixed<SHIFT>, y: Fixed<SHIFT>) -> Self {
Self { x, y }
}
pub const fn splat(v: Fixed<SHIFT>) -> Self {
Self { x: v, y: v }
}
pub fn from_f32(v: Vec2) -> Self {
Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y))
}
/// The first step at or above each part, for a measurement reported as a
/// box: what it occupies is not less than what was measured.
pub fn ceil_from_f32(v: Vec2) -> Self {
Self::new(Fixed::ceil_from_f32(v.x), Fixed::ceil_from_f32(v.y))
}
pub fn to_f32(self) -> Vec2 {
Vec2::new(self.x.to_f32(), self.y.to_f32())
}
pub const fn div_int(self, by: i32) -> Self {
Self::new(self.x.div_int(by), self.y.div_int(by))
}
pub const fn min(self, other: Self) -> Self {
Self::new(self.x.min(other.x), self.y.min(other.y))
}
pub const fn max(self, other: Self) -> Self {
Self::new(self.x.max(other.x), self.y.max(other.y))
}
}
// `impl_op!` names one concrete type, and this one is generic.
const impl<const SHIFT: u32> Add for FixedVec2<SHIFT> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self::new(self.x.add(rhs.x), self.y.add(rhs.y))
}
}
const impl<const SHIFT: u32> Sub for FixedVec2<SHIFT> {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Self::new(self.x.sub(rhs.x), self.y.sub(rhs.y))
}
}
const impl<const SHIFT: u32> AddAssign for FixedVec2<SHIFT> {
fn add_assign(&mut self, rhs: Self) {
*self = Add::add(*self, rhs);
}
}
const impl<const SHIFT: u32> SubAssign for FixedVec2<SHIFT> {
fn sub_assign(&mut self, rhs: Self) {
*self = Sub::sub(*self, rhs);
}
}
impl<const SHIFT: u32> Debug for FixedVec2<SHIFT> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl<const SHIFT: u32> Display for FixedVec2<SHIFT> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sum_of_steps_does_not_drift() {
let mut at = Px::ZERO;
for _ in 0..20_000 {
at += Px::from_raw(3);
}
assert_eq!(at, Px::from_raw(60_000));
for _ in 0..20_000 {
at -= Px::from_raw(3);
}
assert_eq!(at, Px::ZERO);
}
#[test]
fn a_pixel_survives_the_trip_through_f32() {
for raw in [0, 1, -1, 64, -1000, 16_777_215, -16_777_215] {
let px = Px::from_raw(raw);
assert_eq!(Px::from_f32(px.to_f32()), px);
}
}
#[test]
fn a_fraction_of_a_length_is_a_length() {
let half = Px::from_int(100) * Rel::from_f32(0.5);
assert_eq!(half, Px::from_int(50));
assert_eq!(Px::from_int(100) * Rel::ONE, Px::from_int(100));
assert_eq!(Px::from_int(100) * Rel::ZERO, Px::ZERO);
}
/// Toward negative infinity on both sides of zero, which is what makes
/// it a shift rather than a shift and a sign branch -- and what makes a
/// value and its negation land different distances from where they came,
/// so a flipped span can sit a step from its mirror image.
#[test]
fn a_multiply_drops_to_the_step_below_on_both_sides_of_zero() {
// A step and a half of one, which has no step of its own.
let step_and_a_half = Rel::from_f32(1.5).div_int(Px::ONE.raw());
assert_eq!(Px::ONE * step_and_a_half, Px::from_raw(1));
assert_eq!(Px::ONE.neg() * step_and_a_half, Px::from_raw(-2));
}
/// A division rounds to the nearest step, so it cannot put back the
/// steps a truncating multiply dropped: a round trip comes back short,
/// never long, and by the few steps the two operations gave up.
#[test]
fn dividing_by_a_fraction_cannot_undo_a_truncating_multiply() {
let third = Rel::ONE / Rel::from_int(3);
let len = Px::from_int(300);
let back = len * third / third;
assert!(back <= len, "{back:?} is longer than {len:?}");
assert!(len - back <= Px::from_raw(3), "{back:?} against {len:?}");
assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200));
}
/// The bound a greedy line break needs: the width it was measured at is
/// not on the grid, and the narrowest box the break still holds for is
/// the step at or above it, never the one below.
#[test]
fn a_ceiling_never_lands_below_the_number_it_came_from() {
let step = 1.0 / (1 << PX_SHIFT) as f32;
for n in 0..64 {
let v = 189.0 + n as f32 * step / 3.0;
let up = Px::ceil_from_f32(v);
assert!(up.to_f32() >= v, "{up:?} is below {v}");
assert!(
up.to_f32() - v < step,
"{up:?} is more than a step above {v}"
);
}
// An exact step is its own ceiling.
assert_eq!(Px::ceil_from_f32(189.5), Px::from_f32(189.5));
}
#[test]
fn a_number_from_outside_is_clamped_to_the_grid() {
assert_eq!(Px::from_f32(1e12), Px::MAX);
assert_eq!(Px::from_f32(-1e12), Px::MIN);
}
#[test]
fn a_coarser_grid_rounds_and_a_finer_one_does_not() {
// A third, which neither grid holds exactly.
let third = Rel::ONE / Rel::from_int(3);
assert_eq!(third.to_scale::<6>(), Fixed::<6>::from_raw(21));
let coarse = Fixed::<6>::from_raw(21);
assert_eq!(coarse.to_scale::<24>().to_scale::<6>(), coarse);
}
#[test]
fn lerp_takes_the_fraction_as_the_receiver() {
let (from, to) = (Px::from_int(10), Px::from_int(20));
assert_eq!(Rel::ZERO.lerp(from, to), from);
assert_eq!(Rel::ONE.lerp(from, to), to);
assert_eq!(Rel::from_f32(0.5).lerp(from, to), Px::from_int(15));
assert_eq!(Rel::from_f32(0.5).lerp(to, from), Px::from_int(15));
}
#[test]
fn a_ratio_is_finer_than_the_weights_it_divides() {
let (one, three) = (Weight::ONE, Weight::from_int(3));
// A third, which the weights' own grid could only hold to 1/65536.
assert_eq!(Rel::ratio(one, three), Rel::from_raw(5592405));
assert_eq!(Rel::ratio(three, three), Rel::ONE);
assert_eq!(Rel::ratio(Weight::ZERO, three), Rel::ZERO);
}
#[test]
fn nothing_sits_between_a_value_and_the_next_one() {
let at = Px::from_int(3);
assert_eq!(at.next_up().next_down(), at);
assert_eq!(at.next_up().raw() - at.raw(), 1);
assert!(at.next_down() < at && at < at.next_up());
}
#[test]
fn it_prints_the_number_rather_than_the_steps() {
assert_eq!(format!("{:?}", Px::from_f32(17.59375)), "17.59375");
assert_eq!(format!("{}", Px::from_int(-2)), "-2");
}
}
+485
View File
@@ -0,0 +1,485 @@
//! 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, LayoutLen, PxVec2, Size, UiRegion, WidgetId};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
fmt::Write,
time::Instant,
};
#[derive(Clone, Copy)]
pub(crate) enum Counter {
Updates,
DrawRequests,
WidgetDraws,
RegionNodeDraws,
SizeReads,
HintHits,
HintMisses,
ReuseAttempts,
ReuseExact,
ReuseMoved,
ReuseDirty,
ReuseWrongParent,
ReuseRemapped,
ReuseOutside,
ReuseWrongLayer,
ReuseWrongNode,
QueuePops,
DepthReads,
LocalRedraws,
SizeChanges,
ReaderEdges,
PrimitiveWrites,
TextRenders,
TextShapeHits,
TextShapes,
TextBreaks,
GlyphPlacements,
OutsidePinnedLen,
OutsideRelBase,
OutsideRegion,
}
impl Counter {
const COUNT: usize = Self::OutsideRegion as usize + 1;
const NAMES: [&'static str; Self::COUNT] = [
"updates",
"draw requests",
"widget draws",
"region-node draws",
"draw-result size reads",
"hint hits",
"hint misses",
"reuse attempts",
"reuse exact",
"reuse moved",
"reuse: dirty",
"reuse: wrong parent",
"reuse remapped",
"reuse: outside what it holds for",
"reuse: another layer",
"reuse: region-node choice changed",
"redraw queue pops",
"depth reads",
"local redraws",
"size changes",
"reader edges",
"primitive writes",
"text renders",
"text shape hits",
"text shapes",
"text line breaks",
"glyph placements",
"reuse outside: the length it was pinned to",
"reuse outside: a rel base",
"reuse outside: a region length",
];
}
#[derive(Clone, Copy)]
pub(crate) enum TimerKind {
Update,
FullLayout,
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",
"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,
WrongLayer,
Remapped,
Outside,
Undrawn,
}
/// 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: PxVec2,
region_node: bool,
},
Reuse {
id: WidgetId,
outcome: ReuseOutcome,
},
SizeReported {
id: WidgetId,
size: Size,
},
RegionNode {
id: WidgetId,
parent: WidgetId,
region: UiRegion,
},
SizeRead {
id: WidgetId,
reader: WidgetId,
size: Size,
},
HintRead {
id: WidgetId,
reader: WidgetId,
axis: Axis,
hint: Option<LayoutLen>,
},
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: PxVec2,
region_node: bool,
) {
trace(
id,
TraceEvent::DrawRequest {
id,
parent,
region,
pixel_size,
region_node,
},
);
}
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 region_node(id: WidgetId, parent: WidgetId, region: UiRegion) {
trace(id, TraceEvent::RegionNode { 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<LayoutLen>) {
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));
}
}
+39
View File
@@ -0,0 +1,39 @@
#![feature(macro_metavar_expr_concat)]
#![feature(const_ops)]
#![feature(const_trait_impl)]
#![feature(const_convert)]
#![feature(unboxed_closures)]
#![feature(fn_traits)]
#![feature(const_destruct)]
#![feature(associated_type_defaults)]
#![feature(unsize)]
#![feature(coerce_unsized)]
#![feature(option_into_flat_iter)]
#![feature(const_index)]
#[cfg(feature = "layout-diagnostics")]
pub mod layout_diagnostics;
mod attr;
mod event;
mod fixed;
mod num;
mod orientation;
mod primitive;
mod render;
mod ui;
mod widget;
pub mod util;
pub use attr::*;
pub use event::*;
pub use fixed::*;
pub use num::*;
pub use orientation::*;
pub use primitive::*;
pub use render::*;
pub use ui::*;
pub use widget::*;
pub type UiColor = primitive::Color<u8>;
+49
View File
@@ -0,0 +1,49 @@
use crate::util::Vec2;
use std::marker::Destruct;
pub const trait UiNum {
fn to_f32(self) -> f32;
}
const impl UiNum for f32 {
fn to_f32(self) -> f32 {
self
}
}
const impl UiNum for u32 {
fn to_f32(self) -> f32 {
self as f32
}
}
const impl UiNum for i32 {
fn to_f32(self) -> f32 {
self as f32
}
}
pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
Vec2::new(x.to_f32(), y.to_f32())
}
const impl<T: const UiNum + Copy> From<T> for Vec2 {
fn from(v: T) -> Self {
Self {
x: v.to_f32(),
y: v.to_f32(),
}
}
}
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2
where
(T, U): const Destruct,
{
fn from((x, y): (T, U)) -> Self {
Self {
x: x.to_f32(),
y: y.to_f32(),
}
}
}
+222
View File
@@ -0,0 +1,222 @@
use crate::util::impl_axis_index;
use crate::{Px, Rel};
use super::*;
#[derive(Clone, Copy, PartialEq)]
pub struct Align {
pub x: Option<AxisAlign>,
pub y: Option<AxisAlign>,
}
impl Align {
pub const TOP_LEFT: RegionAlign = RegionAlign::TOP_LEFT;
pub const TOP_CENTER: RegionAlign = RegionAlign::TOP_CENTER;
pub const TOP_RIGHT: RegionAlign = RegionAlign::TOP_RIGHT;
pub const CENTER_LEFT: RegionAlign = RegionAlign::CENTER_LEFT;
pub const CENTER: RegionAlign = RegionAlign::CENTER;
pub const CENTER_RIGHT: RegionAlign = RegionAlign::CENTER_RIGHT;
pub const BOT_LEFT: RegionAlign = RegionAlign::BOT_LEFT;
pub const BOT_CENTER: RegionAlign = RegionAlign::BOT_CENTER;
pub const BOT_RIGHT: RegionAlign = RegionAlign::BOT_RIGHT;
pub const LEFT: CardinalAlign = CardinalAlign::LEFT;
pub const H_CENTER: CardinalAlign = CardinalAlign::H_CENTER;
pub const RIGHT: CardinalAlign = CardinalAlign::RIGHT;
pub const TOP: CardinalAlign = CardinalAlign::TOP;
pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER;
pub const BOT: CardinalAlign = CardinalAlign::BOT;
pub fn tuple(&self) -> (Option<AxisAlign>, Option<AxisAlign>) {
(self.x, self.y)
}
}
/// Where a widget sits in a box longer than it is. The default is the middle,
/// because the two edges are the ones that assume a direction: which of them
/// is the near one depends on the writing system and on which way a container
/// runs, and the middle is the same either way.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisAlign(Rel);
impl AxisAlign {
pub const NEG: Self = Self::new(0.0);
pub const CENTER: Self = Self::new(0.5);
pub const POS: Self = Self::new(1.0);
pub const fn new(rel: f32) -> Self {
Self(Rel::from_f32(rel))
}
/// A fraction of the room left over, which is what the layout reads: the
/// three constants are the familiar places along it, not the only ones.
pub const fn rel(&self) -> Rel {
self.0
}
}
impl Default for AxisAlign {
fn default() -> Self {
Self::CENTER
}
}
pub struct CardinalAlign {
axis: Axis,
align: AxisAlign,
}
impl CardinalAlign {
pub const LEFT: Self = Self::new(Axis::X, AxisAlign::NEG);
pub const H_CENTER: Self = Self::new(Axis::X, AxisAlign::CENTER);
pub const RIGHT: Self = Self::new(Axis::X, AxisAlign::POS);
pub const TOP: Self = Self::new(Axis::Y, AxisAlign::NEG);
pub const V_CENTER: Self = Self::new(Axis::Y, AxisAlign::CENTER);
pub const BOT: Self = Self::new(Axis::Y, AxisAlign::POS);
pub const fn new(axis: Axis, align: AxisAlign) -> Self {
Self { axis, align }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct RegionAlign {
pub x: AxisAlign,
pub y: AxisAlign,
}
impl RegionAlign {
/// Both axes at the near edge: the start of a box in its own orientation.
pub const NEAR: Self = Self {
x: AxisAlign::NEG,
y: AxisAlign::NEG,
};
}
impl RegionAlign {
pub const TOP_LEFT: Self = Self::new(AxisAlign::NEG, AxisAlign::NEG);
pub const TOP_CENTER: Self = Self::new(AxisAlign::CENTER, AxisAlign::NEG);
pub const TOP_RIGHT: Self = Self::new(AxisAlign::POS, AxisAlign::NEG);
pub const CENTER_LEFT: Self = Self::new(AxisAlign::NEG, AxisAlign::CENTER);
pub const CENTER: Self = Self::new(AxisAlign::CENTER, AxisAlign::CENTER);
pub const CENTER_RIGHT: Self = Self::new(AxisAlign::POS, AxisAlign::CENTER);
pub const BOT_LEFT: Self = Self::new(AxisAlign::NEG, AxisAlign::POS);
pub const BOT_CENTER: Self = Self::new(AxisAlign::CENTER, AxisAlign::POS);
pub const BOT_RIGHT: Self = Self::new(AxisAlign::POS, AxisAlign::POS);
pub const fn new(x: AxisAlign, y: AxisAlign) -> Self {
Self { x, y }
}
}
impl UiVec2 {
pub fn partial_align(&self, align: Align) -> UiRegion {
UiRegion {
x: if let Some(align) = align.x {
self.x.align(align)
} else {
UiSpan::FULL
},
y: if let Some(align) = align.y {
self.y.align(align)
} else {
UiSpan::FULL
},
}
}
pub fn align(&self, align: RegionAlign) -> UiRegion {
UiRegion {
x: self.x.align(align.x),
y: self.y.align(align.y),
}
}
}
impl Vec2 {
pub fn partial_align(&self, align: Align) -> UiRegion {
let s = UiVec2::from(*self);
UiRegion {
x: if let Some(align) = align.x {
s.x.align(align)
} else {
UiSpan::FULL
},
y: if let Some(align) = align.y {
s.y.align(align)
} else {
UiSpan::FULL
},
}
}
pub fn align(&self, align: RegionAlign) -> UiRegion {
let s = UiVec2::from(*self);
UiRegion {
x: s.x.align(align.x),
y: s.y.align(align.y),
}
}
}
impl Len {
pub const fn align(&self, align: AxisAlign) -> UiSpan {
let rel = align.rel();
let rest = Rel::ONE.sub(rel);
let at = Len::from_parts(rel, Px::ZERO);
UiSpan {
start: Len::from_parts(at.rel.sub(self.rel.mul(rel)), at.px.sub(self.px.mul(rel))),
end: Len::from_parts(at.rel.add(self.rel.mul(rest)), at.px.add(self.px.mul(rest))),
}
}
}
impl From<RegionAlign> for Align {
fn from(region: RegionAlign) -> Self {
Self {
x: Some(region.x),
y: Some(region.y),
}
}
}
impl From<Align> for RegionAlign {
fn from(align: Align) -> Self {
Self {
x: align.x.unwrap_or(AxisAlign::CENTER),
y: align.y.unwrap_or(AxisAlign::CENTER),
}
}
}
impl From<CardinalAlign> for RegionAlign {
fn from(align: CardinalAlign) -> Self {
Align::from(align).into()
}
}
impl From<CardinalAlign> for Align {
fn from(cardinal: CardinalAlign) -> Self {
let align = Some(cardinal.align);
match cardinal.axis {
Axis::X => Self { x: align, y: None },
Axis::Y => Self { x: None, y: align },
}
}
}
const impl From<RegionAlign> for UiVec2 {
fn from(align: RegionAlign) -> Self {
Self::new(
Len::from_parts(align.x.rel(), Px::ZERO),
Len::from_parts(align.y.rel(), Px::ZERO),
)
}
}
impl RegionAlign {
pub const fn pos(self) -> UiVec2 {
UiVec2::from(self)
}
}
impl_axis_index!(RegionAlign => AxisAlign);
+126
View File
@@ -0,0 +1,126 @@
use super::*;
use crate::util::impl_axis_index;
use crate::{Fixed, FixedVec2};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Axis {
X,
Y,
}
impl Axis {
/// A per-axis pair with `aligned` on this axis and `ortho` on the other,
/// which is what `from_axis` does for a vector.
pub fn pair<T>(self, aligned: T, ortho: T) -> [T; 2] {
match self {
Self::X => [aligned, ortho],
Self::Y => [ortho, aligned],
}
}
}
impl std::ops::Not for Axis {
type Output = Self;
fn not(self) -> Self::Output {
match self {
Self::X => Self::Y,
Self::Y => Self::X,
}
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub struct Dir {
pub axis: Axis,
pub sign: Sign,
}
impl Dir {
pub const fn new(axis: Axis, dir: Sign) -> Self {
Self { axis, sign: dir }
}
pub const LEFT: Self = Self::new(Axis::X, Sign::Neg);
pub const RIGHT: Self = Self::new(Axis::X, Sign::Pos);
pub const UP: Self = Self::new(Axis::Y, Sign::Neg);
pub const DOWN: Self = Self::new(Axis::Y, Sign::Pos);
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum Sign {
Neg,
Pos,
}
impl<const SHIFT: u32> FixedVec2<SHIFT> {
pub const fn from_axis(axis: Axis, aligned: Fixed<SHIFT>, ortho: Fixed<SHIFT>) -> Self {
match axis {
Axis::X => Self::new(aligned, ortho),
Axis::Y => Self::new(ortho, aligned),
}
}
}
impl Vec2 {
pub const fn from_axis(axis: Axis, aligned: f32, ortho: f32) -> Self {
Self {
x: match axis {
Axis::X => aligned,
Axis::Y => ortho,
},
y: match axis {
Axis::Y => aligned,
Axis::X => ortho,
},
}
}
}
pub const trait AxisT {
fn get() -> Axis;
}
pub struct XAxis;
const impl AxisT for XAxis {
fn get() -> Axis {
Axis::X
}
}
pub struct YAxis;
const impl AxisT for YAxis {
fn get() -> Axis {
Axis::Y
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct BothAxis<T> {
pub x: T,
pub y: T,
}
impl<T> BothAxis<T> {
pub const fn axis<A: const AxisT>(&mut self) -> &mut T {
match A::get() {
Axis::X => &mut self.x,
Axis::Y => &mut self.y,
}
}
pub fn take_axis<A: const AxisT>(self) -> T {
match A::get() {
Axis::X => self.x,
Axis::Y => self.y,
}
}
pub fn axis_dyn(&mut self, axis: Axis) -> &mut T {
match axis {
Axis::X => &mut self.x,
Axis::Y => &mut self.y,
}
}
}
impl_axis_index!({const SHIFT: u32} FixedVec2<SHIFT> => Fixed<SHIFT>);
impl_axis_index!(Vec2 => f32);
+255
View File
@@ -0,0 +1,255 @@
use super::*;
use crate::util::impl_axis_index;
use crate::{Px, PxVec2, Rel, UiNum, Weight, util::impl_op};
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct Size {
pub x: LayoutLen,
pub y: LayoutLen,
}
/// What a widget asks for along one axis: a [`Len`] -- pixels and a fraction
/// of the box it is given -- plus a share of whatever is left over once
/// everything fixed has been taken. The parts add up rather than choosing
/// between one another.
///
/// Only a container dividing its room can answer a share, so a length nobody
/// divides is a `Len`: a position, a padding, a cap, anything already
/// resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LayoutLen {
pub px: Px,
pub rel: Rel,
pub leftover: Weight,
}
impl<N: UiNum> From<N> for LayoutLen {
fn from(value: N) -> Self {
LayoutLen::px(value.to_f32())
}
}
impl<Nx: UiNum, Ny: UiNum> From<(Nx, Ny)> for Size {
fn from((x, y): (Nx, Ny)) -> Self {
Self {
x: x.into(),
y: y.into(),
}
}
}
/// A length with no share in it is a length a container does not have to
/// divide, which is one it can always give.
impl From<Len> for LayoutLen {
fn from(len: Len) -> Self {
Self {
px: len.px,
rel: len.rel,
leftover: Weight::ZERO,
}
}
}
impl From<LayoutLen> for Size {
fn from(value: LayoutLen) -> Self {
Self { x: value, y: value }
}
}
impl Size {
pub const ZERO: Self = Self {
x: LayoutLen::ZERO,
y: LayoutLen::ZERO,
};
pub const LEFTOVER: Self = Self {
x: LayoutLen::LEFTOVER,
y: LayoutLen::LEFTOVER,
};
/// From something measured outside layout -- a texture, a shaped line --
/// which is where a size in floats comes from.
pub fn px(v: Vec2) -> Self {
Self::from_px(PxVec2::from_f32(v))
}
pub const fn from_px(v: PxVec2) -> Self {
Self {
x: LayoutLen {
px: v.x,
..LayoutLen::ZERO
},
y: LayoutLen {
px: v.y,
..LayoutLen::ZERO
},
}
}
pub fn rel(v: Vec2) -> Self {
Self {
x: LayoutLen::rel(v.x),
y: LayoutLen::rel(v.y),
}
}
pub fn leftover(v: Vec2) -> Self {
Self {
x: LayoutLen::leftover(v.x),
y: LayoutLen::leftover(v.y),
}
}
pub fn to_uivec2(self) -> UiVec2 {
UiVec2 {
x: self.x.apply_leftover(),
y: self.y.apply_leftover(),
}
}
pub fn from_axis(axis: Axis, aligned: LayoutLen, ortho: LayoutLen) -> Self {
match axis {
Axis::X => Self {
x: aligned,
y: ortho,
},
Axis::Y => Self {
x: ortho,
y: aligned,
},
}
}
}
impl LayoutLen {
pub const ZERO: Self = Self {
px: Px::ZERO,
rel: Rel::ZERO,
leftover: Weight::ZERO,
};
pub const LEFTOVER: Self = Self {
px: Px::ZERO,
rel: Rel::ZERO,
leftover: Weight::ONE,
};
/// The whole of what is left over counts as the whole box, which is what
/// a length means to something that is not dividing a box between
/// siblings -- a scroll asking how long its content is.
pub fn apply_leftover(&self) -> Len {
let share = match self.leftover > Weight::ZERO {
true => Rel::ONE,
false => Rel::ZERO,
};
Len::from_parts(self.rel.add(share), self.px)
}
/// Only pixels: the same number of them whatever box it lands in, and
/// whatever anyone else in the row asks for. A length that is any part
/// of a box or of what is left over is not one.
pub fn is_px(self) -> bool {
self.rel == Rel::ZERO && self.leftover == Weight::ZERO
}
/// Nothing but a claim on what is left over, so there is no length here
/// at all where nothing is.
pub fn is_only_leftover(self) -> bool {
self.leftover > Weight::ZERO && self.without_leftover() == Len::ZERO
}
/// This as a length of a box, where it is one. `leftover` is not: a
/// share of what is left over is a length only to whoever divides one,
/// so it passes up in the reported size instead and is resolved there.
pub fn declared(self) -> Option<Len> {
(self.leftover == Weight::ZERO).then(|| self.without_leftover())
}
/// What this takes whatever is left over: the reading of a length for
/// anyone not dividing a box between siblings, where a share is a claim
/// on someone else's room rather than a length of its own.
/// [`Self::apply_leftover`] is the opposite reading of the same value.
pub const fn without_leftover(self) -> Len {
Len::from_parts(self.rel, self.px)
}
/// This length, given as a part of a box `len` long, as a part of the
/// box `len` is itself a part of. The share is untouched: it is a claim
/// on whoever divides the room, not a fraction of anything.
pub const fn within_len(self, len: Len) -> Self {
let part = self.without_leftover().within_len(len);
Self {
px: part.px,
rel: part.rel,
leftover: self.leftover,
}
}
pub fn px(px: impl UiNum) -> Self {
Self {
px: Px::from_num(px),
..Self::ZERO
}
}
pub fn rel(rel: impl UiNum) -> Self {
Self {
rel: Rel::from_num(rel),
..Self::ZERO
}
}
pub fn leftover(ratio: impl UiNum) -> Self {
Self {
leftover: Weight::from_num(ratio),
..Self::ZERO
}
}
}
pub mod len_fns {
use super::*;
pub fn px(px: impl UiNum) -> LayoutLen {
LayoutLen::px(px)
}
pub fn rel(rel: impl UiNum) -> LayoutLen {
LayoutLen::rel(rel)
}
pub fn leftover(ratio: impl UiNum) -> LayoutLen {
LayoutLen::leftover(ratio)
}
}
impl_op!(same LayoutLen Add add; px rel leftover);
impl_op!(same LayoutLen Sub sub; px rel leftover);
impl_op!(same Size Add add; x y);
impl_op!(same Size Sub sub; x y);
impl Default for LayoutLen {
fn default() -> Self {
Self::leftover(1.0)
}
}
impl std::fmt::Display for Size {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl std::fmt::Display for LayoutLen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.px != Px::ZERO {
write!(f, "{} px;", self.px)?;
}
if self.rel != Rel::ZERO {
write!(f, "{} rel;", self.rel)?;
}
if self.leftover != Weight::ZERO {
write!(f, "{} leftover;", self.leftover)?;
}
Ok(())
}
}
impl_axis_index!(Size => LayoutLen);
+11
View File
@@ -0,0 +1,11 @@
mod align;
mod axis;
mod len;
mod pos;
use crate::util::Vec2;
pub use align::*;
pub use axis::*;
pub use len::*;
pub use pos::*;
+449
View File
@@ -0,0 +1,449 @@
use crate::util::impl_axis_index;
use std::{fmt::Display, marker::Destruct};
use super::*;
use crate::{Px, PxVec2, Rel, UiNum, util::impl_op};
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct UiVec2 {
pub x: Len,
pub y: Len,
}
impl UiVec2 {
pub const ZERO: Self = Self {
x: Len::ZERO,
y: Len::ZERO,
};
pub const fn new(x: Len, y: Len) -> Self {
Self { x, y }
}
pub const fn px(px: impl const Into<Vec2>) -> Self {
let px = px.into();
Self {
x: Len::px(px.x),
y: Len::px(px.y),
}
}
/// From lengths already on the grid, with no fraction of a box.
pub const fn from_px(px: PxVec2) -> Self {
Self {
x: Len::from_parts(Rel::ZERO, px.x),
y: Len::from_parts(Rel::ZERO, px.y),
}
}
pub const fn rel(rel: impl const Into<Vec2>) -> Self {
let rel = rel.into();
Self {
x: Len::rel(rel.x),
y: Len::rel(rel.y),
}
}
pub const fn shift(&mut self, offset: impl const Into<UiVec2>) {
let offset = offset.into();
*self += offset;
}
pub const fn offset(mut self, offset: impl const Into<UiVec2>) -> Self {
self.shift(offset);
self
}
pub const fn within(&self, region: &UiRegion) -> UiVec2 {
UiVec2 {
x: self.x.within(&region.x),
y: self.y.within(&region.y),
}
}
/// Resolved against a box of `size`, which is where a fraction stops
/// being one and becomes a place.
pub fn to_px(&self, size: PxVec2) -> PxVec2 {
PxVec2::new(self.x.to_px(size.x), self.y.to_px(size.y))
}
pub const FULL_SIZE: Self = Self::rel(Vec2::ONE);
pub const fn from_axis(axis: Axis, aligned: Len, ortho: Len) -> Self {
match axis {
Axis::X => Self {
x: aligned,
y: ortho,
},
Axis::Y => Self {
x: ortho,
y: aligned,
},
}
}
pub fn get_px(&self) -> Vec2 {
(self.x.px.to_f32(), self.y.px.to_f32()).into()
}
pub fn get_rel(&self) -> Vec2 {
(self.x.rel.to_f32(), self.y.rel.to_f32()).into()
}
}
impl Display for UiVec2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "rel{};px{}", self.get_rel(), self.get_px())
}
}
impl_op!(same UiVec2 Add add; x y);
impl_op!(same UiVec2 Sub sub; x y);
const impl From<Vec2> for UiVec2 {
fn from(px: Vec2) -> Self {
Self::px(px)
}
}
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
where
(T, U): const Destruct,
{
fn from(px: (T, U)) -> Self {
Self::px(px)
}
}
/// A length along one axis: a fraction of the box it is measured in plus an
/// offset, `rel * box + px`. A position is the same number -- the length from
/// the start of the box to the point -- which is why a [`UiSpan`] is two of
/// these. Both parts are fixed point, so composing one through a chain of
/// boxes rounds only where it multiplies, and lands on the same number as any
/// other route to the same place.
///
/// It carries no claim on what a container has left over. That is
/// [`crate::LayoutLen`], which is this plus a weight, and which means nothing
/// to anyone but whoever divides the room.
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, Default, bytemuck::Zeroable)]
pub struct Len {
pub rel: Rel,
pub px: Px,
}
impl_op!(same Len Add add; rel px);
impl_op!(same Len Sub sub; rel px);
impl Len {
pub const ZERO: Self = Self {
rel: Rel::ZERO,
px: Px::ZERO,
};
pub const FULL: Self = Self {
rel: Rel::ONE,
px: Px::ZERO,
};
pub const fn new(rel: f32, px: f32) -> Self {
Self::from_parts(Rel::from_f32(rel), Px::from_f32(px))
}
/// From parts already on the grid, rather than numbers to be put on it.
pub const fn from_parts(rel: Rel, px: Px) -> Self {
Self { rel, px }
}
pub const fn rel(rel: f32) -> Self {
Self::from_parts(Rel::from_f32(rel), Px::ZERO)
}
pub const fn px(px: f32) -> Self {
Self::from_parts(Rel::ZERO, Px::from_f32(px))
}
pub const fn rel_min() -> Self {
Self::ZERO
}
pub const fn rel_max() -> Self {
Self::FULL
}
pub const fn max(&self, other: Self) -> Self {
Self {
rel: self.rel.max(other.rel),
px: self.px.max(other.px),
}
}
pub const fn min(&self, other: Self) -> Self {
Self {
rel: self.rel.min(other.rel),
px: self.px.min(other.px),
}
}
/// Both parts by the same fraction, which is what a part of a length
/// means when the length is part pixels and part a fraction of a box.
pub const fn scale(&self, by: Rel) -> Self {
Self {
rel: self.rel.mul(by),
px: self.px.mul(by),
}
}
pub const fn offset(mut self, amt: Px) -> Self {
self.px = self.px.add(amt);
self
}
pub const fn within(&self, span: &UiSpan) -> Self {
Self {
rel: self.rel.lerp(span.start.rel, span.end.rel),
px: self.px.add(self.rel.lerp(span.start.px, span.end.px)),
}
}
pub const fn within_len(&self, len: Len) -> Self {
self.within(&UiSpan {
start: Len::ZERO,
end: len,
})
}
pub fn select_len(&self, len: Len) -> Self {
len.within_len(*self)
}
pub const fn flip(&mut self) {
self.rel = Rel::ONE.sub(self.rel);
self.px = self.px.neg();
}
pub const fn to(&self, end: Self) -> UiSpan {
UiSpan { start: *self, end }
}
/// Resolved against a box of `len`, which is the only place a fraction
/// becomes a number of pixels.
pub const fn to_px(&self, len: Px) -> Px {
self.px.add(len.mul(self.rel))
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct UiSpan {
pub start: Len,
pub end: Len,
}
impl UiSpan {
pub const FULL: Self = Self {
start: Len::ZERO,
end: Len::FULL,
};
pub const fn rel(rel: f32) -> Self {
Self {
start: Len::rel(rel),
end: Len::rel(rel),
}
}
pub const fn new(start: Len, end: Len) -> Self {
Self { start, end }
}
pub const fn flip(&mut self) {
self.start.flip();
self.end.flip();
std::mem::swap(&mut self.start.rel, &mut self.end.rel);
std::mem::swap(&mut self.start.px, &mut self.end.px);
}
pub const fn shift(&mut self, offset: Len) {
self.start += offset;
self.end += offset;
}
/// Composing a box through the one it sits in, and the hottest line in
/// layout. It used to skip the multiplies where a span was the whole of
/// its parent or the parent the whole of its own; both come out of the
/// multiply unchanged anyway, and the body those comparisons cost was
/// what kept the inliner from taking this at all.
pub const fn within(&self, parent: &Self) -> Self {
Self {
start: self.start.within(parent),
end: self.end.within(parent),
}
}
/// A box `len` long inside this one, on the side `align` says. Both must
/// be lengths of the same rel base: it subtracts one from the other
/// rather than composing it in, which is what keeps a fraction the same
/// fraction however long this box turns out to be.
pub const fn place(self, len: Len, align: AxisAlign) -> Self {
let start = self.start + (self.len() - len).scale(align.rel());
Self::new(start, start + len)
}
pub const fn len(&self) -> Len {
self.end - self.start
}
/// Both ends by the same amount, which is what moving a box without
/// changing its length does to every part of it.
pub const fn translated(self, by: Len) -> Self {
Self {
start: self.start + by,
end: self.end + by,
}
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct UiRegion {
pub x: UiSpan,
pub y: UiSpan,
}
impl UiRegion {
/// Every part of the box by the same amount on each axis. Done to the
/// whole region rather than an end at a time, because that is what it is
/// -- and because four adds in a row are four adds, where four asked for
/// separately are four sequences.
pub const fn translated(self, x: Len, y: Len) -> Self {
Self {
x: self.x.translated(x),
y: self.y.translated(y),
}
}
pub const FULL: Self = Self {
x: UiSpan::FULL,
y: UiSpan::FULL,
};
pub const fn new(x: UiSpan, y: UiSpan) -> Self {
Self { x, y }
}
pub const fn rel(rel: Vec2) -> Self {
Self {
x: UiSpan::rel(rel.x),
y: UiSpan::rel(rel.y),
}
}
pub const fn within(&self, parent: &Self) -> Self {
Self {
x: self.x.within(&parent.x),
y: self.y.within(&parent.y),
}
}
pub const fn flip(&mut self, axis: Axis) {
match axis {
Axis::X => self.x.flip(),
Axis::Y => self.y.flip(),
}
}
pub fn shift(&mut self, offset: impl Into<UiVec2>) {
let offset = offset.into();
self.x.shift(offset.x);
self.y.shift(offset.y);
}
pub fn offset(mut self, offset: impl Into<UiVec2>) -> Self {
self.shift(offset);
self
}
pub fn to_px(&self, size: PxVec2) -> PixelRegion {
PixelRegion {
top_left: self.top_left().to_px(size),
bot_right: self.bot_right().to_px(size),
}
}
pub const fn center(&self) -> UiVec2 {
Align::CENTER.pos().within(self)
}
pub const fn size(&self) -> UiVec2 {
UiVec2 {
x: self.x.len(),
y: self.y.len(),
}
}
pub const fn top_left(&self) -> UiVec2 {
UiVec2 {
x: self.x.start,
y: self.y.start,
}
}
pub const fn bot_right(&self) -> UiVec2 {
UiVec2 {
x: self.x.end,
y: self.y.end,
}
}
pub const fn from_axis(axis: Axis, aligned: UiSpan, ortho: UiSpan) -> Self {
Self {
x: match axis {
Axis::X => aligned,
Axis::Y => ortho,
},
y: match axis {
Axis::X => ortho,
Axis::Y => aligned,
},
}
}
}
impl Display for UiRegion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} -> {} (size: {})",
self.top_left(),
self.bot_right(),
self.size()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PixelRegion {
pub top_left: PxVec2,
pub bot_right: PxVec2,
}
impl PixelRegion {
pub fn contains(&self, pos: PxVec2) -> bool {
pos.x >= self.top_left.x
&& pos.x <= self.bot_right.x
&& pos.y >= self.top_left.y
&& pos.y <= self.bot_right.y
}
pub fn size(&self) -> PxVec2 {
self.bot_right - self.top_left
}
}
impl Display for PixelRegion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} -> {}", self.top_left, self.bot_right)
}
}
impl_axis_index!(UiVec2 => Len);
impl_axis_index!(UiRegion => UiSpan);
+169
View File
@@ -0,0 +1,169 @@
use std::marker::Destruct;
/// stored in linear for sane manipulation
#[repr(C)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
pub struct Color<T> {
pub r: T,
pub g: T,
pub b: T,
pub a: T,
}
impl<T: ColorNum> Default for Color<T> {
fn default() -> Self {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
pub const GRAY: Self = Self::rgb(T::MID, T::MID, T::MID);
pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
pub const TURQUOISE: Self = Self::rgb(T::MIN, T::MAX, T::MID);
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
pub const SKY: Self = Self::rgb(T::MIN, T::MID, T::MAX);
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
pub const PURPLE: Self = Self::rgb(T::MID, T::MIN, T::MAX);
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
pub const NONE: Self = Self::new(T::MIN, T::MIN, T::MIN, T::MIN);
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: T, g: T, b: T) -> Self {
Self { r, g, b, a: T::MAX }
}
pub fn alpha(mut self, a: T) -> Self {
self.a = a;
self
}
pub fn as_arr(self) -> [T; 4] {
[self.r, self.g, self.b, self.a]
}
}
pub const trait F32Conversion {
fn to(self) -> f32;
fn from(x: f32) -> Self;
}
pub trait ColorNum {
const MIN: Self;
const MID: Self;
const MAX: Self;
}
macro_rules! map_rgb {
($x:ident,$self:ident, $e:tt) => {
#[allow(unused_braces)]
Self {
r: {
let $x = $self.r;
$e
},
g: {
let $x = $self.g;
$e
},
b: {
let $x = $self.b;
$e
},
a: $self.a,
}
};
}
impl<T: ColorNum + const F32Conversion> Color<T>
where
Self: const Destruct,
{
pub const fn mul_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() * amt) })
}
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() + amt) })
}
pub const fn darker(self, amt: f32) -> Self {
self.mul_rgb(1.0 - amt)
}
pub const fn brighter(self, amt: f32) -> Self {
map_rgb!(x, self, {
let x = x.to();
T::from(x + (1.0 - x) * amt)
})
}
pub fn map_rgb(self, f: impl Fn(T) -> T) -> Self {
Self {
r: f(self.r),
g: f(self.g),
b: f(self.b),
a: self.a,
}
}
pub fn srgb(r: T, g: T, b: T) -> Self {
Self {
r: s_to_l(r),
g: s_to_l(g),
b: s_to_l(b),
a: T::MAX,
}
}
}
fn s_to_l<T: F32Conversion>(x: T) -> T {
let x = x.to();
T::from(if x <= 0.0405 {
x / 12.92
} else {
((x + 0.055) / 1.055).powf(2.4)
})
}
impl ColorNum for u8 {
const MIN: Self = u8::MIN;
const MID: Self = u8::MAX / 2;
const MAX: Self = u8::MAX;
}
impl ColorNum for f32 {
const MIN: Self = 0.0;
const MID: Self = 0.5;
const MAX: Self = 1.0;
}
unsafe impl bytemuck::Pod for Color<u8> {}
const impl F32Conversion for f32 {
fn to(self) -> f32 {
self
}
fn from(x: f32) -> Self {
x
}
}
const impl F32Conversion for u8 {
fn to(self) -> f32 {
self as f32 / 255.0
}
fn from(x: f32) -> Self {
(x * 255.0).clamp(0.0, 255.0) as Self
}
}
+279
View File
@@ -0,0 +1,279 @@
use std::ops::{Index, IndexMut};
use crate::{
render::{LayerDraws, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
util::to_mut,
};
pub type LayerId = usize;
struct LayerNode<T> {
next: Ptr,
prev: Ptr,
child: Option<Child>,
depth: usize,
data: T,
}
#[derive(Clone, Copy, Debug)]
enum Ptr {
/// continue on same level
Next(usize),
/// go back to parent
Parent(usize),
/// end
None,
}
/// TODO: currently this does not ever free layers
/// is that realistically desired?
pub struct Layers<T> {
vec: Vec<LayerNode<T>>,
/// index of last layer at top level (start at first = 0)
last: usize,
}
#[derive(Clone, Copy)]
struct Child {
head: usize,
tail: usize,
}
pub type DrawLayers = Layers<LayerDraws>;
impl<T: Default> Layers<T> {
pub fn new() -> Layers<T> {
Self {
vec: vec![LayerNode::head()],
last: 0,
}
}
pub fn clear(&mut self) {
self.vec.clear();
self.vec.push(LayerNode::head());
}
fn push(&mut self, node: LayerNode<T>) -> LayerId {
let i = self.vec.len();
self.vec.push(node);
i
}
pub fn next(&mut self, i: LayerId) -> LayerId {
if let Ptr::Next(i) = self.vec[i].next {
return i;
}
let i_new = self.push(LayerNode::new(
T::default(),
self.vec[i].next,
Ptr::Next(i),
self.vec[i].depth,
));
self.vec[i].next = Ptr::Next(i_new);
self.vec[i_new].prev = Ptr::Next(i);
match self.vec[i_new].next {
Ptr::Next(i) => self.vec[i].prev = Ptr::Next(i_new),
Ptr::Parent(i) => self.vec[i].child.as_mut().unwrap().tail = i_new,
Ptr::None => self.last = i_new,
}
i_new
}
pub fn child(&mut self, i: LayerId) -> LayerId {
if let Some(c) = self.vec[i].child {
return c.head;
}
let i_child = self.push(LayerNode::new(
T::default(),
Ptr::Parent(i),
Ptr::Parent(i),
self.vec[i].depth + 1,
));
self.vec[i].child = Some(Child {
head: i_child,
tail: i_child,
});
i_child
}
pub fn iter_mut(&mut self) -> LayerIteratorMut<'_, T> {
LayerIteratorMut::new(&mut self.vec, self.last)
}
pub fn iter_orderless_mut(&mut self) -> impl Iterator<Item = (usize, &mut T)> {
self.vec.iter_mut().map(|n| &mut n.data).enumerate()
}
pub fn iter(&self) -> impl Iterator<Item = (LayerId, &T)> {
self.indices().map(|i| (i, &self.vec[i].data))
}
pub fn iter_depth(&self) -> impl Iterator<Item = ((LayerId, usize), &T)> {
self.indices()
.map(|i| ((i, self.vec[i].depth), &self.vec[i].data))
}
pub fn indices(&self) -> LayerIndexIterator<'_, T> {
LayerIndexIterator::new(&self.vec, self.last)
}
}
impl DrawLayers {
/// Inlined on purpose: it is one call per glyph, the innermost thing a
/// frame does, and whether the inliner takes it turns out to depend on
/// unrelated code elsewhere in the crate -- 12% of a resize frame.
#[inline]
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
}
impl<T: Default> Default for Layers<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> Index<LayerId> for Layers<T> {
type Output = T;
fn index(&self, index: LayerId) -> &Self::Output {
&self.vec[index].data
}
}
impl<T> IndexMut<LayerId> for Layers<T> {
fn index_mut(&mut self, index: LayerId) -> &mut Self::Output {
&mut self.vec[index].data
}
}
impl<T: Default> LayerNode<T> {
pub fn new(data: T, next: Ptr, prev: Ptr, depth: usize) -> Self {
Self {
next,
prev,
child: None,
data,
depth,
}
}
pub fn head() -> Self {
Self::new(T::default(), Ptr::None, Ptr::None, 0)
}
}
pub struct LayerIteratorMut<'a, T> {
inner: LayerIndexIterator<'a, T>,
}
impl<'a, T> Iterator for LayerIteratorMut<'a, T> {
type Item = (usize, &'a mut T);
fn next(&mut self) -> Option<Self::Item> {
let i = self.inner.next()?;
// SAFETY: requires index iterator to work properly
let layer = unsafe { to_mut(&self.inner.vec[i].data) };
Some((i, layer))
}
}
impl<'a, T> DoubleEndedIterator for LayerIteratorMut<'a, T> {
fn next_back(&mut self) -> Option<Self::Item> {
let i = self.inner.next_back()?;
// SAFETY: requires index iterator to work properly
let layer = unsafe { to_mut(&self.inner.vec[i].data) };
Some((i, layer))
}
}
impl<'a, T> LayerIteratorMut<'a, T> {
fn new(vec: &'a mut Vec<LayerNode<T>>, last: usize) -> Self {
Self {
inner: LayerIndexIterator::new(vec, last),
}
}
}
pub struct LayerIndexIterator<'a, T> {
next: Option<usize>,
next_back: Option<usize>,
vec: &'a Vec<LayerNode<T>>,
}
impl<'a, T> Iterator for LayerIndexIterator<'a, T> {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
let ret_i = self.next?;
let node = &self.vec[ret_i];
self.next = if let Some(c) = node.child {
Some(c.head)
} else if let Ptr::Next(i) = node.next {
Some(i)
} else if let Ptr::Parent(i) = node.next {
let mut node = &self.vec[i];
while let Ptr::Parent(i) = node.next {
node = &self.vec[i];
}
if let Ptr::Next(i) = node.next {
Some(i)
} else {
None
}
} else {
None
};
if self.next_back.unwrap() == ret_i {
self.next = None;
self.next_back = None;
}
Some(ret_i)
}
}
impl<'a, T> DoubleEndedIterator for LayerIndexIterator<'a, T> {
fn next_back(&mut self) -> Option<Self::Item> {
let ret_i = self.next_back?;
let node = &self.vec[ret_i];
self.next_back = if let Ptr::Next(mut i) = node.prev {
while let Some(c) = self.vec[i].child {
i = c.tail
}
Some(i)
} else if let Ptr::Parent(i) = node.prev {
Some(i)
} else {
None
};
if self.next.unwrap() == ret_i {
self.next = None;
self.next_back = None;
}
Some(ret_i)
}
}
impl<'a, T> LayerIndexIterator<'a, T> {
fn new(vec: &'a Vec<LayerNode<T>>, last: usize) -> Self {
let mut last = last;
while let Some(c) = vec[last].child {
last = c.tail;
}
Self {
next: Some(0),
next_back: Some(last),
vec,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
mod color;
mod layer;
mod text;
mod texture;
pub use color::*;
pub use layer::*;
pub use text::*;
pub use texture::*;
+440
View File
@@ -0,0 +1,440 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, TimerKind};
use crate::{
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, Px, PxVec2, RegionAlign, UiColor,
util::Vec2,
};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
};
use std::{
collections::VecDeque,
hash::{DefaultHasher, Hash, Hasher},
};
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
zeno::{Format, Vector},
};
pub struct TextData {
pub font_ctx: FontContext,
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 {
font_ctx: FontContext::new(),
layout_ctx: LayoutContext::new(),
scale_ctx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
spare: VecDeque::new(),
}
}
}
#[derive(Clone, PartialEq)]
pub enum Family {
SansSerif,
Serif,
Monospace,
Named(String),
}
impl Family {
fn family(&self) -> FontFamily<'_> {
let name = match self {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
}
}
#[derive(Clone, PartialEq)]
pub struct TextAttrs {
pub color: UiColor,
pub font_size: f32,
pub line_height: f32,
pub family: Family,
pub wrap: bool,
pub align: RegionAlign,
}
pub const LINE_HEIGHT_MULT: f32 = 1.1;
impl Default for TextAttrs {
fn default() -> Self {
let size = 16.0;
Self {
color: UiColor::WHITE,
font_size: size,
line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif,
wrap: false,
align: Align::CENTER_LEFT,
}
}
}
/// 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)]
struct LayoutKey {
attrs: TextAttrs,
max_width: Option<f32>,
}
impl TextBuffer {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
layout: Layout::new(),
layout_key: None,
placed: None,
}
}
pub fn new_empty() -> Self {
Self::new("")
}
pub fn text(&self) -> &str {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
&self.layout
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
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
}
/// Widths covered by the current line breaks, including a wider shaping
/// retained when a later draw requested a narrower box.
pub fn width_holds(&self) -> crate::Holds {
let Some(width) = self.wrap_width() else {
return crate::Holds::ANY;
};
let width = Px::from_f32(width);
let soft_wrapped = self.layout.lines().any(|line| {
matches!(
line.break_reason(),
parley::layout::BreakReason::Regular | parley::layout::BreakReason::Emergency
)
});
let upper = if soft_wrapped { width } else { Px::MAX };
crate::Holds::from(Px::ceil_from_f32(self.layout.width()).min(width)..=upper)
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
let layout_key = LayoutKey {
attrs: attrs.clone(),
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 work.
//
// At the longest line exactly, with no margin below it. A narrower
// width really does break differently, so answering one from the
// break in hand is how a warm tree keeps lines a cold tree would
// never produce. The margin was here because a text reports the
// width it used and a parent hands that back; the report is the step
// at or above its longest line now, so what comes back fits.
if let Some(key) = &self.layout_key
&& key.attrs == *attrs
&& let (Some(broke_at), Some(want)) = (key.max_width, width)
&& want <= broke_at
&& want >= 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);
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height,
)));
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());
}
}
impl TextData {
pub fn place(&mut self, buffer: &TextBuffer) -> Vec<PlacedGlyph> {
let mut placed = Vec::new();
for line in buffer.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
continue;
};
let font = run.run().font();
let font_size = run.run().font_size();
let coords = run.run().normalized_coords();
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else {
continue;
};
let coords_hash = hash_coords(coords);
let font_id = font.data.id();
for glyph in run.positioned_glyphs() {
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
let key = GlyphKey {
font: font_id,
glyph: glyph.id,
size: glyph_size_key(font_size),
subpixel,
coords: coords_hash,
};
let Some(entry) = self.glyph_entry(GlyphRaster {
key,
font: font_ref,
font_size,
coords,
subpixel,
glyph_id: glyph.id,
}) else {
continue;
};
placed.push(PlacedGlyph {
entry,
offset: PxVec2::new(
Px::from_int(glyph.x.floor() as i32 + entry.left),
Px::from_int(glyph.y.floor() as i32 - entry.top),
),
});
}
}
}
placed
}
fn glyph_entry(&mut self, glyph: GlyphRaster<'_>) -> Option<GlyphEntry> {
if let Some(entry) = self.atlas.get(&glyph.key) {
return entry;
}
let mut scaler = self
.scale_ctx
.builder(glyph.font)
.size(glyph.font_size)
.hint(true)
.normalized_coords(glyph.coords)
.build();
let image = Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(glyph.subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.glyph_id as u16);
if let Some(image) = image {
self.atlas.insert(glyph.key, &image)
} else {
self.atlas.insert_empty(glyph.key);
None
}
}
}
struct GlyphRaster<'a> {
key: GlyphKey,
font: FontRef<'a>,
font_size: f32,
coords: &'a [i16],
subpixel: u8,
glyph_id: u32,
}
fn hash_coords(coords: &[i16]) -> u64 {
let mut hasher = DefaultHasher::new();
coords.hash(&mut hasher);
hasher.finish()
}
const GLYPH_SIZE_STEPS_PER_PIXEL: f32 = 16.0;
fn glyph_size_key(font_size: f32) -> u32 {
(font_size * GLYPH_SIZE_STEPS_PER_PIXEL).round() as u32
}
pub struct RenderedText {
pub glyphs: Vec<PlacedGlyph>,
pub size: Vec2,
pub color: UiColor,
}
impl TextData {
/// 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: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> &'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);
// 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)
}
}
+161
View File
@@ -0,0 +1,161 @@
use crate::util::{RefCounter, Vec2};
use image::{DynamicImage, GenericImageView};
use std::{
ops::Index,
sync::mpsc::{Receiver, Sender, channel},
};
#[derive(Debug, Clone)]
pub struct TextureHandle {
slot: u32,
size: Vec2,
counter: RefCounter,
send: Sender<u32>,
}
/// a texture manager for a ui
/// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped
pub struct Textures {
free: Vec<u32>,
images: Vec<Option<DynamicImage>>,
updates: Vec<Update>,
send: Sender<u32>,
recv: Receiver<u32>,
}
pub enum TextureUpdate<'a> {
Push(&'a DynamicImage),
Set(u32, &'a DynamicImage),
Patch(u32, PatchRect, &'a DynamicImage),
Free(u32),
/// Added and freed before the renderer drained either update. It still has
/// to push a slot to stay lined up with `images`; `Free` then empties it.
PushFree,
SetFree,
}
#[derive(Debug, Clone, Copy)]
pub struct PatchRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
enum Update {
Push(u32),
Set(u32),
Patch(u32, PatchRect),
Free(u32),
}
impl Textures {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
free: Vec::new(),
images: Vec::new(),
updates: Vec::new(),
send,
recv,
}
}
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into();
let size = image.dimensions().into();
TextureHandle {
slot: self.push(image),
size,
counter: RefCounter::new(),
send: self.send.clone(),
}
}
fn push(&mut self, image: DynamicImage) -> u32 {
if let Some(i) = self.free.pop() {
self.images[i as usize] = Some(image);
self.updates.push(Update::Set(i));
i
} else {
let i = self.images.len() as u32;
self.images.push(Some(image));
self.updates.push(Update::Push(i));
i
}
}
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.slot as usize]
.as_mut()
.expect("texture was freed while still held")
}
/// Queue an upload of just `rect`, after writing it with `image_mut`.
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
self.updates.push(Update::Patch(handle.slot, rect));
}
/// How many textures are live, which is what a ui can ask; the renderer's
/// copies follow from the updates it drains.
pub fn count(&self) -> usize {
self.images.iter().flatten().count()
}
pub fn free(&mut self) {
for idx in self.recv.try_iter() {
self.images[idx as usize] = None;
self.updates.push(Update::Free(idx));
self.free.push(idx);
}
}
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
self.updates.drain(..).map(|u| match u {
Update::Push(i) => self.images[i as usize]
.as_ref()
.map(TextureUpdate::Push)
.unwrap_or(TextureUpdate::PushFree),
Update::Set(i) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Set(i, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Patch(i, rect) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Patch(i, rect, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Free(i) => TextureUpdate::Free(i),
})
}
}
impl TextureHandle {
/// Index into `Textures`, and into the renderer's parallel slots.
pub fn slot(&self) -> u32 {
self.slot
}
pub fn size(&self) -> Vec2 {
self.size
}
}
impl Drop for TextureHandle {
fn drop(&mut self) {
if self.counter.drop() {
let _ = self.send.send(self.slot);
}
}
}
impl Index<&TextureHandle> for Textures {
type Output = DynamicImage;
fn index(&self, index: &TextureHandle) -> &Self::Output {
self.images[index.slot as usize].as_ref().unwrap()
}
}
impl Default for Textures {
fn default() -> Self {
Self::new()
}
}
+247
View File
@@ -0,0 +1,247 @@
use crate::{
PatchRect, PxVec2,
util::{HashMap, Vec2},
};
use image::RgbaImage;
use swash::scale::image::{Content, Image};
/// Side of one page, and so of every layer of `render::page`'s array texture.
pub(crate) const PAGE: u32 = 1024;
/// Transparent margin kept around every glyph, so that sampling one cannot
/// pick up its neighbour along a shared edge.
const PAD: u32 = 1;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlyphKey {
pub font: u64,
pub glyph: u32,
/// Font size in 1/16 px, so sizes that round to the same pixels share a
/// raster instead of filling the atlas with near-duplicates.
pub size: u32,
/// Horizontal subpixel phase, in 1/4 px.
pub subpixel: u8,
/// Hash of the variation coordinates; a variable font at two weights is two
/// different sets of pixels from one glyph id.
pub coords: u64,
}
#[derive(Clone, Copy)]
pub struct GlyphEntry {
pub uv_min: Vec2,
pub uv_max: Vec2,
/// Offset from the glyph's pen position to the top-left of its pixels.
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
pub is_colored: bool,
/// Which atlas array layer this glyph is on.
pub layer: u32,
}
impl GlyphEntry {
const IS_COLORED: u32 = 1;
pub(crate) fn flags(&self) -> u32 {
if self.is_colored { Self::IS_COLORED } else { 0 }
}
}
struct Page {
image: RgbaImage,
x: u32,
y: u32,
shelf_height: u32,
}
/// A rectangle of one page the renderer has not uploaded yet.
#[derive(Clone, Copy)]
pub struct PageUpload {
pub layer: u32,
pub rect: PatchRect,
}
#[derive(Default)]
pub struct GlyphAtlas {
pages: Vec<Page>,
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
/// too, so it is not re-rasterised on every layout.
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
uploads: Vec<PageUpload>,
}
impl GlyphAtlas {
pub fn get(&self, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
self.entries.get(key).copied()
}
pub fn insert(&mut self, key: GlyphKey, image: &Image) -> Option<GlyphEntry> {
let w = image.placement.width;
let h = image.placement.height;
if w == 0 || h == 0 {
log::warn!(
"glyph {} in font {} rasterized at {w}x{h}; skipping it",
key.glyph,
key.font,
);
self.entries.insert(key, None);
return None;
}
if w > PAGE - PAD * 2 || h > PAGE - PAD * 2 {
log::warn!(
"glyph {} in font {} rasterized at {w}x{h}, too large for the {PAGE}x{PAGE} atlas; skipping it",
key.glyph,
key.font,
);
self.entries.insert(key, None);
return None;
}
let upload = self.allocate(w, h);
let PatchRect { x, y, .. } = upload.rect;
write_glyph(&mut self.pages[upload.layer as usize].image, image, x, y);
self.uploads.push(upload);
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
uv_min: Vec2::new(x as f32 * scale, y as f32 * scale),
uv_max: Vec2::new((x + w) as f32 * scale, (y + h) as f32 * scale),
left: image.placement.left,
top: image.placement.top,
width: w,
height: h,
is_colored: matches!(image.content, Content::Color),
layer: upload.layer,
};
self.entries.insert(key, Some(entry));
Some(entry)
}
/// Reserves room for a `w` by `h` glyph, adding a page if none has it.
fn allocate(&mut self, w: u32, h: u32) -> PageUpload {
let rect = |x, y| PatchRect {
x,
y,
width: w,
height: h,
};
if let Some((i, (x, y))) = self
.pages
.iter_mut()
.enumerate()
.find_map(|(i, page)| page.allocate(w, h).map(|position| (i, position)))
{
return PageUpload {
layer: i as u32,
rect: rect(x, y),
};
}
self.pages.push(Page {
image: RgbaImage::new(PAGE, PAGE),
x: PAD + w + PAD,
y: PAD,
shelf_height: h + PAD,
});
PageUpload {
layer: self.pages.len() as u32 - 1,
rect: rect(PAD, PAD),
}
}
/// Drains what has been written since the last call, for the renderer to
/// upload. A new page needs nothing more: wgpu leaves the rest of a fresh
/// layer transparent, which is what an atlas wants.
pub fn uploads(&mut self) -> impl Iterator<Item = (PageUpload, &RgbaImage)> {
let pages = &self.pages;
self.uploads
.drain(..)
.map(|upload| (upload, &pages[upload.layer as usize].image))
}
pub fn insert_empty(&mut self, key: GlyphKey) {
self.entries.insert(key, None);
}
pub fn page_count(&self) -> u32 {
self.pages.len() as u32
}
pub fn glyph_count(&self) -> usize {
self.entries.len()
}
}
impl Page {
fn allocate(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
let need_w = w + PAD;
let need_h = h + PAD;
if self.x + need_w > PAGE {
if need_w + PAD > PAGE || self.y + self.shelf_height + need_h > PAGE {
return None;
}
self.y += self.shelf_height;
self.x = PAD;
self.shelf_height = 0;
} else if self.y + need_h > PAGE {
return None;
}
let position = (self.x, self.y);
self.x += need_w;
self.shelf_height = self.shelf_height.max(need_h);
Some(position)
}
}
/// Mask glyphs keep coverage in alpha so their raster can be tinted at draw time.
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let width = image.placement.width as usize;
let height = image.placement.height as usize;
let page_stride = page.width() as usize * 4;
let x = x as usize * 4;
let y = y as usize;
let page = page.as_mut();
for row in 0..height {
let start = (y + row) * page_stride + x;
let target = &mut page[start..start + width * 4];
match image.content {
Content::Color => {
let start = row * width * 4;
target.copy_from_slice(&image.data[start..start + width * 4]);
}
Content::Mask => {
let start = row * width;
for (target, &alpha) in target
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(&image.data[start..start + width])
{
target.copy_from_slice(&[255, 255, 255, alpha]);
}
}
Content::SubpixelMask => {
let start = row * width * 4;
for (target, source) in target
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(image.data[start..start + width * 4].as_chunks::<4>().0)
{
target.copy_from_slice(&[255, 255, 255, source[1]]);
}
}
}
}
}
#[derive(Clone, Copy)]
pub struct PlacedGlyph {
pub entry: GlyphEntry,
/// Whole pixels from the origin of the text to this glyph's top-left,
/// on the grid once here rather than on every frame that draws it.
pub offset: PxVec2,
}
+90
View File
@@ -0,0 +1,90 @@
use crate::{UiRegion, util::Id, util::Vec2};
use wgpu::*;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct WindowUniform {
pub dim: Vec2,
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PrimitiveInstance {
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
// The region's four scalars, each a `Rel` beside a `Px`: whole counts
// that the shader decodes, rather than the numbers themselves.
const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![
0 => Sint32x2,
1 => Sint32x2,
2 => Sint32x2,
3 => Sint32x2,
4 => Uint32,
5 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as BufferAddress,
step_mode: VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
}
}
pub type MaskIdx = Id<u32>;
impl MaskIdx {
pub const NONE: Self = Self::preset(u32::MAX);
}
#[repr(C)]
#[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 }
}
}
+447
View File
@@ -0,0 +1,447 @@
use crate::{
UiData, UiRenderState,
render::{data::PrimitiveInstance, util::ArrBuf},
util::{HashMap, Vec2},
};
use data::WindowUniform;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
};
mod atlas;
mod data;
mod page;
mod primitive;
mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
fn module_source(wgsl: &str) -> String {
// The steps come from the same constants the CPU counts in, rather than
// a second copy of them written into the shader: a grid the two disagree
// about puts every coordinate somewhere else.
format!(
"const PX_STEP: f32 = 1.0 / {}.0;\nconst REL_STEP: f32 = 1.0 / {}.0;\n{PRELUDE}\n{wgsl}",
1u32 << crate::PX_SHIFT,
1u32 << crate::REL_SHIFT,
)
}
pub struct UiRenderNode {
shared_layout: BindGroupLayout,
shared_group: BindGroup,
format: TextureFormat,
/// One per registered primitive, in id order.
primitives: Vec<PrimitivePipeline>,
layers: HashMap<usize, RenderLayer>,
active: Vec<usize>,
window_buffer: Buffer,
masks: ArrBuf<Mask>,
moves: ArrBuf<MoveOffset>,
}
struct RenderLayer {
/// One per registered primitive, `None` where this layer draws none.
primitives: Vec<Option<ListBuffers>>,
}
/// What draws one registered primitive.
struct PrimitivePipeline {
data_layout: BindGroupLayout,
pipeline: RenderPipeline,
render: Box<dyn PrimitiveRender>,
}
/// One list's vertex buffer and the data its shader reads.
struct ListBuffers {
instance: ArrBuf<PrimitiveInstance>,
data: ArrBuf<u8>,
group: Option<BindGroup>,
/// What the primitive asked to keep per instance, if anything.
bindings: Vec<u32>,
}
impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_bind_group(0, &self.shared_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
for (id, list) in layer.primitives.iter().enumerate() {
let Some(list) = list else { continue };
let Some(group) = &list.group else { continue };
let primitive = &self.primitives[id];
pass.set_pipeline(&primitive.pipeline);
pass.set_bind_group(1, group, &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
primitive.render.draw(
pass,
ListDraw {
instances: list.instance.len() as u32,
bindings: &list.bindings,
},
);
}
}
}
pub fn update(
&mut self,
device: &Device,
queue: &Queue,
ui: &mut UiData,
ui_render: &mut UiRenderState,
) {
// Before the layers: each list is given its pipeline's data layout.
self.build_pipelines(device, queue, &ui.primitives);
self.active.clear();
for (i, draws) in ui_render.layers.iter_mut() {
self.active.push(i);
for change in draws.apply_free() {
if let Some(inst) = ui_render.active.get_mut(&change.id) {
for primitive in &mut inst.primitives {
let h = &mut primitive.handle;
if h.layer == i && h.kind == change.kind && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(RenderLayer::new);
if draws.updated {
let lists = draws.primitives();
// The zip would otherwise skip a list with no pipeline.
assert!(lists.len() <= self.primitives.len());
rlayer.primitives.resize_with(lists.len(), || None);
for ((buffers, list), primitive) in rlayer
.primitives
.iter_mut()
.zip(lists)
.zip(&self.primitives)
{
let Some(list) = list else {
continue;
};
buffers
.get_or_insert_with(|| ListBuffers::new(device))
.update(device, queue, primitive, list);
}
draws.updated = false;
}
}
for primitive in &mut self.primitives {
primitive.render.update(ui);
}
let mut regroup = false;
if ui.masks.changed {
ui.masks.changed = false;
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 { 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 {
dim: Vec2::new(config.width as f32, config.height as f32),
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
contents: bytemuck::cast_slice(&[window_uniform]),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
let shared_layout = Self::shared_layout(device);
let masks = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui 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,
shared_group,
format: config.format,
primitives: Vec::new(),
window_buffer,
layers: HashMap::default(),
active: Vec::new(),
masks,
moves,
}
}
/// Compiles a pipeline for every primitive registered since the last call.
/// Sources only ever arrive at the end, so an id keeps its pipeline.
fn build_pipelines(&mut self, device: &Device, queue: &Queue, registry: &PrimitiveRegistry) {
for source in &registry.sources()[self.primitives.len()..] {
let render = (source.render)(device, queue);
let data_layout = Self::data_layout(device, source.stride);
let mut groups = vec![Some(&self.shared_layout), Some(&data_layout)];
groups.extend(render.layout().map(Some));
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some(source.label),
bind_group_layouts: &groups,
immediate_size: 0,
});
let pipeline = Self::pipeline(device, &layout, self.format, source.wgsl, source.label);
self.primitives.push(PrimitivePipeline {
data_layout,
pipeline,
render,
});
}
}
fn pipeline(
device: &Device,
layout: &PipelineLayout,
format: TextureFormat,
wgsl: &str,
label: &str,
) -> RenderPipeline {
let module = device.create_shader_module(ShaderModuleDescriptor {
label: Some(label),
source: ShaderSource::Wgsl(module_source(wgsl).into()),
});
device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: VertexState {
module: &module,
entry_point: Some("vs_main"),
buffers: &[Some(PrimitiveInstance::desc())],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: &module,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format,
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: FrontFace::Cw,
cull_mode: Some(Face::Back),
polygon_mode: PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
})
}
/// 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: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<WindowUniform>() as u64),
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<Mask>() as u64),
},
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"),
})
}
fn shared_group(
device: &Device,
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
moves: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: window.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: moves.buffer.as_entire_binding(),
},
],
label: Some("ui shared"),
})
}
/// Layout for a list of one primitive's data. Every size in the ui is
/// stated, so "is the buffer big enough for one entry?" is answered when
/// the bind group is made; a `None` size is wgpu's to check on every draw.
fn data_layout(device: &Device, stride: u64) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(stride),
},
count: None,
}],
label: Some("ui primitive data"),
})
}
}
impl RenderLayer {
fn new() -> Self {
Self {
primitives: Vec::new(),
}
}
}
impl ListBuffers {
fn new(device: &Device) -> Self {
Self {
instance: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance",
),
data: ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"primitive data",
),
group: None,
bindings: Vec::new(),
}
}
fn update(
&mut self,
device: &Device,
queue: &Queue,
primitive: &PrimitivePipeline,
list: &InstanceList,
) {
self.bindings.clear();
primitive.render.instance_bindings(list, &mut self.bindings);
self.instance.update(device, queue, list.instances());
let resized = self.data.update(device, queue, list.data());
if list.instances().is_empty() {
self.group = None;
} else if resized || self.group.is_none() {
self.group = Some(device.create_bind_group(&BindGroupDescriptor {
layout: &primitive.data_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: self.data.buffer.as_entire_binding(),
}],
label: Some("ui primitive data"),
}));
}
}
}
#[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}");
}
}
+156
View File
@@ -0,0 +1,156 @@
use wgpu::*;
use crate::{GlyphAtlas, UiData};
use super::{
atlas::PAGE,
primitive::{ListDraw, PrimitiveRender},
texture::{default_sampler, sampled_group, sampled_layout, write_region},
};
/// Draws glyphs from the atlas, which it owns: one array texture bound once
/// for a whole list, since every glyph in it reads the same pages.
pub struct GlyphRender {
pages: GpuPages,
layout: BindGroupLayout,
sampler: Sampler,
}
impl GlyphRender {
pub fn new(device: &Device, queue: &Queue) -> Self {
let layout = sampled_layout(device, TextureViewDimension::D2Array, "ui atlas");
let sampler = default_sampler(device);
Self {
pages: GpuPages::new(device, queue, &layout, &sampler),
layout,
sampler,
}
}
}
impl PrimitiveRender for GlyphRender {
fn layout(&self) -> Option<&BindGroupLayout> {
Some(&self.layout)
}
fn update(&mut self, ui: &mut UiData) {
self.pages
.update(&mut ui.text.atlas, &self.layout, &self.sampler);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
pass.set_bind_group(2, self.pages.group(), &[]);
pass.draw(0..4, 0..list.instances);
}
}
/// The glyph atlas on the GPU: one array texture whose layers are the pages
/// `GlyphAtlas` packs.
///
/// One array rather than a texture per page because a layer index is ordinary
/// Vulkan 1.0 / GLES sampling, where a `binding_array` would need
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack.
pub struct GpuPages {
device: Device,
queue: Queue,
texture: Texture,
group: BindGroup,
}
impl GpuPages {
pub fn new(
device: &Device,
queue: &Queue,
layout: &BindGroupLayout,
sampler: &Sampler,
) -> Self {
let texture = create_array(device, 1);
Self {
device: device.clone(),
queue: queue.clone(),
group: atlas_group(device, layout, &texture, sampler),
texture,
}
}
pub fn update(&mut self, atlas: &mut GlyphAtlas, layout: &BindGroupLayout, sampler: &Sampler) {
if atlas.page_count() > self.texture.depth_or_array_layers() {
self.grow(atlas.page_count(), layout, sampler);
}
for (upload, page) in atlas.uploads() {
let dst = TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: Origin3d {
x: upload.rect.x,
y: upload.rect.y,
z: upload.layer,
},
aspect: TextureAspect::All,
};
write_region(&self.queue, dst, page, upload.rect);
}
}
pub fn group(&self) -> &BindGroup {
&self.group
}
/// Doubles until `needed` fits and copies the old layers across GPU side.
/// The new texture stales the group, so that is rebuilt here.
fn grow(&mut self, needed: u32, layout: &BindGroupLayout, sampler: &Sampler) {
let old = self.texture.depth_or_array_layers();
let mut layers = old;
while layers < needed {
layers *= 2;
}
let texture = create_array(&self.device, layers);
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("atlas grow"),
});
encoder.copy_texture_to_texture(
self.texture.as_image_copy(),
texture.as_image_copy(),
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: old,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
self.group = atlas_group(&self.device, layout, &texture, sampler);
self.texture = texture;
}
}
fn atlas_group(
device: &Device,
layout: &BindGroupLayout,
texture: &Texture,
sampler: &Sampler,
) -> BindGroup {
let view = texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
sampled_group(device, layout, &view, sampler, "ui atlas")
}
fn create_array(device: &Device, layers: u32) -> Texture {
device.create_texture(&TextureDescriptor {
label: Some("glyph atlas"),
size: Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::COPY_SRC,
view_formats: &[],
})
}
+401
View File
@@ -0,0 +1,401 @@
use std::{any::TypeId, marker::PhantomData};
use crate::{
Color, TextureHandle, UiData, UiRegion, WidgetId,
render::{
data::{MaskIdx, MoveIdx, PrimitiveInstance},
page::GlyphRender,
texture::ImageRender,
},
util::{HashMap, Vec2},
};
use bytemuck::Pod;
use wgpu::{BindGroupLayout, Device, Queue, RenderPass};
/// One instance of a primitive, laid out as the struct its shader reads.
///
/// The type carries its own shader, so drawing one is all the wiring it needs:
/// its list, free list, buffers and pipeline follow from being registered.
pub trait Primitive: Pod + 'static {
/// Compiled after `prelude.wgsl`, which states what it declares and what
/// it is given.
const WGSL: &'static str;
/// Made once, the first time the renderer sees this primitive. It owns
/// whatever the shader samples and records the primitive's own draws; the
/// default owns nothing and draws every instance in one call.
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender>
where
Self: Sized,
{
let _ = (device, queue);
Box::new(Instanced)
}
}
/// The renderer's half of a primitive: what it samples, what it uploads, and
/// what draws it records.
///
/// Everything a draw shares -- the pipeline, the window and masks, the list's
/// own data and instance buffer -- is set before this is called. What is left
/// is what only this primitive knows: its group 2, and how many draws its
/// instances are.
pub trait PrimitiveRender {
/// The layout its shader reads at group 2. `None` for a primitive whose
/// shader samples nothing, whose pipeline then has no group 2 at all.
fn layout(&self) -> Option<&BindGroupLayout> {
None
}
/// Uploads whatever this primitive owns, once a frame, before any draw.
fn update(&mut self, ui: &mut UiData) {
let _ = ui;
}
/// Keeps what the primitive needs per instance at draw time, read from
/// the list's own data. A primitive that binds nothing per instance --
/// most of them -- leaves this empty and draws in one call.
fn instance_bindings(&self, list: &InstanceList, out: &mut Vec<u32>) {
let _ = (list, out);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>);
}
/// What a `PrimitiveRender` draws: this list's instances, and whatever
/// `instance_bindings` kept for them.
pub struct ListDraw<'a> {
pub instances: u32,
pub bindings: &'a [u32],
}
/// The default: nothing sampled, every instance in one call.
pub struct Instanced;
impl PrimitiveRender for Instanced {
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
pass.draw(0..4, 0..list.instances);
}
}
/// Which registered primitive an instance is.
pub struct PrimitiveKind<P> {
id: u32,
_p: PhantomData<fn(P)>,
}
impl<P> PrimitiveKind<P> {
fn new(id: u32) -> Self {
Self {
id,
_p: PhantomData,
}
}
}
impl<P> Clone for PrimitiveKind<P> {
fn clone(&self) -> Self {
*self
}
}
impl<P> Copy for PrimitiveKind<P> {}
/// Every primitive a ui can draw, in the order they were first drawn.
#[derive(Default)]
pub struct PrimitiveRegistry {
kinds: Vec<PrimitiveSource>,
ids: HashMap<TypeId, u32>,
}
pub struct PrimitiveSource {
pub wgsl: &'static str,
pub label: &'static str,
/// Size of one instance's entry, stated as the data binding's minimum.
pub stride: u64,
pub render: fn(&Device, &Queue) -> Box<dyn PrimitiveRender>,
}
impl PrimitiveRegistry {
/// Registers `P` if this is the first time it has been drawn.
pub fn kind<P: Primitive>(&mut self) -> PrimitiveKind<P> {
let Self { kinds, ids } = self;
let id = *ids.entry(TypeId::of::<P>()).or_insert_with(|| {
kinds.push(PrimitiveSource {
wgsl: P::WGSL,
label: std::any::type_name::<P>(),
stride: size_of::<P>() as u64,
render: P::render,
});
kinds.len() as u32 - 1
});
PrimitiveKind::new(id)
}
pub fn sources(&self) -> &[PrimitiveSource] {
&self.kinds
}
}
/// One registered primitive's instances in one layer. Everything per-instance
/// rides here, so it stays in step through a `swap_remove`.
pub struct InstanceList {
instances: Vec<PrimitiveInstance>,
/// The widget each instance belongs to, for renumbering its handles.
assoc: Vec<WidgetId>,
free: Vec<usize>,
/// `stride` bytes of the primitive's own data per instance.
data: Vec<u8>,
/// From the type the list was made for, so a write is never checked.
stride: usize,
}
impl InstanceList {
fn new<P: Primitive>() -> Self {
Self {
instances: Vec::new(),
assoc: Vec::new(),
free: Vec::new(),
data: Vec::new(),
stride: size_of::<P>(),
}
}
pub fn instances(&self) -> &[PrimitiveInstance] {
&self.instances
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn stride(&self) -> usize {
self.stride
}
fn push(&mut self, id: WidgetId, inst: PrimitiveInstance, data: &[u8]) -> usize {
if let Some(i) = self.free.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
self.data[i * self.stride..][..self.stride].copy_from_slice(data);
i
} else {
let i = self.instances.len();
self.instances.push(inst);
self.assoc.push(id);
self.data.extend_from_slice(data);
i
}
}
fn free(&mut self, i: usize) -> MaskIdx {
self.free.push(i);
self.instances[i].mask_idx
}
fn apply_free(&mut self, kind: u32) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
let instances = &mut self.instances;
let assoc = &mut self.assoc;
let data = &mut self.data;
let stride = self.stride;
self.free.drain(..).filter_map(move |i| {
instances.swap_remove(i);
assoc.swap_remove(i);
let last = instances.len();
data.copy_within(last * stride..(last + 1) * stride, i * stride);
data.truncate(last * stride);
if i == last {
return None;
}
let id = assoc[i];
Some(PrimitiveChange {
id,
kind,
old: last,
new: i,
})
})
}
}
/// Everything one layer draws, one list per registered primitive.
pub struct LayerDraws {
/// `None` until this layer draws that primitive, because only the write
/// knows the type the list is for.
primitives: Vec<Option<InstanceList>>,
pub updated: bool,
}
impl Default for LayerDraws {
fn default() -> Self {
Self {
primitives: Vec::new(),
updated: true,
}
}
}
impl LayerDraws {
pub fn write<P: Primitive>(
&mut self,
layer: usize,
PrimitiveInst {
kind,
id,
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
// Grown on first use rather than sized from the registry, which a
// layer cannot see.
if self.primitives.len() <= kind.id as usize {
self.primitives.resize_with(kind.id as usize + 1, || None);
}
let inst_idx = self.primitives[kind.id as usize]
.get_or_insert_with(InstanceList::new::<P>)
.push(
id,
PrimitiveInstance {
region,
mask_idx,
move_idx,
},
bytemuck::bytes_of(&primitive),
);
PrimitiveHandle {
layer,
kind: kind.id,
inst_idx,
}
}
pub fn primitives(&self) -> &[Option<InstanceList>] {
&self.primitives
}
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
self.primitives
.iter_mut()
.enumerate()
.filter_map(|(kind, list)| Some((kind as u32, list.as_mut()?)))
.flat_map(|(kind, list)| list.apply_free(kind))
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
self.list(h).free(h.inst_idx)
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true;
&mut self.list(h).instances[h.inst_idx].region
}
/// A handle is only ever made by `write`, which is what created the list.
fn list(&mut self, h: &PrimitiveHandle) -> &mut InstanceList {
self.primitives[h.kind as usize]
.as_mut()
.expect("handle names a primitive this layer never drew")
}
}
pub struct PrimitiveInst<P> {
pub kind: PrimitiveKind<P>,
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
pub struct PrimitiveChange {
pub id: WidgetId,
/// Which registered primitive's list moved, since they index separately.
pub kind: u32,
pub old: usize,
pub new: usize,
}
#[derive(Debug)]
pub struct PrimitiveHandle {
pub layer: usize,
pub kind: u32,
pub inst_idx: usize,
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct RectPrimitive {
pub color: Color<u8>,
pub radius: f32,
pub thickness: f32,
pub inner_radius: f32,
}
impl Primitive for RectPrimitive {
const WGSL: &'static str = include_str!("shader/rect.wgsl");
}
impl RectPrimitive {
pub fn color(color: Color<u8>) -> Self {
Self {
color,
radius: 0.0,
thickness: 0.0,
inner_radius: 0.0,
}
}
}
/// `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)]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
pub uv_min: Vec2,
pub uv_max: Vec2,
/// Which atlas array layer this glyph is on.
pub layer: u32,
pub color: Color<u8>,
pub flags: u32,
}
// 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 {
const WGSL: &'static str = include_str!("shader/glyph.wgsl");
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender> {
Box::new(GlyphRender::new(device, queue))
}
}
/// One drawn image. Its shader reads nothing per instance; the slot names the
/// texture to bind for it.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct TexturePrimitive {
pub slot: u32,
}
impl Primitive for TexturePrimitive {
const WGSL: &'static str = include_str!("shader/texture.wgsl");
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender> {
Box::new(ImageRender::new(device, queue))
}
}
impl From<&TextureHandle> for TexturePrimitive {
fn from(handle: &TextureHandle) -> Self {
Self {
slot: handle.slot(),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
// Matches `GlyphEntry::IS_COLORED`.
const COLORED: u32 = 1u;
// The glyph atlas, whose array layers are its pages.
@group(2) @binding(0)
var atlas: texture_2d_array<f32>;
@group(2) @binding(1)
var samp: sampler;
struct GlyphInfo {
uv_min: vec2<f32>,
uv_max: vec2<f32>,
// Which layer of the atlas array this glyph's page is.
layer: u32,
color: u32,
flags: u32,
}
@group(1) @binding(0)
var<storage> glyphs: array<GlyphInfo>;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let g = glyphs[in.idx];
let uv = mix(g.uv_min, g.uv_max, in.uv);
let texel = textureSample(atlas, samp, uv, i32(g.layer));
if (g.flags & COLORED) != 0u {
return masked(in, texel);
}
var color = unpack4x8unorm(g.color);
color.a *= texel.a;
return masked(in, color);
}
+193
View File
@@ -0,0 +1,193 @@
// Prepended to every primitive's shader, which declares its own instance data
// as `var<storage> <name>: array<T>` at group 1 binding 0, and an `fs_main`
// shading one instance of it. What it samples, if anything, is bound at group
// 2: the texture at binding 0 and the sampler at binding 1.
@group(0) @binding(0)
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>,
};
struct Mask {
x: RawSpan,
y: RawSpan,
move_idx: u32,
}
struct MoveOffset {
x: RawSpan,
y: RawSpan,
parent: u32,
}
// `PX_STEP` and `REL_STEP` are prepended from `iris_core`'s own constants:
// what it stores is a whole count of each, both powers of two, so decoding
// is exact and the number here is the number the CPU decided.
// Every coordinate the CPU decided is a whole count of `PX_STEP`, so one that
// composes to within half a step of a pixel boundary is on that boundary and
// belongs to the pixel above it. Flooring the product instead drops a pixel
// wherever a fraction divides a window exactly: a fifth of 1920 comes out of
// `REL_STEP` as 383.99998, and five tabs each lose their last column.
//
// Taken over the whole coordinate, fraction and pixels summed, since a floor
// does not distribute over a sum: floored apart, a half of one and a half of
// the other lose the pixel the two together make.
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + PX_STEP * 0.5);
}
struct RawScalar {
rel: i32,
px: i32,
}
struct RawSpan {
start: RawScalar,
end: RawScalar,
}
fn scalar_of(raw: RawScalar) -> Len {
return Len(f32(raw.rel) * REL_STEP, f32(raw.px) * PX_STEP);
}
fn span_of(raw: RawSpan) -> UiSpan {
return UiSpan(scalar_of(raw.start), scalar_of(raw.end));
}
fn scalar_of_pair(raw: vec2<i32>) -> Len {
return Len(f32(raw.x) * REL_STEP, f32(raw.y) * PX_STEP);
}
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;
// The same expression `Len::within` uses, in floats rather than on the
// CPU's grid: a move is resolved here so that scrolling a subtree writes one
// entry instead of walking it. What has to hold is that this agrees with
// itself frame to frame, not that it matches the CPU to the last bit.
fn scalar_within(s: Len, p: UiSpan) -> Len {
return Len(
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, span_of(entry.x)), span_within(r.y, span_of(entry.y)));
at = entry.parent;
}
return r;
}
struct UiSpan {
start: Len,
end: Len,
}
struct Len {
rel: f32,
px: f32,
}
struct InstanceInput {
@location(0) x_start: vec2<i32>,
@location(1) x_end: vec2<i32>,
@location(2) y_start: vec2<i32>,
@location(3) y_end: vec2<i32>,
@location(4) mask_idx: u32,
@location(5) move_idx: u32,
}
struct VertexOutput {
@location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>,
@location(3) @interpolate(flat) mask_idx: u32,
@location(4) @interpolate(flat) idx: u32,
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
in: InstanceInput,
) -> VertexOutput {
var out: VertexOutput;
let local = Region(
UiSpan(scalar_of_pair(in.x_start), scalar_of_pair(in.x_end)),
UiSpan(scalar_of_pair(in.y_start), scalar_of_pair(in.y_end)),
);
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 = snap_floor(top_left_rel * window.dim + top_left_px);
let bot_right = snap_floor(bot_right_rel * window.dim + bot_right_px);
let size = bot_right - top_left;
let uv = vec2<f32>(
f32(vi % 2u),
f32(vi / 2u)
);
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = uv;
out.top_left = top_left;
out.bot_right = bot_right;
out.mask_idx = in.mask_idx;
out.idx = ii;
return out;
}
fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
if in.mask_idx == 4294967295u {
return color;
}
let mask = masks[in.mask_idx];
// 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(span_of(mask.x), span_of(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 = snap_floor(tl * window.dim + tl_px);
let bot_right = snap_floor(br * window.dim + br_px);
let pos = in.clip_position.xy;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
return color * 0.0;
}
return color;
}
+39
View File
@@ -0,0 +1,39 @@
struct Rect {
color: u32,
radius: f32,
thickness: f32,
inner_radius: f32,
}
@group(1) @binding(0)
var<storage> rects: array<Rect>;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let rect = rects[in.idx];
var color = unpack4x8unorm(rect.color);
let edge = 0.5;
let size = in.bot_right - in.top_left;
let corner = size / 2.0;
let center = in.top_left + corner;
let pos = in.clip_position.xy;
let dist = distance_from_rect(pos, center, corner, rect.radius);
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
if rect.thickness > 0.0 {
let dist2 = distance_from_rect(pos, center, corner - rect.thickness, rect.inner_radius);
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
}
return masked(in, color);
}
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 {
// vec from center to pixel
let p = pixel_pos - rect_center;
// vec from inner rect corner to pixel
let q = abs(p) - (rect_corner - radius);
return length(max(q, vec2(0.0))) - radius;
}
+10
View File
@@ -0,0 +1,10 @@
// The image this instance draws, bound for it alone.
@group(2) @binding(0)
var image: texture_2d<f32>;
@group(2) @binding(1)
var samp: sampler;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return masked(in, textureSample(image, samp, in.uv));
}
+243
View File
@@ -0,0 +1,243 @@
use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage};
use wgpu::{util::DeviceExt, *};
use crate::{
PatchRect, TextureUpdate, Textures, UiData,
render::{
TexturePrimitive,
primitive::{ListDraw, PrimitiveRender},
},
};
/// Draws standalone images, which it owns. Each is its own texture, so each
/// instance binds its own and is a draw of its own.
pub struct ImageRender {
textures: GpuTextures,
layout: BindGroupLayout,
sampler: Sampler,
}
impl ImageRender {
pub fn new(device: &Device, queue: &Queue) -> Self {
Self {
textures: GpuTextures::new(device, queue),
layout: sampled_layout(device, TextureViewDimension::D2, "ui image"),
sampler: default_sampler(device),
}
}
}
impl PrimitiveRender for ImageRender {
fn layout(&self) -> Option<&BindGroupLayout> {
Some(&self.layout)
}
fn update(&mut self, ui: &mut UiData) {
self.textures
.update(&mut ui.textures, &self.layout, &self.sampler);
}
fn instance_bindings(&self, list: &super::InstanceList, out: &mut Vec<u32>) {
let slots = list
.data()
.chunks_exact(list.stride())
.map(|data| bytemuck::pod_read_unaligned::<TexturePrimitive>(data).slot);
out.extend(slots);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
for (i, &slot) in list.bindings.iter().enumerate() {
let Some(image) = self.textures.group(slot) else {
continue;
};
pass.set_bind_group(2, image, &[]);
pass.draw(0..4, i as u32..i as u32 + 1);
}
}
}
/// The standalone images a ui draws, each its own texture and bind group --
/// unlike the glyph atlas in `super::page`, which is one array they share.
pub struct GpuTextures {
device: Device,
queue: Queue,
slots: Vec<Option<ImageGpu>>,
}
struct ImageGpu {
/// Kept for `patch`, which needs the texture rather than the view.
texture: Texture,
group: BindGroup,
}
impl GpuTextures {
pub fn new(device: &Device, queue: &Queue) -> Self {
Self {
device: device.clone(),
queue: queue.clone(),
slots: Vec::new(),
}
}
pub fn update(&mut self, textures: &mut Textures, layout: &BindGroupLayout, sampler: &Sampler) {
for update in textures.updates() {
match update {
TextureUpdate::Push(image) => {
let image = self.create(image, layout, sampler);
self.slots.push(Some(image));
}
TextureUpdate::Set(i, image) => {
let image = self.create(image, layout, sampler);
self.slots[i as usize] = Some(image);
}
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
TextureUpdate::PushFree => self.slots.push(None),
TextureUpdate::SetFree => {}
TextureUpdate::Free(i) => self.slots[i as usize] = None,
}
}
}
pub fn group(&self, slot: u32) -> Option<&BindGroup> {
self.slots.get(slot as usize)?.as_ref().map(|i| &i.group)
}
fn create(
&self,
image: &DynamicImage,
layout: &BindGroupLayout,
sampler: &Sampler,
) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data(
&self.queue,
&TextureDescriptor {
label: Some("image"),
size: Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
},
wgt::TextureDataOrder::MipMajor,
rgba.as_bytes(),
);
let view = texture.create_view(&TextureViewDescriptor::default());
let group = sampled_group(&self.device, layout, &view, sampler, "ui image");
ImageGpu { texture, group }
}
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
let Some(Some(slot)) = self.slots.get(i as usize) else {
return;
};
let dst = TexelCopyTextureInfo {
texture: &slot.texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: 0,
},
aspect: TextureAspect::All,
};
match image.as_rgba8() {
Some(rgba) => write_region(&self.queue, dst, rgba, rect),
// The texture is rgba8, so any other layout has to be converted --
// and converting the rectangle is cheaper than the whole image.
None => {
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
write_region(&self.queue, dst, &sub, PatchRect { x: 0, y: 0, ..rect });
}
}
}
}
pub fn write_region(queue: &Queue, dst: TexelCopyTextureInfo, src: &RgbaImage, rect: PatchRect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let stride = src.width() * 4;
queue.write_texture(
dst,
src.as_bytes(),
TexelCopyBufferLayout {
offset: (rect.y * stride + rect.x * 4) as u64,
bytes_per_row: Some(stride),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
/// What a primitive that samples binds: a texture, and the sampler that reads
/// it.
pub fn sampled_group(
device: &Device,
layout: &BindGroupLayout,
view: &TextureView,
sampler: &Sampler,
label: &'static str,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::Sampler(sampler),
},
],
label: Some(label),
})
}
/// The layout for one of those. The dimension differs -- the atlas is an
/// array of pages and an image is not -- and nothing else does.
pub fn sampled_layout(
device: &Device,
dimension: TextureViewDimension,
label: &'static str,
) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: dimension,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some(label),
})
}
pub fn default_sampler(device: &Device) -> Sampler {
device.create_sampler(&SamplerDescriptor::default())
}
@@ -21,18 +21,26 @@ impl<T: Pod> ArrBuf<T> {
_pd: PhantomData,
}
}
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) {
if self.len != data.len() {
/// Returns whether the `Buffer` was recreated, which stales any cached
/// `BindGroup` holding it.
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool {
let resized = self.len != data.len();
if resized {
self.len = data.len();
self.buffer =
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
}
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
resized
}
pub fn len(&self) -> usize {
self.len
}
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
let mut size = size as u64;
if usage.contains(BufferUsages::STORAGE) {
size = size.max(1);
// A binding cannot be empty or under the layout's minimum.
size = size.max(std::mem::size_of::<T>() as u64);
}
device.create_buffer(&BufferDescriptor {
label: Some(label),
@@ -41,8 +49,4 @@ impl<T: Pod> ArrBuf<T> {
usage,
})
}
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.len
}
}
+87
View File
@@ -0,0 +1,87 @@
use crate::{
Declared, LayerId, LayoutHolds, MaskIdx, MoveIdx, PlaceDesc, RegionAlign, RetainedPrimitive,
Size, TextureHandle, UiRegion, UiVec2, WidgetId,
};
/// What is kept of a widget its parent has asked about. `drawn` says whether
/// it currently draws; one that does not is kept so that a change to it, or
/// under it, still reaches whoever asked.
#[derive(Debug)]
pub struct ActiveData {
pub id: WidgetId,
/// Where its drawing goes, in its region node's coordinates.
pub placement: UiRegion,
/// What a fraction declared or reported under this widget is a fraction
/// of, as a length of the window.
pub rel_base: UiVec2,
/// Where its drawing was put, and where it was asked. The two differ
/// where a container asks in one place and puts the answer in another --
/// a row measures from its cursor and puts the child in its slot. Each
/// carries the rel base that ask stated, so asking again from either is
/// the same question it was.
pub placed: PlaceDesc,
pub asked: PlaceDesc,
/// The box it was asked in, in the parent's region-node coordinates: the
/// box its drawing was made in and the one its contract is about. Its
/// drawing is placed elsewhere by re-expression, never by asking again.
pub region: UiRegion,
/// The measured answer and its dependencies. A hint-only dependency or
/// a widget first encountered during placement has no measurement yet.
pub answer: Option<(Size, LayoutHolds)>,
/// Asked more than once in its parent's last draw -- measured in one box
/// and then asked in the one the parent decided. The parent's layout
/// rests on the first answer and its drawing on the last, so only the
/// parent can ask either again.
pub re_asked: bool,
/// What the widget reported, in window-unit lengths.
pub size: Size,
/// The window and region reads that this drawing holds for, and the
/// rel base and region it pinned.
pub holds: LayoutHolds,
pub drawn: bool,
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>,
/// Its primitives, each keeping the box it was written in -- in this
/// widget's placement coordinates, which is what a move recomposes from.
pub primitives: Vec<RetainedPrimitive>,
/// An owned mask holds one reference independently of its primitives.
pub mask_region: Option<UiRegion>,
pub children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub size_deps: Vec<WidgetId>,
/// The movable region its primitives are positioned through: its own when
/// opted in, otherwise the nearest ancestor's.
pub move_idx: MoveIdx,
/// The declared lengths whoever drew this widget resolved into its rel base.
/// A change to one moves a box this widget cannot fix by drawing again,
/// and comparing them is what says so.
pub declared: Declared,
/// Its alignment when it was last drawn, which a change to the property
/// is found against.
pub own_align: RegionAlign,
/// The movable region whose coordinates its placement is in when this
/// widget does not own a region node.
pub parent_move: MoveIdx,
/// The mask its drawing is clipped to: one it set itself, or the one it
/// inherited from whoever drew it.
pub mask: MaskIdx,
/// That inherited one. The two differ exactly where the widget set a
/// mask of its own, which is the one it owns and the one a move rewrites
/// -- and the one a redraw of it must not be handed back, since setting
/// a mask asserts there is none.
pub parent_mask: MaskIdx,
pub layer: LayerId,
}
impl ActiveData {
/// What it answered when its parent asked, where it has been asked at
/// all. Not `size`, which is what its last drawing reported: a drawing
/// re-expressed in the box that answer chose is not a second answer.
pub fn measured(&self) -> Option<Size> {
self.answer.map(|(size, _)| size)
}
}
+184
View File
@@ -0,0 +1,184 @@
use crate::{Len, Px, REL_SHIFT, fixed::div_toward, fixed::narrow};
use std::ops::RangeInclusive;
/// The lengths of a box, in pixels, that one drawing of a widget holds for:
/// give the widget any box in this range and it draws the same thing and
/// reports the same size. A widget that never reads its box in pixels holds
/// for every length; one that does holds for the one it read unless it says
/// otherwise, and a parent holds for whatever keeps every child it asked
/// about or drew inside its own range.
///
/// The ends are lengths on the grid rather than floats with a tolerance
/// around them: a box offered back at the length a widget reported comes back
/// as the same number, so a range means what it says. The one place a range
/// is wider than the length it came from is [`Self::through`], and what it is
/// wider by is the floor that inverting a fraction undoes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Holds {
pub lo: Px,
pub hi: Px,
}
impl Holds {
pub const ANY: Self = Self {
lo: Px::MIN,
hi: Px::MAX,
};
pub const fn at(len: Px) -> Self {
Self { lo: len, hi: len }
}
pub const fn contains(&self, len: Px) -> bool {
len.raw() >= self.lo.raw() && len.raw() <= self.hi.raw()
}
/// Every length `other` holds for is one this holds for, so a drawing
/// made under this range is still good wherever `other` is.
pub const fn covers(self, other: Self) -> bool {
self.lo.raw() <= other.lo.raw() && self.hi.raw() >= other.hi.raw()
}
pub const fn and(self, other: Self) -> Self {
Self {
lo: self.lo.max(other.lo),
hi: self.hi.min(other.hi),
}
}
/// What a box has to be for a part of it, `len` of the box long, to stay
/// in this range: the exact preimage of `px + floor(rel * box)`, which is
/// the one way a box in pixels is reached. A part with no relative extent
/// is a fixed length -- it was drawn at that length and any box keeps it
/// there.
///
/// The answer is an interval even where this range is a single length,
/// because the multiply on the way in drops to the step below and many
/// boxes therefore give one length. That is a floor rather than an
/// allowance: inverting it is two divisions and nothing else, and the
/// whole of a box maps back to itself.
pub const fn through(self, len: Len) -> Self {
if self.lo.raw() == Px::MIN.raw() && self.hi.raw() == Px::MAX.raw() {
return Self::ANY;
}
let rel = len.rel.raw() as i64;
if rel == 0 {
return Self::ANY;
}
let px = len.px.raw() as i64;
// `floor(rel * box) >= lo - px` is `rel * box >= (lo - px) << REL`, and
// `floor(rel * box) <= hi - px` is `rel * box < (hi - px + 1) << REL`.
let lo = (self.lo.raw() as i64 - px) << REL_SHIFT;
let hi = (((self.hi.raw() as i64 - px) + 1) << REL_SHIFT) - 1;
// Dividing by a negative fraction turns the ends around, so which
// bound each comes from is decided before dividing rather than by
// taking the min and max of four divisions.
match rel > 0 {
true => Self::raws(div_toward(lo, rel, true), div_toward(hi, rel, false)),
false => Self::raws(div_toward(hi, rel, true), div_toward(lo, rel, false)),
}
}
const fn raws(lo: i64, hi: i64) -> Self {
Self {
lo: Px::from_raw(narrow(lo)),
hi: Px::from_raw(narrow(hi)),
}
}
}
impl From<RangeInclusive<Px>> for Holds {
fn from(range: RangeInclusive<Px>) -> Self {
Self {
lo: *range.start(),
hi: *range.end(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rel;
#[test]
fn an_unrestricted_range_stays_unrestricted_through_any_length() {
for rel in [-2.0, -0.5, 0.0, 0.5, 1.0, 2.0] {
for px in [-8, 0, 8] {
let len = Len::from_parts(Rel::from_f32(rel), Px::from_int(px));
assert_eq!(Holds::ANY.through(len), Holds::ANY);
}
}
}
#[test]
fn through_reverses_a_range_for_a_negative_fraction() {
// `10 - box / 2` is between 20 and 40 for boxes from -60 to -20.
let part = Len::from_parts(Rel::from_f32(-0.5), Px::from_int(10));
let holds = Holds::from(Px::from_int(20)..=Px::from_int(40)).through(part);
assert!(holds.contains(Px::from_int(-60)) && holds.contains(Px::from_int(-20)));
assert!(!holds.contains(Px::from_int(-61)) && !holds.contains(Px::from_int(-19)));
}
/// The case the widening is for: a part that holds only for the length it
/// was drawn at has to hold for the box it was drawn in, and a third of a
/// box is not a whole number of steps.
#[test]
fn a_part_maps_back_onto_the_box_it_was_measured_in() {
let part = Len::from_parts(Rel::from_f32(1.0 / 3.0), Px::from_int(-146));
for box_len in (440..460).map(Px::from_int) {
let holds = Holds::at(part.to_px(box_len)).through(part);
assert!(holds.contains(box_len), "{box_len:?} left out by {holds:?}");
}
}
/// A widget handed the whole of its parent's box, with or without pixels
/// taken off it, has no fraction to invert: multiplying by one is exact
/// and taking the pixels off again is too, so the box maps back to
/// itself. Allowing for anything here compounded a step a level down a
/// chain of widgets each taking the whole of its parent.
#[test]
fn the_whole_of_a_box_maps_back_to_itself() {
let at = Px::from_int(956);
assert_eq!(Holds::at(at).through(Len::FULL), Holds::at(at));
let less_eight = Len::from_parts(Rel::ONE, Px::from_int(-8));
assert_eq!(
Holds::at(at).through(less_eight),
Holds::at(at + Px::from_int(8))
);
}
/// The range is the exact preimage at both ends, so a box one step
/// outside it really does give a length outside this range. What a wider
/// range costs is a drawing reused where it does not hold.
#[test]
fn a_box_one_step_outside_the_range_is_outside_it() {
let part = Len::from_parts(Rel::from_f32(1.0 / 3.0), Px::from_int(-146));
let at = Px::from_int(300);
let holds = Holds::at(at).through(part);
for inside in [holds.lo, holds.hi] {
assert_eq!(part.to_px(inside), at, "{inside:?} left out of {holds:?}");
}
for outside in [holds.lo.next_down(), holds.hi.next_up()] {
assert_ne!(part.to_px(outside), at, "{outside:?} admitted by {holds:?}");
}
}
/// A truncating multiply only ever drops, so the step it needs allowing
/// for on the way in belongs at the top of the range and not the bottom.
#[test]
fn a_fraction_widens_further_up_than_down() {
let half = Len::from_parts(Rel::from_f32(0.5), Px::ZERO);
let holds = Holds::at(Px::from_int(100)).through(half);
let box_len = Px::from_int(200);
assert!(holds.hi - box_len > box_len - holds.lo, "{holds:?}");
}
#[test]
fn a_boundary_the_next_step_along_does_not_admit_it() {
let boundary = Px::from_int(10);
let above = Holds::from(boundary.next_up()..=Px::MAX);
assert!(!above.contains(boundary));
assert!(above.contains(boundary.next_up()));
}
}
+109
View File
@@ -0,0 +1,109 @@
use crate::util::impl_axis_index;
use crate::{Axis, Holds, Len, Px, PxVec2, UiRegion, UiVec2};
const AXES: [Axis; 2] = [Axis::X, Axis::Y];
/// What one evaluation of a widget depends on along one axis: the window
/// lengths its reads hold for, the pixel lengths of its own box, and the
/// symbolic lengths of that box and of its rel base where either one is what
/// it was expressed in.
///
/// The symbolic lengths are pins rather than ranges: a container places its
/// children as lengths of its rel base measured from where its own box starts,
/// so what it draws turns on that box's length and on nothing about where it
/// is. A box pin reaches the parent only where the box it pinned is the
/// parent's own; anywhere else the parent chose that length itself, and a
/// widget pinned this way is checked when it is re-placed.
///
/// A rel base pin says the answer or the drawing is a fraction of the rel base,
/// which is a different length wherever the rel base is a different one -- at
/// the same window size, so no range of window pixels can say it. A length
/// of the rel base that is only pixels is not one: it is that many pixels
/// whatever the rel base turns out to be.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AxisHolds {
pub window: Holds,
pub rel_base: Option<Len>,
pub region: Holds,
pub region_len: Option<Len>,
}
impl AxisHolds {
pub const ANY: Self = Self {
window: Holds::ANY,
rel_base: None,
region: Holds::ANY,
region_len: None,
};
pub fn and(self, other: Self) -> Self {
// Two pins of the same length disagreeing would mean one drawing was
// a fraction of two different lengths at once.
debug_assert!(
self.region_len.is_none()
|| other.region_len.is_none()
|| self.region_len == other.region_len
);
debug_assert!(
self.rel_base.is_none() || other.rel_base.is_none() || self.rel_base == other.rel_base
);
Self {
window: self.window.and(other.window),
rel_base: self.rel_base.or(other.rel_base),
region: self.region.and(other.region),
region_len: self.region_len.or(other.region_len),
}
}
pub fn covers(self, other: Self) -> bool {
self.window.covers(other.window)
&& self.region.covers(other.region)
&& self
.region_len
.is_none_or(|len| other.region_len == Some(len))
&& self.rel_base.is_none_or(|len| other.rel_base == Some(len))
}
/// Whether a widget in a box `len` long, with that rel base, in that
/// window, is one this drawing holds for.
pub fn contains(self, window: Px, rel_base: Len, len: Len) -> bool {
self.window.contains(window)
&& self.rel_base.is_none_or(|pinned| pinned == rel_base)
&& self.region.contains(len.to_px(window))
&& self.region_len.is_none_or(|pinned| pinned == len)
}
}
/// [`AxisHolds`] on both axes. Every question asked of it is asked of one
/// axis at a time, since a widget that read one length holds for any length
/// of the other.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LayoutHolds {
pub x: AxisHolds,
pub y: AxisHolds,
}
impl LayoutHolds {
pub const ANY: Self = Self {
x: AxisHolds::ANY,
y: AxisHolds::ANY,
};
pub fn and(self, other: Self) -> Self {
Self {
x: self.x.and(other.x),
y: self.y.and(other.y),
}
}
pub fn covers(self, other: Self) -> bool {
self.x.covers(other.x) && self.y.covers(other.y)
}
pub fn contains(self, window: PxVec2, rel_base: UiVec2, region: UiRegion) -> bool {
AXES.into_iter()
.all(|axis| self[axis].contains(window[axis], rel_base[axis], region[axis].len()))
}
}
impl_axis_index!(LayoutHolds => AxisHolds);
+149
View File
@@ -0,0 +1,149 @@
use crate::{
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 holds;
mod layout_holds;
mod painter;
mod place;
mod render_state;
pub use active::*;
pub use holds::*;
pub use layout_holds::*;
pub use painter::{Painter, PrimitiveLike};
pub use place::*;
pub use render_state::*;
#[derive(Default)]
pub struct UiData {
pub widgets: Widgets,
/// Every primitive this ui can draw.
pub primitives: PrimitiveRegistry,
pub textures: Textures,
pub text: TextData,
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;
}
}
/// The same walk the vertex shader does, in the same `Len` the shader is
/// handed, for asking where a drawing will actually land -- hit testing,
/// and nothing layout decides on. Layout threads its lengths down the
/// draw instead, so no box it compares is composed back up this chain.
pub fn resolve(&self, idx: MoveIdx, local: UiRegion) -> UiRegion {
let mut region = local;
self.walk(idx, |entry| region = region.within(entry));
region
}
fn walk(&self, idx: MoveIdx, mut step: impl FnMut(&UiRegion)) {
let mut at = idx;
for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE {
return;
}
let entry = &self.arena[at.idx()];
step(&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"
);
}
/// 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;
#[allow(unused_variables)]
fn on_add(&mut self, id: WeakWidget) {}
#[allow(unused_variables)]
fn on_remove(&mut self, id: WidgetId) {}
#[allow(unused_variables)]
fn on_draw(&mut self, active: &ActiveData) {}
#[allow(unused_variables)]
fn on_undraw(&mut self, active: &ActiveData) {}
fn widgets(&self) -> &Widgets {
&self.ui().widgets
}
fn widgets_mut(&mut self) -> &mut Widgets {
&mut self.ui_mut().widgets
}
fn free(&mut self) {
while let Some(id) = self.widgets_mut().free_next() {
self.on_remove(id);
}
self.ui_mut().textures.free();
}
}
+742
View File
@@ -0,0 +1,742 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Declared, Holds, LayoutHolds, LayoutLen, Len, PlaceDesc, Px, PxVec2, RegionAlign, Rel,
RenderedText, RetainedPrimitive, Size, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiVec2, Weight, WidgetId, Widgets,
render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveInst, PrimitiveKind,
TexturePrimitive,
},
ui::render_state::{DrawInfo, Placing},
};
const AXES: [Axis; 2] = [Axis::X, Axis::Y];
/// makes your surfaces look pretty
pub struct Painter<'a> {
pub(super) state: &'a mut UiRenderState,
pub(super) rsc: &'a mut dyn UiRsc,
/// This widget's rel base, per axis: a length of the window, and what a
/// fraction it or anything under it declares or reports is a fraction
/// of. A length rather than a box, so padding can take from both the
/// rel base and the box without either becoming the other.
pub(super) rel_base: UiVec2,
/// The box this widget was asked in, in its region node's coordinates:
/// what it draws in, and what its children's places are parts of.
pub(super) region: UiRegion,
/// The window in pixels. Frames and boxes become pixels against this one
/// unit, regardless of region-node boundaries.
pub(super) window: PxVec2,
pub(super) mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<RetainedPrimitive>,
pub(super) mask_region: Option<UiRegion>,
/// The previous drawing's owned mask, available for this draw to reclaim.
pub(super) mask_slot: Option<MaskIdx>,
/// Only children whose answers were read constrain this widget's answer.
pub(super) answer_under: LayoutHolds,
pub(super) children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
/// What this draw itself reads, as against what its children's drawings
/// hold for: every window and every length of its own region until it
/// reads one, then that one unless it says otherwise, and the rel base or
/// region length it read symbolically, each of which makes the drawing
/// hold for that length alone.
pub(super) own: LayoutHolds,
/// What each child's drawing depends on. Asking a child again replaces
/// its drawing, so it replaces this too rather than narrowing it.
pub(super) under: Vec<(WidgetId, LayoutHolds)>,
/// The movable region this widget's primitives are positioned through:
/// its own when opted in, otherwise the nearest ancestor's.
pub(super) move_idx: MoveIdx,
pub layer: usize,
/// The layer this widget was entered on, which its children's layers are
/// counted from however far `layer` has walked.
pub(super) own_layer: usize,
pub(super) depth: usize,
pub(super) id: WidgetId,
}
impl<'a> Painter<'a> {
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
let kind = self.rsc.ui_mut().primitives.kind::<P>();
self.write(kind, primitive, region);
}
/// Takes the kind, for a caller writing many of one primitive.
fn write<P: Primitive>(&mut self, kind: PrimitiveKind<P>, primitive: P, region: UiRegion) {
self.write_resolved(kind, primitive, region, self.resolve(region));
}
/// A box in this widget's region, composed into its region node's
/// coordinates.
fn resolve(&self, region: UiRegion) -> UiRegion {
region.within(&self.region)
}
fn write_resolved<P: Primitive>(
&mut self,
kind: PrimitiveKind<P>,
primitive: P,
region: UiRegion,
resolved: UiRegion,
) {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PrimitiveWrites);
let h = self.state.layers.write(
self.layer,
PrimitiveInst {
kind,
id: self.id,
primitive,
region: resolved,
mask_idx: self.mask,
move_idx: self.move_idx,
},
);
self.push_primitive(RetainedPrimitive { handle: h, region });
}
fn push_primitive(&mut self, h: RetainedPrimitive) {
if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask);
}
self.primitives.push(h);
}
/// Writes a primitive over the whole of this widget's own box.
pub fn primitive(&mut self, primitive: impl PrimitiveLike) {
let primitive = primitive.into_primitive(self);
self.primitive_at(primitive, UiRegion::FULL)
}
/// Writes a primitive in a part of this widget's own box, in that box's
/// coordinates.
pub fn primitive_within(&mut self, primitive: impl PrimitiveLike, region: UiRegion) {
let primitive = primitive.into_primitive(self);
self.primitive_at(primitive, region);
}
/// Sets a mask, in this widget's own box's coordinates.
pub fn set_mask(&mut self, region: UiRegion) {
self.mask_region = Some(region);
assert!(self.mask == MaskIdx::NONE);
let resolved = self.resolve(region);
let move_idx = self.move_idx;
let mask = Mask {
region: resolved,
move_idx,
};
let masks = &mut self.rsc.ui_mut().masks;
self.mask = match self.mask_slot.take() {
Some(idx) => {
*masks.get_mut(idx) = mask;
idx
}
None => {
let idx = masks.push(mask);
// The owner keeps the slot alive even with no primitives.
masks.push_ref(idx);
idx
}
};
}
/// Draws a widget in the whole of this widget's own box, with the rel
/// base forwarded unchanged: what a container that is only a wrapper
/// around one child wants.
pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget<W>) -> DrawResult<'s, 'a, W> {
self.widget_at(id, UiRegion::FULL)
}
/// Resolves what the place says about the child's rel base into a length,
/// where that is this widget's own narrowed the way the region is. An
/// axis the region leaves whole is not read at all, so a wrapper that
/// only moves its child does not pin its drawing to a rel base.
fn state_rel_base(&mut self, mut place: PlaceDesc) -> PlaceDesc {
for axis in AXES {
if let Some(span) = place[axis].narrows_rel_base() {
let len = span.len();
let stated = (len != Len::FULL).then(|| len.within_len(self.rel_base(axis)));
place[axis] = place[axis].with_rel_base(stated);
}
}
place
}
/// Asks a child, saying what its fractions are of and where it is asked.
///
/// `place` says where the child goes and what its fractions are of:
/// see [`PlaceDesc`]. A `UiRegion` converts into the common case, which
/// is a box of this widget's own with the answer placed inside it.
///
/// The child draws once, in the region that comes of it, and its answer
/// is placed inside that region by re-expressing the drawing. Nothing is
/// drawn again in a box an answer chose; a container that puts the
/// answer somewhere else says so with [`Self::place_at`].
pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
place: impl Into<PlaceDesc>,
) -> DrawResult<'s, 'a, W> {
let place = self.state_rel_base(place.into());
let region_node = self.rsc.widgets().is_region_node(id.id());
let declared = self.declared_lens(id);
let align = self.rsc.widgets().alignment(id.id());
let (rel_base, region) =
place.rel_base_and_region(self.region, self.rel_base, declared, align);
#[cfg(feature = "layout-diagnostics")]
if region_node {
diag::bump(Counter::RegionNodeDraws);
diag::region_node(id.id(), self.id, region);
}
// A child listed twice would be moved twice.
let re_asked = self.children.contains(&id.id());
if !re_asked {
self.children.push(id.id());
}
let px = rel_base.to_px(self.window);
let (size, answer_holds, holds) = self.state.draw_inner(
id.id(),
DrawInfo {
layer: self.layer,
parent: Some(self.id),
depth: self.depth + 1,
parent_move: self.move_idx,
region_node,
mask: self.mask,
rel_base,
region,
placed: place,
asked: place,
re_asked,
px,
},
None,
self.rsc,
);
let holds = self.in_parent(holds, region, place, declared);
let answer_holds = self.in_parent(answer_holds, region, place, declared);
match self.under.iter_mut().find(|(child, _)| *child == id.id()) {
Some((_, kept)) => *kept = holds,
None => self.under.push((id.id(), holds)),
}
DrawResult {
child: id,
painter: self,
size,
answer_holds,
}
}
/// Takes back a child that was drawn only to find out how long it is.
/// Its drawing is dropped and it is not one of this widget's children
/// this frame; what it answered is still something this widget asked.
pub fn undraw<W: ?Sized>(&mut self, id: &StrongWidget<W>) {
self.children.retain(|child| *child != id.id());
self.under.retain(|(child, _)| *child != id.id());
self.state.undraw_rec(id.id(), self.rsc);
}
/// Puts a child in `place` of this widget's box, where that box is the
/// answer the child already gave: the drawing is re-expressed there
/// rather than made again -- what a row does once it knows every slot,
/// having measured each child from its cursor.
///
/// A child this draw has not asked about, and one whose rel base this
/// narrows, is asked here instead: there is no answer to re-express, or
/// the question has changed. So a container that places every child the
/// same way says it once, and which of the two happens is this widget's
/// business rather than the caller's.
pub fn place_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
place: impl Into<PlaceDesc>,
) -> DrawResult<'s, 'a, W> {
let place = self.state_rel_base(place.into());
let states_rel_base = AXES
.iter()
.any(|&axis| place[axis].stated_rel_base().is_some());
if states_rel_base || !self.children.contains(&id.id()) {
return self.widget_at(id, place);
}
let at = self.placing();
self.state.place_in(id.id(), &at, place, self.rsc);
let active = &self.state.active[&id.id()];
let size = active.measured().unwrap_or(active.size);
DrawResult {
child: id,
painter: self,
size,
// Read where it was asked; moving it is not a second answer.
answer_holds: LayoutHolds::ANY,
}
}
/// This widget as the thing its children are placed within.
fn placing(&self) -> Placing {
Placing {
id: self.id,
region: self.region,
rel_base: self.rel_base,
window: self.window,
depth: self.depth,
move_idx: self.move_idx,
mask: self.mask,
}
}
/// What a widget's rules declare its lengths to be, which whoever draws
/// it resolves into its rel base. Reading them depends on nothing -- the box
/// that comes of them is kept on the child, and `redraw` compares it
/// there.
fn declared_lens<W: ?Sized>(&self, id: &StrongWidget<W>) -> Declared {
self.rsc.widgets().declared_lens(id.id())
}
/// What a child says its length is without being drawn, if it can say,
/// as the length its draw would report: a fraction in it is resolved
/// against this widget's rel base, which is the rel base a child asked with
/// nothing narrowed gets. Asking counts as reading its size.
pub fn size_hint<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<LayoutLen> {
let widgets = self.rsc.widgets();
// A rule is the answer where there is one: it wins over whatever the
// widget would draw, so it has to win over what the widget says too.
let hint = widgets.size_rules(id.id())[axis].exact().or_else(|| {
widgets
.get_dyn(id.id())
.and_then(|widget| widget.size_hint(axis))
});
let rel_base = self.rel_base[axis];
let resolved = hint.map(|hint| hint.within_len(rel_base));
#[cfg(feature = "layout-diagnostics")]
{
diag::hint_read(id.id(), self.id, axis, resolved);
diag::bump(match resolved {
Some(_) => Counter::HintHits,
None => Counter::HintMisses,
});
}
if let Some(hint) = hint {
self.depend_on(id);
// Resolving a fraction against this rel base makes this draw a
// function of the rel base's length. The fraction to ask about is
// the child's own: resolved against a rel base of pixels, none is
// left to see it by.
if hint.rel != Rel::ZERO {
self.own[axis].rel_base = Some(rel_base);
}
}
resolved
}
fn depend_on<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
if !self.size_deps.contains(&child.id()) {
self.size_deps.push(child.id());
}
}
pub fn render_text<'b>(
&mut self,
buffer: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> &'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)
}
/// Writes glyphs in the selected rel base or region coordinates.
// TODO: merge the text methods into the primitive ones.
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
// Glyph offsets and sizes are pixels, which compose additively.
// Only the shared origin needs composing through the region.
let resolved = self.resolve(origin);
let kind = self.rsc.ui_mut().primitives.kind::<GlyphPrimitive>();
for glyph in text.glyphs.iter() {
let place = |mut region: UiRegion| {
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::from_px(glyph.offset));
let size = PxVec2::new(
Px::from_int(glyph.entry.width as i32),
Px::from_int(glyph.entry.height as i32),
);
region.x.end = region.x.start.offset(size.x);
region.y.end = region.y.start.offset(size.y);
region
};
self.write_resolved(
kind,
GlyphPrimitive {
uv_min: glyph.entry.uv_min,
uv_max: glyph.entry.uv_max,
layer: glyph.entry.layer,
color: text.color,
flags: glyph.entry.flags(),
},
place(origin),
place(resolved),
);
}
}
/// The symbolic length of this widget's own box along one axis, in the
/// lengths of its rel base that it places its children in. Reading it pins
/// the drawing to that length -- and to nothing about where the box
/// starts, which is what lets a container move without being drawn
/// again. One axis at a time, because a container that divides one axis
/// holds for any length of the other.
pub fn region_len(&mut self, axis: Axis) -> Len {
let len = self.region[axis].len();
self.own[axis].region_len = Some(len);
len
}
/// This widget's rel base along one axis: what a fraction it or anything
/// under it declares or reports is a fraction of. A container reads it
/// to hand a length of it down -- padding, which takes its pixels off.
/// Reading it pins the drawing to that rel base, the way
/// [`Self::region_len`] pins it to the box.
pub fn rel_base(&mut self, axis: Axis) -> Len {
let len = self.rel_base[axis];
self.own[axis].rel_base = Some(len);
len
}
/// Where this widget sits in a box longer than the length it takes. A
/// widget that positions its own content reads it to place that content
/// the way the box around it would have placed the widget.
pub fn alignment(&self) -> RegionAlign {
self.rsc.widgets().alignment(self.id)
}
/// Whether a rule beside this widget gives its length on `axis` outright,
/// which makes whatever it reports for that axis moot. A rule that only
/// bounds the length is not one of these: the answer is still the
/// widget's to give, and something still has to work it out.
///
/// The widget under a rule does not otherwise learn of it -- this is for
/// a container deciding whether reading its children across an axis is
/// worth anything, since reading one is also what makes its own size
/// depend on it.
pub fn has_exact_size(&self, axis: Axis) -> bool {
self.rsc.widgets().size_rules(self.id)[axis]
.exact()
.is_some()
}
/// This widget's own box in pixels. Reading it makes the drawing one
/// that holds for this box only, until `holds` says how far it goes.
pub fn px_size(&mut self) -> PxVec2 {
PxVec2::new(self.px_len(Axis::X), self.px_len(Axis::Y))
}
/// One axis of this widget's own box in pixels. Prefer this to
/// [`Self::px_size`] when the other axis cannot affect the drawing.
pub fn px_len(&mut self, axis: Axis) -> Px {
let len = self.region[axis].len();
let px = len.to_px(self.window[axis]);
let own = &mut self.own[axis].region;
if *own == Holds::ANY {
*own = Holds::at(px);
}
px
}
/// The lengths of this widget's own box on `axis` that what it is drawing
/// holds for -- the same primitives, in the same fractions and offsets
/// of the box, and the same reported size. A widget that read its length
/// in pixels holds for that one alone until it says otherwise.
pub fn holds(&mut self, axis: Axis, holds: impl Into<Holds>) {
let len = self.region[axis].len();
let holds = holds.into();
debug_assert!(
holds.contains(len.to_px(self.window[axis])),
"'{}' ({:?}) says its drawing holds for lengths that leave out its own box",
self.label(),
self.id
);
self.own[axis].region = holds;
}
/// A window length in pixels, which is what every length in layout is
/// measured in. Reading one pins the drawing to this window wherever the
/// length is a fraction of it; one that is only pixels is that many
/// pixels in any window and pins nothing.
pub fn to_px(&mut self, len: Len, axis: Axis) -> Px {
let window = self.window[axis];
if len.rel != Rel::ZERO {
let own = &mut self.own[axis].window;
if *own == Holds::ANY {
*own = Holds::at(window);
}
}
len.to_px(window)
}
/// The windows this drawing holds for, stated rather than taken: a
/// container that branched on a length in pixels says which side of the
/// boundary it was on, which is wider than the one window reading that
/// length pins, and replaces it.
pub fn window_holds(&mut self, axis: Axis, holds: impl Into<Holds>) {
let holds = holds.into();
debug_assert!(
holds.contains(self.window[axis]),
"'{}' ({:?}) says its drawing holds for windows that leave out this one",
self.label(),
self.id
);
self.own[axis].window = holds;
}
pub fn text_data(&mut self) -> &mut TextData {
&mut self.rsc.ui_mut().text
}
pub fn child_layer(&mut self) {
self.layer = self.state.layers.child(self.layer);
}
/// The layer this widget's `n`th child draws on, addressed rather than
/// walked to. A container that measures one child by drawing it can ask
/// on the layer that child will end up on, and then the second ask is a
/// reuse rather than a second drawing on another layer.
pub fn child_layer_at(&mut self, n: usize) {
let mut at = self.state.layers.child(self.own_layer);
for _ in 0..n {
at = self.state.layers.next(at);
}
self.layer = at;
}
pub fn next_layer(&mut self) {
self.layer = self.state.layers.next(self.layer);
}
pub fn label(&self) -> &str {
&self.rsc.widgets().data(self.id).unwrap().label
}
pub fn id(&self) -> &WidgetId {
&self.id
}
}
/// A child that has just been drawn. Reading its size records that this
/// widget's own size depends on it; dropping it without reading draws the
/// child and leaves the parent independent of what it came to.
pub struct DrawResult<'p, 'a, W: ?Sized> {
painter: &'p mut Painter<'a>,
child: &'p StrongWidget<W>,
size: Size,
answer_holds: LayoutHolds,
}
impl<W: ?Sized> DrawResult<'_, '_, W> {
pub fn size(self) -> Size {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::SizeReads);
diag::size_read(self.child.id(), self.painter.id, self.size);
}
self.painter.depend_on(self.child);
self.painter.answer_under = self.painter.answer_under.and(self.answer_holds);
self.size
}
pub fn len(self, axis: Axis) -> LayoutLen {
self.size()[axis]
}
}
/// What `Painter::primitive` takes: a primitive, or something that yields one
/// and does whatever else drawing it needs.
pub trait PrimitiveLike {
type Primitive: Primitive;
fn into_primitive(self, painter: &mut Painter) -> Self::Primitive;
}
impl<P: Primitive> PrimitiveLike for P {
type Primitive = P;
fn into_primitive(self, _: &mut Painter) -> P {
self
}
}
impl PrimitiveLike for &TextureHandle {
type Primitive = TexturePrimitive;
/// Retains a share of the handle, so the slot the primitive names cannot
/// be freed and reused while it is still drawn.
fn into_primitive(self, painter: &mut Painter) -> TexturePrimitive {
painter.textures.push(self.clone());
self.into()
}
}
/// Moves what a child depends on into this widget's own terms: this
/// method's `impl` block is where a `Painter`'s own boxes are, so it takes
/// only what the child was asked with.
impl Painter<'_> {
/// Window ranges are already about the one unit and combine directly.
/// A rel base pin becomes this widget's own rel base wherever a length of it
/// is what reached the child; where only pixels did, no length of this
/// rel base can change the child's and the pin stops here.
///
/// A child's validity maps back through the part of this widget's box,
/// where the box the child was asked in is that part; a declared length
/// places the box inside the part instead, and then only that length
/// reaches the child. A narrowed rel base is not one of these: it decides
/// what fractions under the child mean and leaves the box the part it
/// was given.
fn in_parent(
&self,
holds: LayoutHolds,
region: UiRegion,
place: PlaceDesc,
declared: Declared,
) -> LayoutHolds {
let mut result = LayoutHolds::ANY;
for axis in AXES {
let declared = declared[axis];
let holds = holds[axis];
let result = &mut result[axis];
// Every read became pixels against the window, so a range on
// it is already in this widget's terms.
result.window = holds.window;
let at = place[axis];
let reaches = at.stated_rel_base().is_none()
&& !at.is_sized()
&& declared.is_none_or(|len| len.rel != Rel::ZERO);
result.rel_base = holds.rel_base.and(reaches.then(|| self.rel_base[axis]));
match (at.within_span(), declared.is_some()) {
// Its box is a part of this widget's own box, in that box's
// own lengths, so what it holds for maps back through that
// part into a range on this widget's box. A length it pinned
// is this widget's length less the part's pixels where the
// part is the whole of the box less pixels, which is the one
// shape that inverts exactly; any other part pins this
// widget's own length.
(Some(span), false) => {
let part_len = span.len();
result.region = holds.region.through(part_len);
result.region_len = holds.region_len.map(|pinned| match part_len.rel {
Rel::ONE => pinned - Len::from_parts(Rel::ZERO, part_len.px),
_ => self.region[axis].len(),
});
}
// Its box is a length this widget decided, from its own
// rel base or from a sibling's answer: no length of this
// widget's box reaches it, so what it holds for is a range
// on the window and none of it on that box.
_ => {
result.window = result.window.and(holds.region.through(region[axis].len()));
}
}
}
result
}
}
impl Widgets {
/// What a widget's box is where a rule or its own hint says so outright.
pub(crate) fn declared_lens(&self, id: WidgetId) -> Declared {
let rules = self.size_rules(id);
let widget = self.get_dyn(id);
Declared::from_axes(|axis| {
rules[axis].declared().or_else(|| {
// A hint still narrows the box where no rule does, which is
// how a widget with a natural pixel size -- an image, a gap
// -- gets that size rather than the whole offer. That is the
// offer's business rather than a declaration's, and this
// falls away once a widget occupies its reported size inside
// the box it was offered.
widget
.and_then(|widget| widget.size_hint(axis))
.and_then(LayoutLen::declared)
})
})
}
}
impl LayoutLen {
/// Whether what a widget reported along an axis is the whole of the box
/// it is in rather than a part to be placed inside it. A share fills,
/// because a share is a length only to whoever divides one, and whoever
/// did is the one that handed down this box. A declared axis does too:
/// the rule already gave the region its length, and the rule's length is
/// what the widget reports there. And an axis the parent decided from
/// the answer is the answer already.
pub(crate) fn fills(self, declared: Option<Len>, decided: bool) -> bool {
self.leftover != Weight::ZERO || declared.is_some() || decided
}
}
/// Where a widget's drawing goes inside the part its parent gave it: what
/// it reported, on the side of the part its alignment says, and the whole
/// part wherever the answer fills it.
///
/// The length it reported is a length of its rel base, and the part is one too,
/// so this takes one from the other rather than composing it into the part.
/// That is what makes a fraction the same fraction wherever the part it is
/// placed in sits and however long it is -- the fraction is resolved once,
/// here, against the rel base it was reported of.
impl PlaceDesc {
pub(crate) fn placement(
self,
region: UiRegion,
size: Size,
declared: Declared,
align: RegionAlign,
) -> UiRegion {
let mut placed = region;
for axis in AXES {
let reported = size[axis];
if reported.fills(declared[axis], self[axis].does_fill()) {
continue;
}
placed[axis] = placed[axis].place(reported.without_leftover(), align[axis]);
}
placed
}
/// The rel base length and the box a child is asked in, in the coordinates the
/// widget asking draws in.
///
/// `own` is that widget's own box, and `place` what of it the child is
/// given, including any rel base it states -- a row's slot, or padding's rel
/// base less its pixels. That is a window length, like every other length
/// here, since a slot of a row is not a fraction of anything the row can
/// name. The child's declaration is a fraction of whichever reached it, and
/// is the only one that also places the box: a box the caller decided is
/// what `place` names.
pub(crate) fn rel_base_and_region(
self,
own: UiRegion,
parent_rel_base: UiVec2,
declared: Declared,
align: RegionAlign,
) -> (UiVec2, UiRegion) {
let given = self.of(own, align);
let mut rel_base = parent_rel_base;
let mut region = given;
for axis in AXES {
let base = self[axis]
.stated_rel_base()
.unwrap_or_else(|| parent_rel_base[axis]);
let len = declared[axis]
.map(|len| len.within_len(base))
.unwrap_or(base);
rel_base[axis] = len;
if declared[axis].is_some() {
region[axis] = given[axis].place(len, align[axis]);
}
}
(rel_base, region)
}
}
+252
View File
@@ -0,0 +1,252 @@
use crate::util::impl_axis_index;
use crate::{Axis, AxisAlign, Len, PrimitiveHandle, RegionAlign, UiRegion, UiSpan};
/// How a child's region along one axis comes from the region of the widget
/// asking, and what its fractions are of.
///
/// The three ways of saying a region are the three the geometry already has:
/// a span composed into the caller's box, a span shifted to where that box
/// starts, and a length placed in it by alignment. Which one is meant cannot
/// be read off the numbers, since two of them take the same span and apply
/// it differently, so it is said here.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlaceDescAxis {
span: PlaceSpan,
fills: bool,
rel_base: RelBase,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum PlaceSpan {
Within(UiSpan),
Shifted(UiSpan),
Sized(Len),
}
/// What a child's fractions are of, where the caller has not named a length.
#[derive(Clone, Copy, Debug, PartialEq)]
enum RelBase {
/// The caller's own, unchanged.
Inherit,
/// The caller's own, narrowed the way the region is.
WithRegion,
/// This length of the window.
Len(Len),
}
impl PlaceDescAxis {
/// The whole of the caller's box.
pub const WHOLE: Self = UiSpan::FULL.within_desc();
/// This region is the child's placement: its answer is not placed inside
/// it again. A container uses it where it hands back exactly what the
/// child asked for -- a row placing a child at the length it reported.
pub const fn fills(mut self) -> Self {
self.fills = true;
self
}
/// This along `axis`, and the whole of the caller's box across it: what
/// a container dividing one axis says, since nothing divides the other.
/// [`PlaceDesc::from_axis`] says the across one where it is not the
/// whole.
pub const fn on_axis(self, axis: Axis) -> PlaceDesc {
PlaceDesc::from_axis(axis, self, Self::WHOLE)
}
/// What the child's fractions are of, as a length of the window: a
/// resolved share, or a box a sibling's answer decided.
pub const fn rel_base(mut self, len: Len) -> Self {
self.rel_base = RelBase::Len(len);
self
}
/// Whether the region is the placement outright, rather than a box the
/// answer is placed inside.
pub(crate) const fn does_fill(self) -> bool {
self.fills
}
/// Where it lands in the coordinates `own` is in.
pub(crate) fn of(self, own: UiSpan, align: AxisAlign) -> UiSpan {
match self.span {
PlaceSpan::Within(span) => span.within(&own),
PlaceSpan::Shifted(mut span) => {
span.shift(own.start);
span
}
PlaceSpan::Sized(len) => own.place(len, align),
}
}
/// The child's rel base, where this says one outright. `None` forwards
/// the caller's own, and [`RelBase::WithRegion`] is resolved by whoever
/// can read that rel base, so it does not reach here.
pub(crate) const fn stated_rel_base(self) -> Option<Len> {
match self.rel_base {
RelBase::Len(len) => Some(len),
_ => None,
}
}
/// The length this narrows the caller's rel base by, where it does.
/// `None` leaves that rel base alone, and reading it is then a
/// dependency the caller does not take.
pub(crate) const fn narrows_rel_base(self) -> Option<UiSpan> {
match (self.rel_base, self.span) {
(RelBase::WithRegion, PlaceSpan::Within(span)) => Some(span),
_ => None,
}
}
/// The span it composes into the caller's box, where that is what it
/// does: the one case whose validity maps back through the part.
pub(crate) const fn within_span(self) -> Option<UiSpan> {
match self.span {
PlaceSpan::Within(span) => Some(span),
_ => None,
}
}
/// Whether the caller decided this length rather than a place along its
/// own box, which is what stops its length reaching the child at all.
pub(crate) const fn is_sized(self) -> bool {
matches!(self.span, PlaceSpan::Sized(_))
}
/// The same, with its rel base stated outright.
pub(crate) const fn with_rel_base(mut self, len: Option<Len>) -> Self {
self.rel_base = match len {
Some(len) => RelBase::Len(len),
None => RelBase::Inherit,
};
self
}
}
/// Where a child is asked, on both axes. A [`UiRegion`] converts into the
/// common case: that box of the caller's own, the answer placed inside it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlaceDesc {
pub x: PlaceDescAxis,
pub y: PlaceDescAxis,
}
impl PlaceDesc {
/// The whole of the caller's box, on both axes.
pub const WHOLE: Self = Self::splat(PlaceDescAxis::WHOLE);
pub const fn new(x: PlaceDescAxis, y: PlaceDescAxis) -> Self {
Self { x, y }
}
/// The same on both axes.
pub const fn splat(place: PlaceDescAxis) -> Self {
Self { x: place, y: place }
}
/// A description per axis, where the two differ and neither is the
/// axis a container divides.
pub fn from_axes(f: impl Fn(Axis) -> PlaceDescAxis) -> Self {
Self::new(f(Axis::X), f(Axis::Y))
}
/// `aligned` on `axis` and `ortho` on the other, which is how a
/// container that divides one axis says what it is doing.
pub const fn from_axis(axis: Axis, aligned: PlaceDescAxis, ortho: PlaceDescAxis) -> Self {
match axis {
Axis::X => Self::new(aligned, ortho),
Axis::Y => Self::new(ortho, aligned),
}
}
/// Both regions are the child's placement. See [`PlaceDescAxis::fills`].
pub const fn fills(self) -> Self {
Self::new(self.x.fills(), self.y.fills())
}
/// The child's rel base on one axis. See [`PlaceDescAxis::rel_base`].
pub const fn rel_base(mut self, axis: Axis, len: Len) -> Self {
self[axis] = self[axis].rel_base(len);
self
}
/// The box each axis names, in the coordinates `own` is in.
pub(crate) fn of(self, own: UiRegion, align: RegionAlign) -> UiRegion {
UiRegion::new(self.x.of(own.x, align.x), self.y.of(own.y, align.y))
}
}
impl UiSpan {
/// This span composed into the caller's own box, so it moves and scales
/// with it: [`UiSpan::within`], which is what a container that insets
/// one speaks. Taking eleven pixels off the end needs no length, where
/// saying the same thing in window lengths would make the container read
/// its own box -- and a box chosen from its own answer then feeds back
/// into the answer.
///
/// The child's rel base is narrowed the same way, so padding takes its
/// pixels off both and `rel(1)` under it fills the caller rather than
/// overflowing it.
pub const fn within_desc(self) -> PlaceDescAxis {
PlaceDescAxis {
span: PlaceSpan::Within(self),
fills: false,
rel_base: RelBase::WithRegion,
}
}
/// This span shifted to where the caller's own box starts: window
/// lengths along a cursor, which is what a container dividing room among
/// its children speaks. A child's report is a window length, so the
/// cursor that sums those reports is one too, and a moved box re-places
/// every child by re-adding its start, exactly.
///
/// The child's rel base passes through: how far along the cursor a child
/// sits says nothing about what a fraction under it is of. The same span
/// says [`Self::within_desc`] as a part of that box instead, and which is
/// meant cannot be read off the numbers.
pub const fn shifted_desc(self) -> PlaceDescAxis {
PlaceDescAxis {
span: PlaceSpan::Shifted(self),
fills: false,
rel_base: RelBase::Inherit,
}
}
}
impl Len {
/// A box this long, placed in the caller's own by the child's alignment:
/// the rule that places an answer, with the length given from above
/// rather than reported. What a stack's sizing child decides for the
/// rest. It is the child's rel base too.
pub const fn as_desc(self) -> PlaceDescAxis {
PlaceDescAxis {
span: PlaceSpan::Sized(self),
fills: false,
rel_base: RelBase::Len(self),
}
}
}
impl From<UiRegion> for PlaceDesc {
fn from(region: UiRegion) -> Self {
Self::new(region.x.within_desc(), region.y.within_desc())
}
}
impl From<PlaceDescAxis> for PlaceDesc {
fn from(place: PlaceDescAxis) -> Self {
Self::splat(place)
}
}
/// A primitive as it was written: its box in the widget's own box's
/// coordinates, which is what a move of that box re-composes from.
#[derive(Debug)]
pub struct RetainedPrimitive {
pub handle: PrimitiveHandle,
pub region: UiRegion,
}
impl_axis_index!(PlaceDesc => PlaceDescAxis);
File diff suppressed because it is too large. Load diff
+118
View File
@@ -0,0 +1,118 @@
use std::ops::Deref;
use crate::util::{Id, IdNum, IdTracker};
pub struct Arena<T, I> {
data: Vec<T>,
tracker: IdTracker<I>,
}
impl<T, I: IdNum> Arena<T, I> {
pub fn new() -> Self {
Self {
data: Vec::new(),
tracker: IdTracker::default(),
}
}
pub fn push(&mut self, value: T) -> Id<I> {
let id = self.tracker.next();
let i = id.idx();
if i == self.data.len() {
self.data.push(value);
} else {
self.data[i] = value;
}
id
}
pub fn remove(&mut self, id: Id<I>) -> T
where
T: Copy,
{
let i = id.idx();
self.tracker.free(id);
self.data[i]
}
pub(crate) fn get_mut(&mut self, id: Id<I>) -> &mut T {
&mut self.data[id.idx()]
}
}
impl<T, I: IdNum> Default for Arena<T, I> {
fn default() -> Self {
Self::new()
}
}
pub struct TrackedArena<T, I> {
inner: Arena<T, I>,
refs: Vec<u32>,
pub changed: bool,
}
impl<T, I: IdNum> TrackedArena<T, I> {
pub fn new() -> Self {
Self {
inner: Arena::default(),
refs: Vec::new(),
changed: true,
}
}
pub fn push(&mut self, value: T) -> Id<I> {
self.changed = true;
let id = self.inner.push(value);
let i = id.idx();
if i == self.refs.len() {
self.refs.push(0);
}
id
}
pub fn push_ref(&mut self, i: Id<I>) {
self.refs[i.idx()] += 1;
}
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.changed = true;
self.inner.get_mut(id)
}
pub fn remove(&mut self, id: Id<I>) -> T
where
T: Copy,
{
let i = id.idx();
self.refs[i] -= 1;
if self.refs[i] == 0 {
self.changed = true;
self.inner.remove(id)
} else {
self[i]
}
}
}
impl<T, I: IdNum> Default for TrackedArena<T, I> {
fn default() -> Self {
Self::new()
}
}
impl<T, I> Deref for TrackedArena<T, I> {
type Target = Vec<T>;
fn deref(&self) -> &Self::Target {
&self.inner.data
}
}
impl<T, I> Deref for Arena<T, I> {
type Target = Vec<T>;
fn deref(&self) -> &Self::Target {
&self.data
}
}
+35
View File
@@ -0,0 +1,35 @@
use std::ops::{Deref, DerefMut};
pub struct DynBorrower<'a, T: ?Sized> {
data: &'a mut T,
borrowed: &'a mut bool,
}
impl<'a, T: ?Sized> DynBorrower<'a, T> {
pub fn new(data: &'a mut T, borrowed: &'a mut bool) -> Self {
if *borrowed {
panic!("tried to mutably borrow the same thing twice");
}
Self { data, borrowed }
}
}
impl<T: ?Sized> Drop for DynBorrower<'_, T> {
fn drop(&mut self) {
*self.borrowed = false;
}
}
impl<T: ?Sized> Deref for DynBorrower<'_, T> {
type Target = T;
fn deref(&self) -> &Self::Target {
self.data
}
}
impl<T: ?Sized> DerefMut for DynBorrower<'_, T> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.data
}
}
+30
View File
@@ -0,0 +1,30 @@
use std::ops::{Deref, DerefMut};
pub struct MutDetect<T> {
inner: T,
pub changed: bool,
}
impl<T> Deref for MutDetect<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> DerefMut for MutDetect<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.changed = true;
&mut self.inner
}
}
impl<T> From<T> for MutDetect<T> {
fn from(inner: T) -> Self {
MutDetect {
inner,
changed: true,
}
}
}
+87
View File
@@ -0,0 +1,87 @@
#[repr(C)]
#[derive(Eq, Hash, PartialEq, Debug, Clone, Copy, bytemuck::Zeroable)]
pub struct Id<I = u64>(I);
unsafe impl<I: Copy + bytemuck::Zeroable + 'static> bytemuck::Pod for Id<I> {}
pub struct IdTracker<I = u64> {
free: Vec<Id<I>>,
cur: Id<I>,
}
impl<I: IdNum> IdTracker<I> {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Id<I> {
if let Some(id) = self.free.pop() {
return id;
}
let next = self.cur.next();
std::mem::replace(&mut self.cur, next)
}
#[allow(dead_code)]
pub fn free(&mut self, id: Id<I>) {
self.free.push(id);
}
}
impl<I: IdNum> Id<I> {
#[allow(dead_code)]
/// for debug purposes; should this be exposed?
/// generally you want to use labels with widgets
pub(crate) fn raw(id: I) -> Self {
Self(id)
}
pub fn idx(&self) -> usize {
self.0.idx()
}
pub fn next(&self) -> Id<I> {
Self(self.0.next())
}
pub const fn preset(value: I) -> Self {
Self(value)
}
}
impl<I: IdNum> Default for IdTracker<I> {
fn default() -> Self {
Self {
free: Vec::new(),
cur: Id(I::first()),
}
}
}
pub trait IdNum {
fn first() -> Self;
fn next(&self) -> Self;
fn idx(&self) -> usize;
}
impl IdNum for u64 {
fn first() -> Self {
0
}
fn next(&self) -> Self {
self + 1
}
fn idx(&self) -> usize {
*self as usize
}
}
impl IdNum for u32 {
fn first() -> Self {
0
}
fn next(&self) -> Self {
self + 1
}
fn idx(&self) -> usize {
*self as usize
}
}
+123
View File
@@ -0,0 +1,123 @@
pub const trait LerpUtil {
fn lerp(self, from: Self, to: Self) -> Self;
}
const impl LerpUtil for f32 {
/// linear interpolation
/// from * (1.0 - self) + to * self
fn lerp(self, from: Self, to: Self) -> Self {
from + (to - from) * self
}
}
macro_rules! impl_op {
($T:ident $op:ident $fn:ident $opa:ident $fna:ident; $($field:ident)*) => {
#[allow(non_snake_case)]
mod ${concat($T, _op_, $fn, _impl)} {
use super::*;
#[allow(unused_imports)]
use std::ops::*;
const impl $op for $T {
type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output {
Self {
$($field: self.$field.$fn(rhs.$field),)*
}
}
}
const impl $opa for $T {
fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs);
}
}
const impl $op<f32> for $T {
type Output = Self;
fn $fn(self, rhs: f32) -> Self::Output {
Self {
$($field: self.$field.$fn(rhs),)*
}
}
}
const impl $op<$T> for f32 {
type Output = $T;
fn $fn(self, rhs: $T) -> Self::Output {
$T {
$($field: self.$fn(rhs.$field),)*
}
}
}
const impl $opa<f32> for $T {
fn $fna(&mut self, rhs: f32) {
*self = self.$fn(rhs);
}
}
}
};
// Without the `f32` operations, for a type whose fields are not all the
// same kind of number: there is nothing a bare float means to a fraction
// and an offset at once.
(same $T:ident $op:ident $fn:ident $opa:ident $fna:ident; $($field:ident)*) => {
#[allow(non_snake_case)]
mod ${concat($T, _op_, $fn, _same_impl)} {
use super::*;
#[allow(unused_imports)]
use std::ops::*;
const impl $op for $T {
type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output {
Self {
$($field: self.$field.$fn(rhs.$field),)*
}
}
}
const impl $opa for $T {
fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs);
}
}
}
};
(same $T:ident $op:ident $fn:ident; $($field:ident)*) => {
impl_op!(same $T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*);
};
($T:ident $op:ident $fn:ident; $($field:ident)*) => {
impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*);
};
(impl $op:ident for $T:ident: $fn:ident $($field:ident)*) => {
impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*);
};
}
pub(crate) use impl_op;
/// `Index<Axis>` for a pair, which is how every pair here is read by axis.
/// The generics clause is given in braces where the type has one.
macro_rules! impl_axis_index {
($({$($gen:tt)*})? $T:ty => $Out:ty) => {
const impl $(<$($gen)*>)? std::ops::Index<crate::Axis> for $T {
type Output = $Out;
fn index(&self, axis: crate::Axis) -> &$Out {
match axis {
crate::Axis::X => &self.x,
crate::Axis::Y => &self.y,
}
}
}
const impl $(<$($gen)*>)? std::ops::IndexMut<crate::Axis> for $T {
fn index_mut(&mut self, axis: crate::Axis) -> &mut $Out {
match axis {
crate::Axis::X => &mut self.x,
crate::Axis::Y => &mut self.y,
}
}
}
};
}
pub(crate) use impl_axis_index;
+24
View File
@@ -0,0 +1,24 @@
mod arena;
mod borrow;
mod change;
mod id;
mod math;
mod refcount;
mod slot;
mod trust;
mod typemap;
mod vec2;
pub use arena::*;
pub use borrow::*;
pub use change::*;
pub use id::*;
pub use math::*;
pub use refcount::*;
pub use slot::*;
pub(crate) use trust::*;
pub use typemap::*;
pub use vec2::*;
pub type HashMap<K, V> = fxhash::FxHashMap<K, V>;
pub type HashSet<K> = fxhash::FxHashSet<K>;
+36
View File
@@ -0,0 +1,36 @@
use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
#[derive(Debug)]
pub struct RefCounter(Arc<AtomicU32>);
impl RefCounter {
pub fn new() -> Self {
Self(Arc::new(0.into()))
}
pub fn refs(&self) -> u32 {
self.0.load(Ordering::Acquire)
}
pub fn drop(&mut self) -> bool {
let refs = self.0.fetch_sub(1, Ordering::Release);
refs == 0
}
pub fn quiet_clone(&self) -> Self {
Self(self.0.clone())
}
}
impl Default for RefCounter {
fn default() -> Self {
Self::new()
}
}
impl Clone for RefCounter {
fn clone(&self) -> Self {
self.0.fetch_add(1, Ordering::Release);
Self(self.0.clone())
}
}
+69
View File
@@ -0,0 +1,69 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SlotId {
idx: u32,
genr: u32,
}
pub struct SlotVec<T> {
data: Vec<(u32, Option<T>)>,
free: Vec<u32>,
}
impl<T> SlotVec<T> {
pub fn new() -> Self {
Self {
data: Default::default(),
free: Default::default(),
}
}
pub fn add(&mut self, x: T) -> SlotId {
if let Some(idx) = self.free.pop() {
let (genr, data) = &mut self.data[idx as usize];
*data = Some(x);
SlotId { idx, genr: *genr }
} else {
let idx = self.data.len() as u32;
let genr = 0;
self.data.push((genr, Some(x)));
SlotId { idx, genr }
}
}
pub fn free(&mut self, id: SlotId) {
let (genr, data) = &mut self.data[id.idx as usize];
*genr += 1;
*data = None;
self.free.push(id.idx);
}
pub fn get(&self, id: SlotId) -> Option<&T> {
let slot = &self.data[id.idx as usize];
if slot.0 != id.genr {
return None;
}
slot.1.as_ref()
}
pub fn get_mut(&mut self, id: SlotId) -> Option<&mut T> {
let slot = &mut self.data[id.idx as usize];
if slot.0 != id.genr {
return None;
}
slot.1.as_mut()
}
pub fn len(&self) -> usize {
self.data.len() - self.free.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl<T> Default for SlotVec<T> {
fn default() -> Self {
Self::new()
}
}
+12
View File
@@ -0,0 +1,12 @@
#[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) }
}
#[allow(clippy::mut_from_ref, clippy::missing_safety_doc)]
pub(crate) unsafe fn to_mut<T>(x: &T) -> &mut T {
#[allow(mutable_transmutes)]
unsafe {
std::mem::transmute::<&T, &mut T>(x)
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::util::HashMap;
use std::{
any::TypeId,
marker::Unsize,
ops::{Deref, DerefMut},
};
pub struct TypeMap<Trait: ?Sized> {
map: HashMap<TypeId, Box<Trait>>,
}
impl<Trait: ?Sized> TypeMap<Trait> {
pub fn set_type<T: Unsize<Trait> + 'static>(&mut self, val: T) {
self.map
.insert(TypeId::of::<T>(), Box::new(val) as Box<Trait>);
}
pub fn type_mut<T: Unsize<Trait> + 'static>(&mut self) -> Option<&mut T> {
Some(Self::convert_mut(self.map.get_mut(&TypeId::of::<T>())?))
}
pub fn type_or_default<T: Default + Unsize<Trait> + 'static>(&mut self) -> &mut T {
Self::convert_mut(
self.map
.entry(TypeId::of::<T>())
.or_insert(Box::new(T::default()) as Box<Trait>),
)
}
fn convert_mut<T: Unsize<Trait>>(entry: &mut Box<Trait>) -> &mut T {
// allegedly this is just what Any does...
unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) }
}
}
impl<T: ?Sized> Deref for TypeMap<T> {
type Target = HashMap<TypeId, Box<T>>;
fn deref(&self) -> &Self::Target {
&self.map
}
}
impl<T: ?Sized> DerefMut for TypeMap<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.map
}
}
impl<T: ?Sized> Default for TypeMap<T> {
fn default() -> Self {
Self {
map: Default::default(),
}
}
}
+94
View File
@@ -0,0 +1,94 @@
use crate::util::impl_op;
use std::{hash::Hash, ops::*};
/// `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,
pub y: f32,
}
impl Eq for Vec2 {}
impl Hash for Vec2 {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
state.write_u32(self.x.to_bits());
state.write_u32(self.y.to_bits());
}
}
impl Vec2 {
pub const ZERO: Self = Self::new(0.0, 0.0);
pub const ONE: Self = Self::new(1.0, 1.0);
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
pub const fn round(self) -> Self {
Self {
x: self.x.round(),
y: self.y.round(),
}
}
pub const fn floor(self) -> Self {
Self {
x: self.x.floor(),
y: self.y.floor(),
}
}
pub const fn ceil(self) -> Self {
Self {
x: self.x.ceil(),
y: self.y.ceil(),
}
}
pub const fn tuple(&self) -> (f32, f32) {
(self.x, self.y)
}
pub const fn with_x(mut self, x: f32) -> Self {
self.x = x;
self
}
pub const fn with_y(mut self, y: f32) -> Self {
self.y = y;
self
}
}
// this version looks kinda cool... is it more readable? more annoying to copy and change though
impl_op!(impl Add for Vec2: add x y);
impl_op!(Vec2 Sub sub; x y);
impl_op!(Vec2 Mul mul; x y);
impl_op!(Vec2 Div div; x y);
impl Neg for Vec2 {
type Output = Self;
fn neg(mut self) -> Self::Output {
self.x = -self.x;
self.y = -self.y;
self
}
}
impl std::fmt::Debug for Vec2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl std::fmt::Display for Vec2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
+28
View File
@@ -0,0 +1,28 @@
use crate::{RegionAlign, SizeRules, Widget};
pub struct WidgetData {
pub widget: Box<dyn Widget>,
pub label: String,
pub(super) region_node: bool,
pub(super) size: SizeRules,
pub(super) align: RegionAlign,
/// dynamic borrow checking
pub borrowed: bool,
}
impl WidgetData {
pub fn new<W: Widget>(widget: W) -> Self {
let mut label = std::any::type_name::<W>().to_string();
if let (Some(first), Some(last)) = (label.find(":"), label.rfind(":")) {
label = label.split_at(first).0.to_string() + "::" + label.split_at(last + 1).1;
}
Self {
widget: Box::new(widget),
label,
region_node: false,
size: SizeRules::default(),
align: RegionAlign::default(),
borrowed: false,
}
}
}
+164
View File
@@ -0,0 +1,164 @@
use std::{marker::Unsize, ops::CoerceUnsized, sync::mpsc::Sender};
use crate::{
UiRsc, Widget,
util::{RefCounter, SlotId},
};
pub type WidgetId = SlotId;
/// An identifier for a widget that can index a UI or event ctx to get it.
/// This is a strong handle that does not impl Clone, and when it is dropped,
/// a signal is sent to the owning UI to clean up the resources.
///
/// TODO: ergonomic clones when they get put in rust-analyzer & don't cause ICEs?
pub struct StrongWidget<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId,
counter: RefCounter,
send: Sender<WidgetId>,
ty: *const W,
}
/// A weak handle to a widget.
/// Will not keep it alive, but can still be used for indexing like WidgetHandle.
pub struct WeakWidget<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId,
#[allow(unused)]
ty: *const W,
}
impl<W: Widget + ?Sized + Unsize<dyn Widget>> StrongWidget<W> {
pub fn any(self) -> StrongWidget<dyn Widget> {
self
}
}
impl<W: ?Sized> StrongWidget<W> {
pub(crate) fn new(id: WidgetId, send: Sender<WidgetId>) -> Self {
Self {
id,
counter: RefCounter::new(),
send,
ty: null_ptr(),
}
}
pub fn id(&self) -> WidgetId {
self.id
}
pub fn refs(&self) -> u32 {
self.counter.refs()
}
pub fn weak(&self) -> WeakWidget<W> {
let Self { ty, id, .. } = *self;
WeakWidget { ty, id }
}
}
impl<W: ?Sized> WeakWidget<W> {
pub(crate) fn new(id: WidgetId) -> Self {
Self { id, ty: null_ptr() }
}
pub fn id(&self) -> WidgetId {
self.id
}
#[track_caller]
pub fn upgrade(self, ui: &mut impl UiRsc) -> StrongWidget<W> {
ui.widgets_mut().upgrade(self)
}
}
impl<W: ?Sized> Drop for StrongWidget<W> {
fn drop(&mut self) {
if self.counter.drop() {
let _ = self.send.send(self.id);
}
}
}
pub trait WidgetIdFn<Rsc, W: ?Sized = dyn Widget>: FnOnce(&mut Rsc) -> WeakWidget<W> {}
impl<Rsc, W: ?Sized, F: FnOnce(&mut Rsc) -> WeakWidget<W>> WidgetIdFn<Rsc, W> for F {}
pub trait IdLike {
type Widget: ?Sized;
fn id(&self) -> WidgetId;
}
impl<W: ?Sized> IdLike for &StrongWidget<W> {
type Widget = W;
fn id(&self) -> WidgetId {
self.id
}
}
impl<W: ?Sized> IdLike for StrongWidget<W> {
type Widget = W;
fn id(&self) -> WidgetId {
self.id
}
}
impl<W: ?Sized> IdLike for WeakWidget<W> {
type Widget = W;
fn id(&self) -> WidgetId {
self.id
}
}
impl IdLike for WidgetId {
type Widget = dyn Widget;
fn id(&self) -> WidgetId {
*self
}
}
impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<StrongWidget<U>> for StrongWidget<T> {}
impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<WeakWidget<U>> for WeakWidget<T> {}
impl<W: ?Sized> Clone for WeakWidget<W> {
fn clone(&self) -> Self {
*self
}
}
impl<W: ?Sized> Copy for WeakWidget<W> {}
impl<W: ?Sized> PartialEq for WeakWidget<W> {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl<W> PartialEq for StrongWidget<W> {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl<W> std::fmt::Debug for StrongWidget<W> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.id.fmt(f)
}
}
impl<'a, W: Widget + 'a, State: UiRsc> FnOnce<(&'a mut State,)> for WeakWidget<W> {
type Output = &'a mut W;
extern "rust-call" fn call_once(self, args: (&'a mut State,)) -> Self::Output {
&mut args.0.widgets_mut()[self]
}
}
fn null_ptr<W: ?Sized>() -> *const W {
if size_of::<&W>() == size_of::<*const dyn Widget>() {
let w: *const dyn Widget = &();
unsafe { std::mem::transmute_copy(&w) }
} else {
unsafe { std::mem::transmute_copy(&[0usize; 1]) }
}
}
unsafe impl<W: ?Sized> Send for WeakWidget<W> {}
unsafe impl<W: ?Sized> Sync for WeakWidget<W> {}
+75
View File
@@ -0,0 +1,75 @@
use crate::UiRsc;
use super::*;
use std::marker::Unsize;
pub trait WidgetLike<Rsc: UiRsc, Tag>: Sized {
type Widget: Widget + ?Sized + Unsize<dyn Widget>;
fn add(self, rsc: &mut Rsc) -> WeakWidget<Self::Widget>;
fn add_strong(self, rsc: &mut Rsc) -> StrongWidget<Self::Widget> {
self.add(rsc).upgrade(rsc)
}
fn with_id<W2>(
self,
f: impl FnOnce(&mut Rsc, WeakWidget<Self::Widget>) -> WeakWidget<W2>,
) -> impl WidgetIdFn<Rsc, W2> {
move |state| {
let id = self.add(state);
f(state, id)
}
}
fn set_root(self, rsc: &mut Rsc, root: &mut impl HasRoot) {
let id = self.add_strong(rsc);
root.set_root(id);
}
}
pub trait HasRoot {
fn set_root(&mut self, root: StrongWidget);
}
pub trait WidgetArrLike<Rsc, const LEN: usize, Tag> {
#[track_caller]
fn add(self, state: &mut Rsc) -> WidgetArr<LEN>;
}
impl<Rsc, const LEN: usize> WidgetArrLike<Rsc, LEN, ArrTag> for WidgetArr<LEN> {
fn add(self, _: &mut Rsc) -> WidgetArr<LEN> {
self
}
}
// variadic generics please save us
macro_rules! impl_widget_arr {
($n:expr;$($W:ident)*) => {
impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*);
};
($n:expr;$($W:ident)*;$($Tag:ident)*) => {
impl<Rsc: UiRsc, $($W: WidgetLike<Rsc, $Tag>,$Tag,)*> WidgetArrLike<Rsc, $n, ($($Tag,)*)> for ($($W,)*) {
fn add(self, rsc: &mut Rsc) -> WidgetArr<$n> {
#[allow(non_snake_case)]
let ($($W,)*) = self;
WidgetArr::new(
[$($W.add(rsc).upgrade(rsc),)*],
)
}
}
};
}
impl_widget_arr!(1;A);
impl_widget_arr!(2;A B);
impl_widget_arr!(3;A B C);
impl_widget_arr!(4;A B C D);
impl_widget_arr!(5;A B C D E);
impl_widget_arr!(6;A B C D E F);
impl_widget_arr!(7;A B C D E F G);
impl_widget_arr!(8;A B C D E F G H);
impl_widget_arr!(9;A B C D E F G H I);
impl_widget_arr!(10;A B C D E F G H I J);
impl_widget_arr!(11;A B C D E F G H I J K);
impl_widget_arr!(12;A B C D E F G H I J K L);
+83
View File
@@ -0,0 +1,83 @@
use crate::{Axis, LayoutLen, Painter, Size};
use std::any::Any;
mod data;
mod handle;
mod like;
mod size_rule;
mod tag;
mod view;
mod widgets;
pub use data::*;
pub use handle::*;
pub use like::*;
pub use size_rule::*;
pub use tag::*;
pub use view::*;
pub use widgets::*;
pub trait Widget: Any {
/// Draws the widget, and returns what it used of the box it was given.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// An exact length the widget can give without a painter or its children.
/// Optional, and saves a draw rather than changing one: a hint that
/// disagrees with the eventual draw fails a debug assertion.
fn size_hint(&self, _axis: Axis) -> Option<LayoutLen> {
None
}
}
impl Widget for () {
/// A gap: nothing drawn, at the default length, so a span gives it a share.
fn draw(&mut self, _: &mut Painter) -> Size {
Size::default()
}
fn size_hint(&self, _axis: Axis) -> Option<LayoutLen> {
Some(LayoutLen::default())
}
}
impl dyn Widget {
pub fn as_any(&self) -> &dyn Any {
self
}
pub fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
/// A function that returns a widget given a UI.
/// Useful for defining trait functions on widgets that create a parent widget so that the children
/// don't need to be IDs yet
pub trait WidgetFn<State, W: Widget + ?Sized>: FnOnce(&mut State) -> W {}
impl<State, W: Widget + ?Sized, F: FnOnce(&mut State) -> W> WidgetFn<State, W> for F {}
pub struct WidgetArr<const LEN: usize> {
pub arr: [StrongWidget; LEN],
}
impl<const LEN: usize> WidgetArr<LEN> {
pub fn new(arr: [StrongWidget; LEN]) -> Self {
Self { arr }
}
}
pub trait WidgetOption<State> {
fn get(self, state: &mut State) -> Option<StrongWidget>;
}
impl<State> WidgetOption<State> for () {
fn get(self, _: &mut State) -> Option<StrongWidget> {
None
}
}
impl<State, F: FnOnce(&mut State) -> Option<StrongWidget>> WidgetOption<State> for F {
fn get(self, state: &mut State) -> Option<StrongWidget> {
self(state)
}
}
+94
View File
@@ -0,0 +1,94 @@
use crate::util::impl_axis_index;
use crate::{Axis, LayoutLen, Len};
/// What a widget's length on one axis is, as a rule its parent applies where
/// it draws it rather than an answer the widget gives about itself.
///
/// A rule and a drawn size are not two opinions to reconcile: a rule wins on
/// the axis it names, and the `Size` returned by `draw` answers only the axes
/// with no rule. That is what lets a span divide its space around a length
/// nobody has drawn yet, and it is why a rule lives beside the widget rather
/// than inside it -- the widget under the rule never has to know about it.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SizeRule {
/// Whatever the widget reports from drawing.
#[default]
Free,
/// This length, whatever the widget reports.
Exact(LayoutLen),
}
impl SizeRule {
/// The length this rule gives without the widget being drawn, if it can
/// give one.
pub fn declared(&self) -> Option<Len> {
self.exact().and_then(LayoutLen::declared)
}
/// The length this rule gives outright, whatever the widget reports --
/// which makes the widget's answer on that axis moot. A share counts: it
/// is a length the widget's parent still has to divide, so it is exact
/// here and resolved there, unlike `declared`, which is only the ones
/// that give a box directly.
pub fn exact(&self) -> Option<LayoutLen> {
match self {
Self::Free => None,
Self::Exact(len) => Some(*len),
}
}
/// The length a widget reporting `reported` ends up with.
pub fn apply(&self, reported: LayoutLen) -> LayoutLen {
match self {
Self::Free => reported,
Self::Exact(len) => *len,
}
}
}
impl From<LayoutLen> for SizeRule {
fn from(len: LayoutLen) -> Self {
Self::Exact(len)
}
}
impl From<Option<LayoutLen>> for SizeRule {
fn from(len: Option<LayoutLen>) -> Self {
len.map_or(Self::Free, Self::Exact)
}
}
/// One rule per axis, which is how a widget carries a length on one axis and
/// leaves the other to whatever it draws.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SizeRules {
pub x: SizeRule,
pub y: SizeRule,
}
impl_axis_index!(SizeRules => SizeRule);
/// What a widget's box is on each axis where something says so outright,
/// before it is drawn: a rule beside it, or a hint it gives about itself.
/// Whoever draws the widget resolves these against its rel base.
///
/// A [`Len`] rather than a [`LayoutLen`], because a share can never be one
/// -- see [`LayoutLen::declared`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Declared {
pub x: Option<Len>,
pub y: Option<Len>,
}
impl Declared {
pub const NONE: Self = Self { x: None, y: None };
pub fn from_axes(f: impl Fn(Axis) -> Option<Len>) -> Self {
Self {
x: f(Axis::X),
y: f(Axis::Y),
}
}
}
impl_axis_index!(Declared => Option<Len>);
+64
View File
@@ -0,0 +1,64 @@
use super::*;
use crate::UiRsc;
use std::marker::Unsize;
pub struct WidgetTag;
impl<Rsc: UiRsc, W: Widget> WidgetLike<Rsc, WidgetTag> for W {
type Widget = W;
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> {
let w = rsc.ui_mut().widgets.add_weak(self);
rsc.on_add(w);
w
}
}
pub struct FnTag;
impl<Rsc: UiRsc, W: Widget, F: FnOnce(&mut Rsc) -> W> WidgetLike<Rsc, FnTag> for F {
type Widget = W;
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> {
self(rsc).add(rsc)
}
}
pub trait WidgetFnTrait<Rsc> {
type Widget: Widget;
fn run(self, rsc: &mut Rsc) -> Self::Widget;
}
pub struct FnTraitTag;
impl<Rsc: UiRsc, T: WidgetFnTrait<Rsc>> WidgetLike<Rsc, FnTraitTag> for T {
type Widget = T::Widget;
#[track_caller]
fn add(self, rsc: &mut Rsc) -> WeakWidget<T::Widget> {
self.run(rsc).add(rsc)
}
}
pub struct RefTag;
impl<Rsc: UiRsc, W: ?Sized + Widget + Unsize<dyn Widget>> WidgetLike<Rsc, RefTag>
for WeakWidget<W>
{
type Widget = W;
fn add(self, _: &mut Rsc) -> WeakWidget<W> {
self
}
}
pub struct RefFnTag;
impl<Rsc: UiRsc, W: ?Sized + Widget + Unsize<dyn Widget>, F: FnOnce(&mut Rsc) -> WeakWidget<W>>
WidgetLike<Rsc, RefFnTag> for F
{
type Widget = W;
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> {
self(rsc)
}
}
pub struct ViewTag;
impl<Rsc: UiRsc, V: WidgetView> WidgetLike<Rsc, ViewTag> for V {
type Widget = V::Widget;
fn add(self, _: &mut Rsc) -> WeakWidget<Self::Widget> {
self.root()
}
}
pub struct ArrTag;
+24
View File
@@ -0,0 +1,24 @@
use std::marker::Unsize;
use crate::{IdLike, WeakWidget, Widget};
pub trait WidgetView {
type Widget: Widget + ?Sized + Unsize<dyn Widget>;
fn root(&self) -> WeakWidget<Self::Widget>;
}
pub trait HasWidget {
type Widget: Widget + ?Sized + Unsize<dyn Widget>;
}
impl<W: Widget + Unsize<dyn Widget> + ?Sized> HasWidget for WeakWidget<W> {
type Widget = W;
}
impl<WV: WidgetView> IdLike for WV {
type Widget = WV::Widget;
fn id(&self) -> super::WidgetId {
self.root().id
}
}
+211
View File
@@ -0,0 +1,211 @@
use std::sync::mpsc::{Receiver, Sender, channel};
use crate::{
Axis, AxisAlign, IdLike, RegionAlign, SizeRule, SizeRules, StrongWidget, WeakWidget, Widget,
WidgetData, WidgetId,
util::{DynBorrower, HashSet, SlotVec, forget_mut, to_mut},
};
pub struct Widgets {
pub needs_redraw: HashSet<WidgetId>,
vec: SlotVec<WidgetData>,
send: Sender<WidgetId>,
recv: Receiver<WidgetId>,
pub(crate) waiting: HashSet<WidgetId>,
}
impl Widgets {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
needs_redraw: Default::default(),
vec: Default::default(),
waiting: Default::default(),
send,
recv,
}
}
pub fn has_updates(&self) -> bool {
!self.needs_redraw.is_empty()
}
pub fn get_dyn(&self, id: WidgetId) -> Option<&dyn Widget> {
Some(self.vec.get(id)?.widget.as_ref())
}
pub fn get_dyn_mut(&mut self, id: WidgetId) -> Option<&mut dyn Widget> {
self.needs_redraw.insert(id);
Some(self.vec.get_mut(id)?.widget.as_mut())
}
/// get_dyn but dynamic borrow checking of widgets
/// lets you do recursive (tree) operations, like the painter does
pub(crate) fn get_dyn_dynamic<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> {
// SAFETY: must guarantee no other mutable references to this widget exist
// done through the borrow variable
let data = unsafe { forget_mut(to_mut(self.vec.get(id).unwrap())) };
if data.borrowed {
panic!("tried to mutably borrow the same widget twice");
}
WidgetWrapper::new(data.widget.as_mut(), &mut data.borrowed)
}
pub fn get<I: IdLike>(&self, id: &I) -> Option<&I::Widget>
where
I::Widget: Sized + Widget,
{
self.get_dyn(id.id())?.as_any().downcast_ref()
}
pub fn get_mut<I: IdLike>(&mut self, id: &I) -> Option<&mut I::Widget>
where
I::Widget: Sized + Widget,
{
self.get_dyn_mut(id.id())?.as_any_mut().downcast_mut()
}
pub fn add_strong<W: Widget>(&mut self, widget: W) -> StrongWidget<W> {
let id = self.vec.add(WidgetData::new(widget));
StrongWidget::new(id, self.send.clone())
}
pub fn add_weak<W: Widget>(&mut self, widget: W) -> WeakWidget<W> {
let id = self.vec.add(WidgetData::new(widget));
self.waiting.insert(id);
WeakWidget::new(id)
}
#[track_caller]
pub fn upgrade<W: ?Sized>(&mut self, rf: WeakWidget<W>) -> StrongWidget<W> {
if !self.waiting.remove(&rf.id()) {
let label = self.label(rf);
let id = rf.id();
panic!(
"widget '{label}' ({id:?}) was already added\ncannot add a widget twice; consider creating two"
)
}
StrongWidget::new(rf.id(), self.send.clone())
}
pub fn data(&self, id: impl IdLike) -> Option<&WidgetData> {
self.vec.get(id.id())
}
pub fn label(&self, id: impl IdLike) -> &String {
&self.data(id.id()).unwrap().label
}
/// useful for debugging
pub fn set_label(&mut self, id: impl IdLike, label: String) {
self.data_mut(id.id()).unwrap().label = label;
}
/// Whether this widget owns a movable retained region.
pub fn is_region_node(&self, id: impl IdLike) -> bool {
self.data(id).unwrap().region_node
}
/// Chooses whether this widget's retained drawing has one movable region
/// of its own. Changing the boundary redraws the subtree once so every
/// primitive names the right coordinate space.
pub fn set_region_node(&mut self, id: impl IdLike, region_node: bool) {
let id = id.id();
let data = self.data_mut(id).unwrap();
if data.region_node == region_node {
return;
}
data.region_node = region_node;
self.needs_redraw.insert(id);
}
/// The length rules whoever draws this widget applies to its box.
pub fn size_rules(&self, id: impl IdLike) -> SizeRules {
self.data(id).unwrap().size
}
/// Sets one axis's rule. The widget is marked rather than its parent
/// because the parent is not known here; `redraw` escalates a changed
/// declared length to whoever resolves it.
pub fn set_size_rule(&mut self, id: impl IdLike, axis: Axis, rule: SizeRule) {
let id = id.id();
let data = self.data_mut(id).unwrap();
if data.size[axis] == rule {
return;
}
data.size[axis] = rule;
self.needs_redraw.insert(id);
}
/// Where this widget sits in a box longer than the length it takes.
pub fn alignment(&self, id: impl IdLike) -> RegionAlign {
self.data(id).unwrap().align
}
/// Sets one axis's alignment. Which box a widget ends up in is its
/// parent's to decide, so this is escalated the way a length rule is.
pub fn set_alignment(&mut self, id: impl IdLike, axis: Axis, align: AxisAlign) {
let id = id.id();
let data = self.data_mut(id).unwrap();
if data.align[axis] == align {
return;
}
data.align[axis] = align;
self.needs_redraw.insert(id);
}
/// Both axes at once, for a caller holding a pair.
pub fn set_size_rules(
&mut self,
id: impl IdLike,
x: impl Into<SizeRule>,
y: impl Into<SizeRule>,
) {
let id = id.id();
self.set_size_rule(id, Axis::X, x.into());
self.set_size_rule(id, Axis::Y, y.into());
}
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
self.vec.get_mut(id.id())
}
pub fn free_next(&mut self) -> Option<WidgetId> {
let next = self.recv.try_recv().ok()?;
self.vec.free(next);
Some(next)
}
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.vec.len()
}
}
impl Default for Widgets {
fn default() -> Self {
Self::new()
}
}
pub type WidgetWrapper<'a> = DynBorrower<'a, dyn Widget>;
impl<I: IdLike> std::ops::Index<I> for Widgets
where
I::Widget: Sized + Widget,
{
type Output = I::Widget;
fn index(&self, id: I) -> &Self::Output {
self.get(&id).unwrap()
}
}
impl<I: IdLike> std::ops::IndexMut<I> for Widgets
where
I::Widget: Sized + Widget,
{
fn index_mut(&mut self, id: I) -> &mut Self::Output {
self.get_mut(&id).unwrap()
}
}
+17
View File
@@ -0,0 +1,17 @@
use iris::prelude::*;
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 {
rect(Color::RED).set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+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 }
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+224
View File
@@ -0,0 +1,224 @@
use std::{cell::RefCell, rc::Rc};
use winit::event::WindowEvent;
use iris::prelude::*;
type ClientRsc = DefaultRsc<Client>;
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
info: WeakWidget<Text>,
}
impl DefaultAppState for Client {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let rrect = rect(Color::WHITE).radius(20);
let pad_test = (
rrect.color(Color::BLUE),
(
// The square is one widget and the two shares of the row it
// sits centred in are another: a length is a property of a
// widget, so `.width` here would overwrite the `.sized`.
rrect
.color(Color::RED)
.sized((100, 100))
.center()
.wrapper()
.width(leftover(2)),
(
rrect.color(Color::ORANGE),
rrect.color(Color::LIME).pad(10.0),
)
.span(Dir::RIGHT)
.width(leftover(2)),
rrect.color(Color::YELLOW),
)
.span(Dir::RIGHT)
.pad(10)
.width(leftover(3)),
)
.span(Dir::RIGHT)
.add(rsc);
let span_test = (
rrect.color(Color::GREEN).width(100),
rrect.color(Color::ORANGE),
rrect.color(Color::CYAN),
rrect.color(Color::BLUE).width(rel(0.5)),
rrect.color(Color::MAGENTA).width(100),
rrect.color(Color::RED).width(100),
)
.span(Dir::LEFT)
.add(rsc);
let span_add = Span::empty(Dir::RIGHT).add(rsc);
let add_button = rect(Color::LIME)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("assets/sungals.png"))
.center()
.add_strong(rsc);
span_add(rsc).push(child);
})
.sized((150, 150))
.align(Align::BOT_RIGHT);
let del_button = rect(Color::RED)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop();
})
.sized((150, 150))
.align(Align::BOT_LEFT);
let span_add_test = (span_add, add_button, del_button).stack().add(rsc);
let btext = |content| wtext(content).size(30);
let text_test = (
btext("this is a").align(Align::LEFT),
btext("teeeeeeeest").align(Align::RIGHT),
btext("okkk\nokkkkkk!").align(Align::LEFT),
btext("hmm"),
btext("a"),
(
btext("'").family(Family::Monospace).align(Align::TOP),
btext("'").family(Family::Monospace),
btext(":gamer mode").family(Family::Monospace),
rect(Color::CYAN).sized((10, 10)).center(),
rect(Color::RED).sized((100, 100)).center(),
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP),
)
.span(Dir::RIGHT)
.center(),
wtext("pretty cool right?").size(50),
)
.span(Dir::DOWN)
.add(rsc);
let texts = Span::empty(Dir::DOWN).gap(10).add(rsc);
let msg_area = texts.scrollable().masked().background(rect(Color::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.size(30)
.attr::<Selectable>(())
.on(Submit, move |ctx, rsc| {
let w = ctx.widget;
let content = w.edit(rsc).take();
let text = wtext(content)
.editable(EditMode::MultiLine)
.size(30)
.text_align(Align::LEFT)
.wrap(true)
.attr::<Selectable>(());
let msg_box = text
.background(rect(Color::WHITE.darker(0.5)))
.add_strong(rsc);
texts(rsc).push(msg_box);
})
.add(rsc);
let text_edit_scroll = (
msg_area.height(leftover(1)),
(
Rect::new(Color::WHITE.darker(0.9)),
(
add_text.width(leftover(1)),
Rect::new(Color::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut ClientRsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state);
})
.sized((40, 40)),
)
.span(Dir::RIGHT)
.pad(10),
)
.stack()
.size(StackSize::Child(1))
.layer_offset(1)
.align(Align::BOT),
)
.span(Dir::DOWN)
.add(rsc);
let main = Wrapper::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |color, to: WeakWidget, label| {
let to = to.upgrade(rsc);
let vec = &mut vals.borrow_mut().1;
let i = vec.len();
if vec.is_empty() {
vec.push(None);
main(rsc).set(to);
} else {
vec.push(Some(to));
}
let vals = vals.clone();
let rect = rect(color)
.on(CursorSense::click(), move |ctx, rsc| {
let (prev, vec) = &mut *vals.borrow_mut();
if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h);
*prev = i;
}
ctx.widget(rsc).color = color.darker(0.3);
})
.on(
CursorSense::HoverStart | CursorSense::unclick(),
move |ctx, rsc| {
ctx.widget(rsc).color = color.brighter(0.2);
},
)
.on(CursorSense::HoverEnd, move |ctx, rsc| {
ctx.widget(rsc).color = color;
});
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
};
let tabs = (
switch_button(Color::RED, pad_test, "pad"),
switch_button(Color::GREEN, span_test, "span"),
switch_button(Color::BLUE, span_add_test, "image span"),
switch_button(Color::MAGENTA, text_test, "text layout"),
switch_button(
Color::YELLOW.mul_rgb(0.5),
text_edit_scroll,
"text edit scroll",
),
)
.span(Dir::RIGHT);
let info = wtext("").add(rsc);
let info_sect = info.pad(10).align(Align::RIGHT);
((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect)
.stack()
.set_root(rsc, &mut ui_state);
Self { ui_state, info }
}
fn window_event(
&mut self,
_: WindowEvent,
rsc: &mut DefaultRsc<Self>,
render: &mut UiRenderState,
) {
let new = format!(
"widgets: {}\nactive: {}\ntextures: {}",
rsc.widgets().len(),
render.active_widgets(),
rsc.ui().textures.count(),
);
if new != *rsc.widgets()[self.info].content {
*rsc.widgets_mut()[self.info].content = new;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use iris::prelude::*;
use std::time::Duration;
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 rect = rect(Color::RED).add(rsc);
rect.task_on(CursorSense::click(), async move |mut ctx| {
tokio::time::sleep(Duration::from_secs(1)).await;
ctx.update(move |_, rsc| {
let rect = rect(rsc);
if rect.color == Color::RED {
rect.color = Color::BLUE;
} else {
rect.color = Color::RED;
}
});
})
.set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Text sizing: wrapped text reads the width it is offered, fixed text does
//! not, and both report a height their container lays out around.
use iris::prelude::*;
fn main() {
DefaultApp::<State>::run();
}
#[derive(DefaultUiState)]
struct State {
ui_state: DefaultUiState,
}
const SAMPLE: &str = "Wrapping shapes one source into as many lines as its container \
leaves room for, so the height of a paragraph is an answer rather than a setting, and \
the same words in a narrower box come back taller. Resize the window and watch the \
text below reflow into a different number of lines while nothing about it changes.";
impl DefaultAppState for State {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let panel = || rect(Color::WHITE.darker(0.85));
let wrapped = wtext(SAMPLE)
.size(28)
.wrap(true)
.text_align(Align::LEFT)
.pad(16)
.background(panel());
// Each one takes the whole width, because `text_align` puts the
// glyphs somewhere in the box the text is given and a text that
// reports the width of its own glyphs is given exactly that.
let label = |text: &str, align| wtext(text).size(24).text_align(align).width(rel(1.0));
let aligned = (
label("left", Align::LEFT),
label("centred", Align::H_CENTER),
label("right", Align::RIGHT),
)
.span(Dir::DOWN)
.gap(8)
.pad(16)
.background(panel());
// The same words in half the width, which is a different number of
// lines and so a different height. A declared width only holds along
// a span's own axis, hence the row.
let narrow = (
wtext(SAMPLE)
.size(20)
.wrap(true)
.pad(16)
.background(panel())
.align(Align::TOP)
.width(rel(0.5)),
rect(Color::WHITE.darker(0.95)),
)
.span(Dir::RIGHT);
(wrapped, aligned, narrow)
.span(Dir::DOWN)
.gap(12)
.pad(12)
.set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+49
View File
@@ -0,0 +1,49 @@
use iris::prelude::*;
fn main() {
DefaultApp::<State>::run();
}
#[derive(DefaultUiState)]
struct State {
ui_state: DefaultUiState,
}
type Rsc = DefaultRsc<State>;
#[derive(Clone, Copy, WidgetView)]
struct Test {
#[root]
root: WeakWidget<Rect>,
cur: WeakState<bool>,
}
impl Test {
pub fn new(rsc: &mut Rsc) -> Self {
let root = rect(Color::RED).add(rsc);
let cur = rsc.create_state(root, false);
Self { root, cur }
}
pub fn toggle(&self, rsc: &mut Rsc) {
let cur = &mut rsc[self.cur];
*cur = !*cur;
if *cur {
rsc[self.root].color = Color::BLUE;
} else {
rsc[self.root].color = Color::RED;
}
}
}
impl DefaultAppState for State {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let test = Test::new(rsc);
test.on(CursorSense::click(), move |_, rsc| {
test.toggle(rsc);
})
.set_root(rsc, &mut ui_state);
Self { ui_state }
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "iris-macro"
version.workspace = true
edition.workspace = true
[dependencies]
proc-macro2 = "1.0.103"
quote = "1.0.42"
syn = { version = "2.0.111", features = ["full"] }
[lib]
proc-macro = true
+196
View File
@@ -0,0 +1,196 @@
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{
Attribute, Block, Error, GenericParam, Generics, Ident, ItemStruct, ItemTrait, Signature,
Token, Type, Visibility,
parse::{Parse, ParseStream, Result},
parse_macro_input, parse_quote,
spanned::Spanned,
};
struct Input {
attrs: Vec<Attribute>,
vis: Visibility,
name: Ident,
generics: Generics,
fns: Vec<InputFn>,
}
struct InputFn {
sig: Signature,
body: Block,
}
impl Parse for Input {
fn parse(input: ParseStream) -> Result<Self> {
let attrs = input.call(Attribute::parse_outer)?;
let vis = input.parse()?;
input.parse::<Token![trait]>()?;
let name = input.parse()?;
let generics = input.parse::<Generics>()?;
input.parse::<Token![;]>()?;
let mut fns = Vec::new();
while !input.is_empty() {
let sig = input.parse()?;
let body = input.parse()?;
fns.push(InputFn { sig, body })
}
if !input.is_empty() {
input.error("function expected");
}
Ok(Input {
attrs,
vis,
name,
generics,
fns,
})
}
}
#[proc_macro]
pub fn widget_trait(input: TokenStream) -> TokenStream {
let Input {
attrs,
vis,
name,
mut generics,
fns,
} = parse_macro_input!(input as Input);
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
let impls: Vec<_> = fns
.iter()
.map(|InputFn { sig, body }| quote! { #sig #body })
.collect();
let Some(GenericParam::Type(state)) = generics.params.first() else {
return Error::new(name.span(), "expected state generic parameter")
.into_compile_error()
.into();
};
let state = &state.ident;
generics
.params
.push(parse_quote!(WL: WidgetLike<#state, Tag>));
generics.params.push(parse_quote!(Tag));
let mut trai: ItemTrait = parse_quote!(
#vis trait #name #generics {
#(#sigs;)*
}
);
trai.attrs = attrs;
quote! {
#trai
impl #generics #name<Rsc, WL, Tag> for WL {
#(#impls)*
}
}
.into()
}
#[proc_macro_derive(DefaultUiState, attributes(default_ui_state))]
pub fn derive_default_ui_state(input: TokenStream) -> TokenStream {
let mut output = proc_macro2::TokenStream::new();
let state: ItemStruct = parse_macro_input!(input);
let mut found_attr = false;
let mut state_field = None;
for field in &state.fields {
if !found_attr
&& let Type::Path(path) = &field.ty
&& path.path.is_ident("DefaultUiState")
{
state_field = Some(field);
}
let Some(attr) = field
.attrs
.iter()
.find(|a| a.path().is_ident("default_ui_state"))
else {
continue;
};
if found_attr {
output.extend(
Error::new(
attr.span(),
"cannot have more than one default_ui_state attribute",
)
.into_compile_error(),
);
continue;
}
found_attr = true;
state_field = Some(field);
}
let Some(field) = state_field else {
output.extend(
Error::new(state.ident.span(), "no DefaultUiState field found").into_compile_error(),
);
return output.into();
};
let sname = &state.ident;
let fname = field.ident.as_ref().unwrap();
output.extend(quote! {
impl iris::default::HasDefaultUiState for #sname {
fn default_state(&self) -> &iris::default::DefaultUiState {
&self.#fname
}
fn default_state_mut(&mut self) -> &mut iris::default::DefaultUiState {
&mut self.#fname
}
}
});
output.into()
}
#[proc_macro_derive(WidgetView, attributes(root))]
pub fn derive_widget_view(input: TokenStream) -> TokenStream {
let mut output = proc_macro2::TokenStream::new();
let state: ItemStruct = parse_macro_input!(input);
let mut found_attr = false;
let mut state_field = None;
for field in &state.fields {
let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident("root")) else {
continue;
};
if found_attr {
output.extend(
Error::new(attr.span(), "cannot have more than one root widget")
.into_compile_error(),
);
continue;
}
found_attr = true;
state_field = Some(field);
}
let Some(field) = state_field else {
output.extend(
Error::new(state.ident.span(), "no root widget field found (#[root])")
.into_compile_error(),
);
return output.into();
};
let sname = &state.ident;
let fname = field.ident.as_ref().unwrap();
let fty = &field.ty;
output.extend(quote! {
impl iris::core::WidgetView for #sname {
type Widget = <#fty as iris::core::HasWidget>::Widget;
fn root(&self) -> #fty {
self.#fname
}
}
});
output.into()
}
+31
View File
@@ -0,0 +1,31 @@
# iris
My experimental attempt at a rust ui library (also my first ui library).
It's currently designed around using retained data structures (widgets), rather than diffing generated trees from data like xilem or iced. This is an experiment and I'm not sure if it's a good idea or not.
Examples are in `examples`, eg. `cargo run --example tabs`.
Goals, in general order:
1. does what I want it to (text, images, video, animations)
2. very easy to use ignoring ergonomic ref counting
3. reasonably fast / efficient (a lot faster than electron, save battery life, try to beat iced and xilem)
## dev details
not targeting web rn cause wanna use actual nice gpu features & entire point of this is to make desktop apps / not need a web browser
general ideas trynna use rn / experiment with:
- retained mode
- specifically designed around wgpu so there's no translation
- postfix functions for most things to prevent unreadable indentation (going very well)
- events can be done directly where you draw the widgets
- almost no macros in user code & actual LSP typechecking (variadic generics if you can hear me please save us)
- relative anchor + absolute offset coord system (+ "rest" / leftover during widget layout)
- single threaded ui & pass context around to make non async usage straightforward (pretty unsure about this)
- widgets store outside of the actual rendering so they can be moved around and swapped easily (unsure about this but seems to work good for now)
under heavy initial development so not gonna try to explain status, maybe check TODO for that;
sizable chance it gets a rewrite once I know everything I need and what seems to work best
it's called iris because it's the structure around what you actually want to display and colorful
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Replays `.touch` recordings through Wayland's virtual-pointer protocol;
# headless sway has no input devices for coordinate-driving tools to move.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# Share the harness parser so both ways of replaying read a file the same.
iris = { path = ".." }
wayland-client = { workspace = true }
wayland-protocols-wlr = { workspace = true }
+141
View File
@@ -0,0 +1,141 @@
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
const BTN_LEFT: u32 = 0x110;
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["clippy", "rustfmt"]
+14
View File
@@ -0,0 +1,14 @@
# The compositor `scripts/run-headless.sh` starts, so that an example has a
# surface where there is no display. Nothing here is meant to be looked at
# directly; `grim` is.
#
# No Xwayland: winit talks Wayland natively, so an X server is a second thing
# to go wrong for no gain.
xwayland disable
# The default output, overridden per run by `--mode`. Larger than the window
# an example opens, so nothing is scaled or clipped.
output HEADLESS-1 mode 1920x1200@60Hz
default_border none
focus_follows_mouse no
+182
View File
@@ -0,0 +1,182 @@
#!/bin/sh
# Run an iris example on a machine with no display.
#
# ./scripts/run-headless.sh tabs
# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./scripts/run-headless.sh tabs --replay taps.touch --shot /tmp/tabs.png
# ./scripts/run-headless.sh app --dir ../elsewhere --mode 1080x2424@120Hz
#
# `--dir DIR` names the workspace to build in, defaulting to this one, so a
# project that depends on iris can be run through the same rig. `--bin` runs a
# crate binary rather than an example, and takes its own argv from
# `$RUN_HEADLESS_ARGS`, word-split on purpose.
#
# `--mode` sets the output, for running something at a size other than a
# desktop's -- a phone's, say. Set every run rather than only when it changes:
# the compositor is reused between runs, so a default-shaped run after a
# 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
# `--shot` it also writes `<shot>-before.png` from just before the gesture,
# since "it moved" is a claim about two pictures.
#
# What it supplies is a compositor for winit to open a surface on: a headless
# sway, and `grim` to screenshot it. Sway gets its own socket and runtime
# directory rather than joining whatever else is running, because it tiles --
# adding a window to someone else's compositor resizes theirs.
set -eu
need() {
command -v "$1" >/dev/null 2>&1 || {
echo "run-headless: $1 is not installed ($2)" >&2
exit 127
}
}
need sway "the compositor an example opens its window on"
need swaymsg "sway's control socket"
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
workdir="$root"
cd "$root"
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
resize=""
example=""
kind=example
mode=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--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] [--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"
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, so a second run reuses
# this compositor instead of starting another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$scripts/headless.conf" >"$run/sway.log" 2>&1 &
i=0
while [ $i -lt 20 ]; do
swaymsg -t get_version >/dev/null 2>&1 && break
i=$((i + 1)); sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "run-headless: compositor did not start; see $run/sway.log" >&2
exit 1
}
fi
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
i=0
while [ $i -lt 20 ]; do
[ -s "$run/display" ] && break
i=$((i + 1)); sleep 0.5
done
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
WAYLAND_DISPLAY=$(cat "$run/display")
export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || (cd "$root" && cargo build --bin replay-touch -p rig-input) >&2
cd "$workdir"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
bin="$workdir/target/debug/$example"
else
cargo build --example "$example" "$@" >&2
bin="$workdir/target/debug/examples/$example"
fi
# Deliberately word-split: this is the binary's own argv, not a single path.
# shellcheck disable=SC2086
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
# Wait for the window to be mapped rather than for a number of seconds. A
# fixed sleep took an all-black screenshot the first time this ran, when sway
# had started in the same invocation and had not composited its output yet --
# which is indistinguishable from an app that draws nothing.
i=0
while [ $i -lt 40 ]; do
kill -0 "$pid" 2>/dev/null || break
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
i=$((i + 1)); sleep 0.25
done
i=0
while [ $i -lt "$((seconds * 2))" ]; do
kill -0 "$pid" 2>/dev/null || break
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"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$root/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
status=0
else
wait "$pid" 2>/dev/null || status=$?
echo "run-headless: $example exited early (status ${status:-0})" >&2
status=${status:-1}
fi
echo "--- $example output ---" >&2
cat "$run/$example.log" >&2
exit "$status"
-163
View File
@@ -1,163 +0,0 @@
use std::ops::Range;
use crate::{
primitive::{Axis, Painter, RoundedRectData, UIRegion},
UIColor, Widget, WidgetArrLike, WidgetFn, WidgetId, WidgetLike,
};
#[derive(Clone, Copy)]
pub struct RoundedRect {
pub color: UIColor,
pub radius: f32,
pub thickness: f32,
pub inner_radius: f32,
}
impl RoundedRect {
pub fn color(mut self, color: UIColor) -> Self {
self.color = color;
self
}
}
impl Widget for RoundedRect {
fn draw(&self, painter: &mut Painter) {
painter.write(RoundedRectData {
color: self.color,
radius: self.radius,
thickness: self.thickness,
inner_radius: self.inner_radius,
});
}
}
pub struct Span {
pub elements: Vec<(Range<f32>, WidgetId)>,
pub axis: Axis,
}
impl Widget for Span {
fn draw(&self, painter: &mut Painter) {
for (span, child) in &self.elements {
let mut sub_region = UIRegion::full();
let view = sub_region.axis_mut(self.axis);
*view.top_left.anchor = span.start;
*view.bot_right.anchor = span.end;
painter.draw_within(child, sub_region);
}
}
}
impl Span {
pub fn proportioned<const LEN: usize>(
axis: Axis,
ratios: [impl UINum; LEN],
elements: [WidgetId; LEN],
) -> Self {
let ratios = ratios.map(|r| r.to_f32());
let total: f32 = ratios.iter().sum();
let mut start = 0.0;
Self {
elements: elements
.into_iter()
.zip(ratios)
.map(|(e, r)| {
let end = start + r / total;
let res = (start..end, e);
start = end;
res
})
.collect(),
axis,
}
}
}
pub struct Regioned {
region: UIRegion,
inner: WidgetId,
}
impl Widget for Regioned {
fn draw(&self, painter: &mut Painter) {
painter.region.select(&self.region);
painter.draw(&self.inner);
}
}
pub struct Padding {
left: f32,
right: f32,
top: f32,
bottom: f32,
}
impl Padding {
pub fn uniform(amt: f32) -> Self {
Self {
left: amt,
right: amt,
top: amt,
bottom: amt,
}
}
pub fn region(&self) -> UIRegion {
let mut region = UIRegion::full();
region.top_left.offset.x += self.left;
region.top_left.offset.y += self.top;
region.bot_right.offset.x -= self.right;
region.bot_right.offset.y -= self.bottom;
region
}
}
impl<T: UINum> From<T> for Padding {
fn from(amt: T) -> Self {
Self::uniform(amt.to_f32())
}
}
pub trait WidgetUtil<W> {
fn pad(self, padding: impl Into<Padding>) -> impl WidgetLike<Regioned>;
}
impl<W: Widget, WL: WidgetLike<W>> WidgetUtil<W> for WL {
fn pad(self, padding: impl Into<Padding>) -> impl WidgetLike<Regioned> {
WidgetFn(|ui| Regioned {
region: padding.into().region(),
inner: self.id(ui).erase_type(),
})
}
}
pub trait WidgetArrUtil<const LEN: usize, Ws> {
fn span(self, axis: Axis, ratios: [impl UINum; LEN]) -> impl WidgetLike<Span>;
}
impl<const LEN: usize, Ws, Wa: WidgetArrLike<LEN, Ws>> WidgetArrUtil<LEN, Ws> for Wa {
fn span(self, axis: Axis, ratios: [impl UINum; LEN]) -> impl WidgetLike<Span> {
WidgetFn(move |ui| Span::proportioned(axis, ratios, self.ui(ui).arr))
}
}
pub trait UINum {
fn to_f32(self) -> f32;
}
impl UINum for f32 {
fn to_f32(self) -> f32 {
self
}
}
impl UINum for u32 {
fn to_f32(self) -> f32 {
self as f32
}
}
impl UINum for i32 {
fn to_f32(self) -> f32 {
self as f32
}
}
+60
View File
@@ -0,0 +1,60 @@
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy},
window::WindowId,
};
pub trait AppState {
type Event: 'static;
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self;
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop);
fn event(&mut self, event: Self::Event, event_loop: &ActiveEventLoop);
fn exit(&mut self);
fn run()
where
Self: Sized,
{
App::<Self>::run();
}
}
pub struct App<State: AppState> {
state: Option<State>,
proxy: EventLoopProxy<State::Event>,
}
impl<State: AppState> App<State> {
pub fn run() {
let event_loop = EventLoop::with_user_event().build().unwrap();
let proxy = event_loop.create_proxy();
event_loop
.run_app(&mut App::<State> { state: None, proxy })
.unwrap();
}
}
impl<State: AppState> ApplicationHandler<State::Event> for App<State> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_none() {
let state = State::new(event_loop, self.proxy.clone());
self.state = Some(state);
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
let state = self.state.as_mut().unwrap();
state.window_event(event, event_loop);
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: State::Event) {
let state = self.state.as_mut().unwrap();
state.event(event, event_loop);
}
fn exiting(&mut self, _: &ActiveEventLoop) {
let state = self.state.as_mut().unwrap();
state.exit();
}
}
+80
View File
@@ -0,0 +1,80 @@
use crate::prelude::*;
use std::time::{Duration, Instant};
use winit::dpi::{LogicalPosition, LogicalSize};
pub struct Selector;
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
where
Rsc::State: HasDefaultUiState,
{
type Input = WeakWidget<TextEdit>;
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id).unwrap();
let id_pos = region.top_left;
let container_pos = ctx.data.render.window_region(&container).unwrap().top_left;
// The pointer arrives from the platform in floats; everything
// it is compared against is on the grid.
let pos = (PxVec2::from_f32(ctx.data.pos) + container_pos - id_pos).to_f32();
let size = region.size().to_f32();
select(
rsc,
ctx.data.render,
ctx.state,
id,
pos,
size,
ctx.data.sense.is_dragging(),
);
});
}
}
pub struct Selectable;
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
where
Rsc::State: HasDefaultUiState,
{
type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
select(
rsc,
ctx.data.render,
ctx.state,
id,
ctx.data.pos,
ctx.data.size,
ctx.data.sense.is_dragging(),
);
});
}
}
fn select(
rsc: &mut impl UiRsc,
render: &UiRenderState,
state: &mut impl HasDefaultUiState,
id: WeakWidget<TextEdit>,
pos: Vec2,
size: Vec2,
dragging: bool,
) {
let state = state.default_state_mut();
let now = Instant::now();
let recent = (now - state.last_click) < Duration::from_millis(300);
state.last_click = now;
id.edit(rsc).select(pos, size, dragging, recent);
if let Some(region) = render.window_region(&id) {
state.window.set_ime_allowed(true);
state.window.set_ime_cursor_area(
LogicalPosition::<f32>::from(region.top_left.to_f32().tuple()),
LogicalSize::<f32>::from(region.size().to_f32().tuple()),
);
}
state.focus = Some(id);
}
+9
View File
@@ -0,0 +1,9 @@
use iris_core::Event;
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Submit;
impl Event for Submit {}
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Edited;
impl Event for Edited {}
+78
View File
@@ -0,0 +1,78 @@
use crate::prelude::*;
use winit::{
event::{MouseButton, MouseScrollDelta, WindowEvent},
keyboard::{Key, NamedKey},
};
#[derive(Default)]
pub struct Input {
cursor: CursorState,
pub modifiers: Modifiers,
}
impl Input {
pub fn event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
self.cursor.exists = true;
}
WindowEvent::MouseInput { state, button, .. } => {
let buttons = &mut self.cursor.buttons;
let pressed = state.is_pressed();
match button {
MouseButton::Left => buttons.left.update(pressed),
MouseButton::Right => buttons.right.update(pressed),
MouseButton::Middle => buttons.middle.update(pressed),
_ => (),
}
}
WindowEvent::MouseWheel { delta, .. } => {
let mut delta = match *delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
};
if delta.x == 0.0 && self.modifiers.shift {
delta.x = delta.y;
delta.y = 0.0;
}
self.cursor.scroll_delta = delta;
}
WindowEvent::CursorLeft { .. } => {
self.cursor.exists = false;
self.modifiers.clear();
}
WindowEvent::KeyboardInput { event, .. } => {
if let Key::Named(named) = event.logical_key {
let pressed = event.state.is_pressed();
match named {
NamedKey::Control => {
self.modifiers.control = pressed;
}
NamedKey::Shift => {
self.modifiers.shift = pressed;
}
_ => (),
}
}
}
_ => return false,
}
true
}
pub fn end_frame(&mut self) {
self.cursor.end_frame();
}
}
impl DefaultUiState {
pub fn window_size(&self) -> Vec2 {
let size = self.renderer.window().inner_size();
(size.width, size.height).into()
}
pub fn cursor_state(&self) -> &CursorState {
&self.input.cursor
}
}
+367
View File
@@ -0,0 +1,367 @@
use crate::prelude::*;
use arboard::Clipboard;
use std::{marker::PhantomData, sync::Arc, time::Instant};
use winit::{
event::{Ime, WindowEvent},
event_loop::{ActiveEventLoop, EventLoopProxy},
window::{Window, WindowAttributes},
};
mod app;
mod attr;
mod event;
mod input;
mod render;
mod sense;
mod state;
mod task;
pub use app::*;
pub use attr::*;
pub use event::*;
pub use input::*;
pub use render::*;
pub use sense::*;
pub use state::*;
pub use task::*;
/// Sends an application's own events to its event loop. It wraps the proxy
/// rather than being one because task updates travel the same way: what an
/// application sends is its `Event`, not the loop's whole message type.
pub struct Proxy<State: DefaultAppState>(EventLoopProxy<DefaultEvent<State>>);
impl<State: DefaultAppState> Clone for Proxy<State> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<State: DefaultAppState> Proxy<State> {
pub fn send_event(&self, event: State::Event) {
let _ = self.0.send_event(DefaultEvent::User(event));
}
}
/// What the event loop carries: the application's own events, and the
/// updates tasks send back to the ui thread.
pub enum DefaultEvent<State: DefaultAppState> {
User(State::Event),
Update(Box<dyn TaskUpdate<DefaultRsc<State>>>),
}
impl<State: DefaultAppState> TaskQueue<DefaultRsc<State>> for Proxy<State> {
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<State>>>) {
let _ = self.0.send_event(DefaultEvent::Update(update));
}
}
pub struct DefaultUiState {
pub root: Option<StrongWidget>,
pub renderer: UiRenderer,
pub input: Input,
pub focus: Option<WeakWidget<TextEdit>>,
pub clipboard: Clipboard,
pub window: Arc<Window>,
pub ime: usize,
pub last_click: Instant,
}
impl HasRoot for DefaultUiState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
impl DefaultUiState {
pub fn new(window: impl Into<Arc<Window>>) -> Self {
let window = window.into();
Self {
root: None,
renderer: UiRenderer::new(window.clone()),
window,
input: Input::default(),
clipboard: Clipboard::new().unwrap(),
ime: 0,
last_click: Instant::now(),
focus: None,
}
}
}
pub trait HasDefaultUiState: Sized + 'static {
fn default_state(&self) -> &DefaultUiState;
fn default_state_mut(&mut self) -> &mut DefaultUiState;
}
pub trait DefaultAppState: HasDefaultUiState {
type Event: Send = ();
fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, proxy: Proxy<Self>) -> Self;
#[allow(unused_variables)]
fn event(
&mut self,
event: Self::Event,
rsc: &mut DefaultRsc<Self>,
render: &mut UiRenderState,
) {
}
#[allow(unused_variables)]
fn exit(&mut self, rsc: &mut DefaultRsc<Self>, render: &mut UiRenderState) {}
#[allow(unused_variables)]
fn window_event(
&mut self,
event: WindowEvent,
rsc: &mut DefaultRsc<Self>,
render: &mut UiRenderState,
) {
}
fn window_attributes() -> WindowAttributes {
Default::default()
}
}
pub struct DefaultRsc<State: 'static> {
pub ui: UiData,
pub events: EventManager<Self>,
pub tasks: Tasks<Self>,
pub state: WidgetState,
_state: PhantomData<State>,
}
impl<State> DefaultRsc<State> {
pub fn init(queue: Arc<dyn TaskQueue<Self>>) -> Self {
Self {
ui: Default::default(),
events: Default::default(),
tasks: Tasks::init(queue),
state: Default::default(),
_state: Default::default(),
}
}
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> {
self.state.add(id.id(), data)
}
}
impl<State> UiRsc for DefaultRsc<State> {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
self.state.remove(id);
}
}
impl<State: 'static> HasState for DefaultRsc<State> {
type State = State;
}
impl<State: 'static> HasEvents for DefaultRsc<State> {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
impl<State: 'static> HasTasks for DefaultRsc<State> {
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
&mut self.tasks
}
}
impl<State: 'static> HasWidgetState for DefaultRsc<State> {
fn widget_state(&self) -> &WidgetState {
&self.state
}
fn widget_state_mut(&mut self) -> &mut WidgetState {
&mut self.state
}
}
pub struct DefaultApp<State: DefaultAppState> {
rsc: DefaultRsc<State>,
render: UiRenderState,
state: State,
}
impl<State: DefaultAppState> AppState for DefaultApp<State> {
type Event = DefaultEvent<State>;
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
let window = event_loop
.create_window(State::window_attributes())
.unwrap();
let default_state = DefaultUiState::new(window);
let mut rsc = DefaultRsc::init(Arc::new(Proxy(proxy.clone())));
let state = State::new(default_state, &mut rsc, Proxy(proxy));
let render = UiRenderState::new();
Self { rsc, state, render }
}
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) {
match event {
DefaultEvent::User(event) => self.state.event(event, &mut self.rsc, &mut self.render),
DefaultEvent::Update(update) => update(&mut self.state, &mut self.rsc),
}
self.request_redraw_if_needed();
}
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
let Self { rsc, render, state } = self;
let ui_state = state.default_state_mut();
let input_changed = ui_state.input.event(&event);
let cursor_state = ui_state.cursor_state().clone();
let old = ui_state.focus;
if cursor_state.buttons.left.is_start() {
ui_state.focus = None;
}
if input_changed {
render.run_sensors(rsc, state, cursor_state);
}
let ui_state = state.default_state_mut();
if old != ui_state.focus
&& let Some(old) = old
{
old.edit(rsc).deselect();
}
match &event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
render.update(&ui_state.root, rsc);
ui_state.renderer.update(&mut rsc.ui, render);
ui_state.renderer.draw();
}
WindowEvent::Resized(size) => {
render.resize((size.width, size.height), rsc.widgets_mut());
ui_state.renderer.resize(size)
}
WindowEvent::KeyboardInput { event, .. } => {
if let Some(sel) = ui_state.focus
&& event.state.is_pressed()
{
let mut text = sel.edit(rsc);
match text.apply_event(event, &ui_state.input.modifiers) {
TextInputResult::Unfocus => {
ui_state.focus = None;
ui_state.window.set_ime_allowed(false);
}
TextInputResult::Submit => {
rsc.run_event::<Submit>(sel, (), state);
}
TextInputResult::Paste => {
if let Ok(t) = ui_state.clipboard.get_text() {
text.insert(&t);
}
rsc.run_event::<Edited>(sel, (), state);
}
TextInputResult::Copy(text) => {
if let Err(err) = ui_state.clipboard.set_text(text) {
eprintln!("failed to copy text to clipboard: {err}")
}
}
TextInputResult::Used => {
rsc.run_event::<Edited>(sel, (), state);
}
TextInputResult::Unused => {}
}
}
}
WindowEvent::Ime(ime) => {
if let Some(sel) = ui_state.focus {
let mut text = sel.edit(rsc);
match ime {
Ime::Enabled | Ime::Disabled => (),
Ime::Preedit(content, _pos) => {
// TODO: highlight once that's real
text.replace(ui_state.ime, content);
ui_state.ime = content.chars().count();
}
Ime::Commit(content) => {
text.insert(content);
}
}
}
}
_ => (),
}
state.window_event(event, rsc, render);
self.request_redraw_if_needed();
self.state.default_state_mut().input.end_frame();
}
fn exit(&mut self) {
self.state.exit(&mut self.rsc, &mut self.render);
}
}
impl<State: DefaultAppState> DefaultApp<State> {
fn request_redraw_if_needed(&mut self) {
let ui_state = self.state.default_state_mut();
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
ui_state.renderer.window().request_redraw();
}
}
}
pub trait RscIdx<Rsc> {
type Output;
fn get(self, rsc: &Rsc) -> &Self::Output;
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
}
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> {
type Output = I::Output;
fn index(&self, index: I) -> &Self::Output {
index.get(self)
}
}
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::IndexMut<I> for DefaultRsc<State> {
fn index_mut(&mut self, index: I) -> &mut Self::Output {
index.get_mut(self)
}
}
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
type Output = W;
fn get(self, rsc: &Rsc) -> &Self::Output {
&rsc.ui().widgets[self]
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
&mut rsc.ui_mut().widgets[self]
}
}
impl<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
type Output = T;
fn get(self, rsc: &Rsc) -> &Self::Output {
rsc.widget_state().get(self)
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
rsc.widget_state_mut().get_mut(self)
}
}
+152
View File
@@ -0,0 +1,152 @@
use iris_core::{UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt;
use std::sync::Arc;
use wgpu::*;
use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK;
pub struct UiRenderer {
window: Arc<Window>,
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
encoder: CommandEncoder,
pub ui: UiRenderNode,
}
impl UiRenderer {
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) {
self.ui.update(&self.device, &self.queue, ui, render);
}
pub fn draw(&mut self) {
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture) => texture,
CurrentSurfaceTexture::Suboptimal(texture) => {
self.surface.configure(&self.device, &self.config);
texture
}
CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Lost => {
self.surface.configure(&self.device, &self.config);
return;
}
CurrentSurfaceTexture::Timeout
| CurrentSurfaceTexture::Occluded
| CurrentSurfaceTexture::Validation => return,
};
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
store: StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
self.ui.draw(render_pass);
}
self.queue.submit(std::iter::once(encoder.finish()));
self.window.pre_present_notify();
self.queue.present(output);
}
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
self.ui.resize((size.width, size.height), &self.queue);
}
fn create_encoder(device: &Device) -> CommandEncoder {
device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"),
})
}
pub fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
let instance = Instance::new(InstanceDescriptor {
backends: Backends::PRIMARY,
display: Some(Box::new(window.clone())),
..InstanceDescriptor::new_without_display_handle()
});
let surface = instance
.create_surface(window.clone())
.expect("Could not create window surface!");
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
apply_limit_buckets: false,
})
.block_on()
.expect("Could not get adapter!");
let (device, queue) = adapter
.request_device(&DeviceDescriptor {
required_limits: Limits {
max_buffer_size: 1 << 30,
..Default::default()
},
..Default::default()
})
.block_on()
.expect("Could not get device!");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
color_space: SurfaceColorSpace::Auto,
width: size.width,
height: size.height,
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &config);
Self {
surface,
device,
queue,
config,
encoder,
ui,
window,
}
}
pub fn window(&self) -> &Window {
self.window.as_ref()
}
}
+376
View File
@@ -0,0 +1,376 @@
use crate::prelude::*;
use std::{
ops::{BitOr, Deref, DerefMut},
rc::Rc,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CursorButton {
Left,
Right,
Middle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CursorSense {
PressStart(CursorButton),
Pressing(CursorButton),
PressEnd(CursorButton),
HoverStart,
Hovering,
HoverEnd,
Scroll,
}
#[derive(Clone)]
pub struct CursorSenses(Vec<CursorSense>);
impl Event for CursorSenses {
type Data<'a> = CursorData<'a>;
type Global = Hovered;
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
let mut data = data.clone();
data.sense = sense;
Some(data)
} else {
None
}
}
/// A press or a scroll is used up by whatever answered it, so it stops
/// there. Hovering is not: a cursor resting somewhere goes on resting.
fn consumes(&self, data: &Self::Data<'_>) -> bool {
!data.sense.position_only()
}
}
/// Who the cursor was inside, before and after an input. The difference is
/// whose hover has ended -- including a widget a higher layer has covered,
/// which the walk stops before reaching.
///
/// Two buffers that swap rather than one rebuilt, so an input allocates
/// nothing once they have grown.
#[derive(Default)]
pub struct Hovered {
was: Vec<WidgetId>,
now: Vec<WidgetId>,
}
impl CursorSense {
pub fn click() -> Self {
Self::PressStart(CursorButton::Left)
}
pub fn click_or_drag() -> CursorSenses {
Self::click() | Self::Pressing(CursorButton::Left)
}
pub fn unclick() -> Self {
Self::PressEnd(CursorButton::Left)
}
pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left))
}
/// False if the sense is a button or a scroll, true if it is only about
/// where the cursor is.
fn position_only(&self) -> bool {
matches!(self, Self::HoverStart | Self::Hovering | Self::HoverEnd)
}
}
#[derive(Default, Clone)]
pub struct CursorState {
pub pos: Vec2,
pub exists: bool,
pub buttons: CursorButtons,
pub scroll_delta: Vec2,
}
#[derive(Default, Clone)]
pub struct CursorButtons {
pub left: ActivationState,
pub middle: ActivationState,
pub right: ActivationState,
}
impl CursorButtons {
pub fn select(&self, button: &CursorButton) -> &ActivationState {
match button {
CursorButton::Left => &self.left,
CursorButton::Right => &self.right,
CursorButton::Middle => &self.middle,
}
}
pub fn end_frame(&mut self) {
self.left.end_frame();
self.middle.end_frame();
self.right.end_frame();
}
pub fn iter(&self) -> impl Iterator<Item = (CursorButton, &ActivationState)> {
[
CursorButton::Left,
CursorButton::Middle,
CursorButton::Right,
]
.into_iter()
.map(|b| (b, self.select(&b)))
}
}
impl CursorState {
/// True if the cursor is only reporting where it is: no button and no
/// scroll this frame.
pub fn position_only(&self) -> bool {
self.scroll_delta == Vec2::ZERO && self.buttons.iter().all(|(_, state)| state.is_off())
}
pub fn end_frame(&mut self) {
self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum ActivationState {
Start,
On,
End,
#[default]
Off,
}
/// this and other similar stuff has a generic
/// because I kind of want to make CursorModule generic
/// or basically have some way to have custom senses
/// that depend on active widget positions
/// but I'm not sure how or if worth it
pub struct Sensor<Ctx: HasEvents, Data> {
pub senses: CursorSenses,
pub f: Rc<dyn EventFn<Ctx, Data>>,
}
pub type SenseShape = UiRegion;
#[derive(Clone)]
pub struct CursorData<'a> {
/// where this widget was hit
pub pos: Vec2,
pub size: Vec2,
pub scroll_delta: Vec2,
pub hover: ActivationState,
pub cursor: CursorState,
/// the first sense that triggered this
pub sense: CursorSense,
pub render: &'a UiRenderState,
}
pub trait SensorUi {
fn run_sensors<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
);
}
impl SensorUi for UiRenderState {
fn run_sensors<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
) {
// in order to remove this take, need to store active list in UiRenderState somehow
// this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there?
let active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
let mut hovered = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().global);
hovered.now.clear();
let position_only = cursor.position_only();
let region_of = |id| self.window_region(&id);
for layer in self.layers.indices().rev() {
let mut consumed = false;
for id in active.get(&layer).into_flat_iter().map(|(id, _)| *id) {
let Some(region) = region_of(id) else {
continue;
};
if !cursor.exists || !region.contains(PxVec2::from_f32(cursor.pos)) {
continue;
}
hovered.now.push(id);
let hover = match hovered.was.contains(&id) {
true => ActivationState::On,
false => ActivationState::Start,
};
// A press or a scroll stops where something answered it, so a
// button over a list does not swallow the list's scrolling.
consumed |= deliver(self, rsc, state, id, hover, &cursor, region);
// A cursor doing neither stops at whatever it is over, so
// hovering does not reach through.
consumed |= position_only;
}
// Applied after the layer, never during it: senses on one layer do
// not block each other.
if consumed {
break;
}
}
// Whatever the cursor was inside and is not now, whether it left or a
// layer above took the input before the walk reached it. A widget that
// stopped being drawn has no region to report and is simply dropped.
for &id in &hovered.was {
if !hovered.now.contains(&id)
&& let Some(region) = region_of(id)
{
deliver(self, rsc, state, id, ActivationState::End, &cursor, region);
}
}
std::mem::swap(&mut hovered.was, &mut hovered.now);
let senses = rsc.events_mut().get_type::<CursorSense>();
senses.active = active;
senses.global = hovered;
}
}
/// Runs one widget's cursor senses, and says whether they used up the input.
fn deliver<Rsc: HasEvents>(
render: &UiRenderState,
rsc: &mut Rsc,
state: &mut Rsc::State,
id: WidgetId,
hover: ActivationState,
cursor: &CursorState,
region: PixelRegion,
) -> bool {
let data = CursorData {
pos: cursor.pos - region.top_left.to_f32(),
size: region.size().to_f32(),
scroll_delta: cursor.scroll_delta,
hover,
cursor: cursor.clone(),
// this does not have any meaning;
// might wanna set up Event to have a prepare stage
sense: CursorSense::Hovering,
render,
};
rsc.run_event::<CursorSense>(id, data, state)
}
pub fn should_run(
senses: &CursorSenses,
cursor: &CursorState,
hover: ActivationState,
) -> Option<CursorSense> {
for sense in senses.iter() {
// A widget the cursor is no longer inside senses only its position:
// the press that ended its hover landed on something else.
if !hover.is_on() && !sense.position_only() {
continue;
}
if match sense {
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
CursorSense::HoverStart => hover.is_start(),
CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
} {
return Some(*sense);
}
}
None
}
impl ActivationState {
pub fn is_start(&self) -> bool {
*self == Self::Start
}
pub fn is_on(&self) -> bool {
*self == Self::Start || *self == Self::On
}
pub fn is_end(&self) -> bool {
*self == Self::End
}
pub fn is_off(&self) -> bool {
*self == Self::End || *self == Self::Off
}
pub fn update(&mut self, on: bool) {
*self = match *self {
Self::Start => match on {
true => Self::On,
false => Self::End,
},
Self::On => match on {
true => Self::On,
false => Self::End,
},
Self::End => match on {
true => Self::Start,
false => Self::Off,
},
Self::Off => match on {
true => Self::Start,
false => Self::Off,
},
}
}
pub fn end_frame(&mut self) {
match self {
Self::Start => *self = Self::On,
Self::End => *self = Self::Off,
_ => (),
}
}
}
impl EventLike for CursorSense {
type Event = CursorSenses;
fn into_event(self) -> Self::Event {
self.into()
}
}
impl Deref for CursorSenses {
type Target = Vec<CursorSense>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for CursorSenses {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl From<CursorSense> for CursorSenses {
fn from(val: CursorSense) -> Self {
CursorSenses(vec![val])
}
}
impl BitOr for CursorSense {
type Output = CursorSenses;
fn bitor(self, rhs: Self) -> Self::Output {
CursorSenses(vec![self, rhs])
}
}
impl BitOr<CursorSense> for CursorSenses {
type Output = Self;
fn bitor(mut self, rhs: CursorSense) -> Self::Output {
self.0.push(rhs);
self
}
}
+75
View File
@@ -0,0 +1,75 @@
use iris_core::{
WidgetId,
util::{HashMap, HashSet},
};
use std::{
any::{Any, TypeId},
marker::PhantomData,
};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Key {
id: WidgetId,
ty: TypeId,
i: usize,
}
#[derive(Default)]
pub struct WidgetState {
widgets: HashMap<WidgetId, HashSet<(TypeId, usize)>>,
counts: HashMap<(WidgetId, TypeId), usize>,
map: HashMap<Key, Box<dyn Any>>,
}
impl WidgetState {
pub fn new() -> Self {
Self::default()
}
pub fn add<T: 'static>(&mut self, id: WidgetId, data: T) -> WeakState<T> {
let ty = TypeId::of::<T>();
let count = self.counts.entry((id, ty)).or_default();
let i = *count;
let key = Key { ty, i, id };
self.map.insert(key, Box::new(data));
self.widgets.entry(id).or_default().insert((ty, i));
*count += 1;
WeakState {
key,
_pd: PhantomData,
}
}
pub fn remove(&mut self, id: WidgetId) {
for &(ty, i) in self.widgets.get(&id).into_iter().flatten() {
self.map.remove(&Key { id, ty, i });
}
}
pub fn get<T: 'static>(&self, state: WeakState<T>) -> &T {
self.map.get(&state.key).unwrap().downcast_ref().unwrap()
}
pub fn get_mut<T: 'static>(&mut self, state: WeakState<T>) -> &mut T {
self.map
.get_mut(&state.key)
.unwrap()
.downcast_mut()
.unwrap()
}
}
#[derive(Clone, Copy)]
pub struct WeakState<T> {
key: Key,
_pd: PhantomData<T>,
}
pub trait HasWidgetState {
fn widget_state(&self) -> &WidgetState;
fn widget_state_mut(&mut self) -> &mut WidgetState;
}
impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState<T> {
type Output = &'a mut T;
extern "rust-call" fn call_once(self, (state,): (&'a mut WidgetState,)) -> Self::Output {
state.get_mut(self)
}
}
+64
View File
@@ -0,0 +1,64 @@
use iris_core::HasState;
use std::{pin::Pin, sync::Arc};
use tokio::{
runtime::Runtime,
sync::mpsc::{
UnboundedReceiver as AsyncReceiver, UnboundedSender as AsyncSender,
unbounded_channel as async_channel,
},
};
pub trait TaskUpdate<Rsc: HasState>: FnOnce(&mut Rsc::State, &mut Rsc) + Send {}
impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc> for F {}
/// Hands an update from a task to the thread that owns the ui. Delivery and
/// waking are one act: a host posts the update as a message its loop already
/// carries, so nothing has to wake the loop separately, or claim a redraw to
/// be looked at.
pub trait TaskQueue<Rsc: HasState>: Send + Sync + 'static {
fn send(&self, update: Box<dyn TaskUpdate<Rsc>>);
}
pub struct Tasks<Rsc: HasState> {
start: AsyncSender<BoxTask>,
queue: Arc<dyn TaskQueue<Rsc>>,
}
pub struct TaskCtx<Rsc: HasState> {
queue: Arc<dyn TaskQueue<Rsc>>,
}
impl<Rsc: HasState> TaskCtx<Rsc> {
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
self.queue.send(Box::new(f));
}
}
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
impl<Rsc: HasState> Tasks<Rsc> {
pub fn init(queue: Arc<dyn TaskQueue<Rsc>>) -> Self {
let (start, start_recv) = async_channel();
std::thread::spawn(|| {
let rt = Runtime::new().unwrap();
rt.block_on(listen(start_recv))
});
Self { start, queue }
}
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
where
F::CallOnceFuture: Send,
{
let queue = self.queue.clone();
let _ = self.start.send(Box::pin(async move {
task(TaskCtx { queue }).await;
}));
}
}
async fn listen(mut recv: AsyncReceiver<BoxTask>) {
while let Some(task) = recv.recv().await {
tokio::spawn(task);
}
}
+87
View File
@@ -0,0 +1,87 @@
use iris_core::*;
use iris_macro::*;
use std::sync::Arc;
use crate::default::{TaskCtx, TaskUpdate, Tasks};
pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
fn on<E: EventLike>(
self,
event: E,
f: impl for<'a> WidgetEventFn<Rsc, <E::Event as Event>::Data<'a>, Self::Widget>,
) -> impl WidgetIdFn<Rsc, Self::Widget> {
move |rsc| {
let id = self.add(rsc);
rsc.register_event(id, event.into_event(), move |ctx, rsc| {
f(
EventIdCtx {
widget: id,
state: ctx.state,
data: ctx.data,
},
rsc,
);
});
id
}
}
}
impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {}
widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
self,
event: E,
f: F,
) -> impl WidgetIdFn<Rsc, WL::Widget>
where <E::Event as Event>::Data<'a>: Send,
for<'b> F::CallRefFuture<'b>: Send,
{
let f = Arc::new(f);
move |rsc| {
let id = self.add(rsc);
rsc.register_event(id, event.into_event(), move |_, rsc| {
let f = f.clone();
rsc.tasks_mut().spawn(async move |task| {
f(AsyncEventIdCtx {
widget: id,
task,
}).await;
});
});
id
}
}
}
pub trait HasTasks: Sized + HasState + HasEvents {
fn tasks_mut(&mut self) -> &mut Tasks<Self>;
fn spawn_task<F: AsyncFnOnce(TaskCtx<Self>) + 'static + std::marker::Send>(&mut self, task: F)
where
F::CallOnceFuture: Send,
{
self.tasks_mut().spawn(task);
}
}
pub trait AsyncWidgetEventFn<Rsc: HasEvents, W: ?Sized>:
AsyncFn(AsyncEventIdCtx<Rsc, W>) + Send + Sync + 'static
{
}
impl<Rsc: HasEvents, F: AsyncFn(AsyncEventIdCtx<Rsc, W>) + Send + Sync + 'static, W: ?Sized>
AsyncWidgetEventFn<Rsc, W> for F
{
}
pub struct AsyncEventIdCtx<Rsc: HasEvents, W: ?Sized> {
pub widget: WeakWidget<W>,
task: TaskCtx<Rsc>,
}
impl<Rsc: HasEvents, W: ?Sized> AsyncEventIdCtx<Rsc, W> {
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
self.task.update(f);
}
}
+333
View File
@@ -0,0 +1,333 @@
//! A ui with no window: build a tree, run frames, move a pointer, and read
//! back where widgets landed.
//!
//! It does not draw. A claim about pixels still needs a real surface.
use crate::prelude::*;
use std::{
sync::{
Arc,
mpsc::{Receiver, SyncSender, sync_channel},
},
time::Duration,
};
/// There is no loop here to post to, so updates queue until the test asks
/// for them.
struct Queue(SyncSender<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>);
impl TaskQueue<DefaultRsc<HarnessState>> for Queue {
fn send(&self, update: Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>) {
let _ = self.0.send(update);
}
}
/// `assert_eq!` for where a frame put a widget, written as its two corners.
#[macro_export]
macro_rules! assert_corners {
($harness:expr, $id:expr, ($x0:expr, $y0:expr), ($x1:expr, $y1:expr)) => {
assert_eq!(
$harness.region(&$id).expect("widget drew nothing"),
$crate::core::PixelRegion {
top_left: $crate::core::PxVec2::new(
$crate::core::Px::from_f32($x0 as f32),
$crate::core::Px::from_f32($y0 as f32),
),
bot_right: $crate::core::PxVec2::new(
$crate::core::Px::from_f32($x1 as f32),
$crate::core::Px::from_f32($y1 as f32),
),
}
);
};
}
pub use crate::assert_corners;
/// One replayed pointer sample, cut down to what a window delivers: where the
/// pointer is, and whether the button changed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TouchAction {
Down,
Move,
Up,
}
impl TouchAction {
fn parse(word: &str) -> Option<Self> {
match word {
"down" => Some(Self::Down),
"move" => Some(Self::Move),
"up" => Some(Self::Up),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TouchSample {
pub t_ms: u64,
pub action: TouchAction,
pub pos: Vec2,
}
/// A recorded gesture, in the output's own pixels: `<ms> down|move|up <x> <y>`
/// a line, `#` to end of line ignored.
///
/// One parser for both ways of replaying a recording -- into a harness, and
/// into a real window -- so the two cannot read the same file differently.
pub struct TouchScript {
pub samples: Vec<TouchSample>,
}
impl TouchScript {
pub fn parse(text: &str) -> Result<Self, String> {
let mut samples: Vec<TouchSample> = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
let mut words = line.split_whitespace();
let (Some(t), Some(action), Some(x), Some(y), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) else {
return Err(at("expected `t_ms action x y`"));
};
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
let action =
TouchAction::parse(action).ok_or_else(|| at("action is not down/move/up"))?;
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
if let Some(last) = samples.last()
&& t_ms < last.t_ms
{
return Err(at("samples must be in time order"));
}
samples.push(TouchSample {
t_ms,
action,
pos: Vec2::new(x, y),
});
}
Ok(Self { samples })
}
}
#[derive(Default)]
pub struct HarnessState {
pub root: Option<StrongWidget>,
}
impl HasRoot for HarnessState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
pub struct Harness {
pub rsc: DefaultRsc<HarnessState>,
pub render: UiRenderState,
pub state: HarnessState,
updates: Receiver<Box<dyn TaskUpdate<DefaultRsc<HarnessState>>>>,
cursor: CursorState,
}
impl Harness {
/// `size` is the output in physical pixels.
pub fn new(size: impl Into<Vec2>) -> Self {
// A `TaskQueue` must be `Sync`, which `mpsc::Sender` is not; the
// bound that comes with `SyncSender` is far past anything a test
// leaves unread.
let (send, updates) = sync_channel(1024);
let mut rsc = DefaultRsc::init(Arc::new(Queue(send)));
let mut render = UiRenderState::new();
render.resize(size, rsc.widgets_mut());
Self {
rsc,
render,
state: HarnessState::default(),
updates,
cursor: CursorState::default(),
}
}
pub fn size(&self) -> Vec2 {
self.render.output_size().to_f32()
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.render.resize(size, self.rsc.widgets_mut());
}
/// Changes a length rule after the fact, the way `.width()` sets one.
pub fn set_len(&mut self, id: impl IdLike, axis: Axis, len: impl Into<LayoutLen>) {
self.rsc
.widgets_mut()
.set_size_rule(id, axis, SizeRule::Exact(len.into()));
}
/// Sets the root and lays it out, so a pointer event has something to hit.
pub fn set_root<T>(&mut self, widget: impl WidgetLike<DefaultRsc<HarnessState>, T>) {
widget.set_root(&mut self.rsc, &mut self.state);
self.frame();
}
pub fn needs_redraw(&self) -> bool {
self.render
.needs_redraw(&self.state.root, self.rsc.widgets())
}
pub fn apply_updates(&mut self) -> usize {
let mut applied = 0;
while let Ok(update) = self.updates.try_recv() {
update(&mut self.state, &mut self.rsc);
applied += 1;
}
applied
}
/// Waits for a task's first update, then applies everything waiting.
/// False if none arrived in time.
#[must_use]
pub fn await_update(&mut self, timeout: Duration) -> bool {
let Ok(update) = self.updates.recv_timeout(timeout) else {
return false;
};
update(&mut self.state, &mut self.rsc);
self.apply_updates();
true
}
/// Lays the tree out and builds its primitives.
pub fn frame(&mut self) {
self.apply_updates();
self.render.update(&self.state.root, &mut self.rsc);
}
/// Where the last frame put a widget, or `None` if it drew nothing.
pub fn region(&self, id: &impl IdLike) -> Option<PixelRegion> {
self.render.window_region(id)
}
pub fn move_to(&mut self, pos: impl Into<Vec2>) {
self.cursor.pos = pos.into();
self.cursor.exists = true;
self.sense();
}
pub fn leave(&mut self) {
self.cursor.exists = false;
self.sense();
}
pub fn press(&mut self, button: CursorButton) {
self.button(button).update(true);
self.sense();
}
pub fn release(&mut self, button: CursorButton) {
self.button(button).update(false);
self.sense();
}
/// A wheel carries no position, so this goes wherever the cursor was last
/// moved to -- nowhere, until it has been moved.
pub fn scroll(&mut self, delta: impl Into<Vec2>) {
self.cursor.scroll_delta = delta.into();
self.sense();
}
pub fn click(&mut self, pos: impl Into<Vec2>) {
self.move_to(pos);
self.press(CursorButton::Left);
self.release(CursorButton::Left);
}
/// Drives a recorded gesture through the harness.
pub fn replay(&mut self, script: &TouchScript) {
for sample in &script.samples {
match sample.action {
TouchAction::Down => {
self.move_to(sample.pos);
self.press(CursorButton::Left);
}
TouchAction::Move => self.move_to(sample.pos),
TouchAction::Up => {
self.move_to(sample.pos);
self.release(CursorButton::Left);
}
}
}
}
fn button(&mut self, button: CursorButton) -> &mut ActivationState {
let buttons = &mut self.cursor.buttons;
match button {
CursorButton::Left => &mut buttons.left,
CursorButton::Middle => &mut buttons.middle,
CursorButton::Right => &mut buttons.right,
}
}
/// Dispatches against the layout of the last frame, which is what a
/// window delivers input against too.
fn sense(&mut self) {
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor);
self.cursor.end_frame();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Result<Vec<(u64, TouchAction, f32, f32)>, String> {
Ok(TouchScript::parse(text)?
.samples
.iter()
.map(|s| (s.t_ms, s.action, s.pos.x, s.pos.y))
.collect())
}
#[test]
fn a_recording_is_time_action_and_a_point() {
assert_eq!(
parse("0 down 10 20\n16 move 10.5 24\n32 up 10.5 24").unwrap(),
[
(0, TouchAction::Down, 10.0, 20.0),
(16, TouchAction::Move, 10.5, 24.0),
(32, TouchAction::Up, 10.5, 24.0),
]
);
}
#[test]
fn blank_lines_and_comments_are_not_samples() {
assert_eq!(
parse("# a flick\n\n 0 down 1 2 # the finger lands\n\n").unwrap(),
[(0, TouchAction::Down, 1.0, 2.0)]
);
}
#[test]
fn a_recording_that_goes_backwards_is_rejected() {
// Replay waits out the gap between samples, so time running backwards
// would silently become no wait at all.
let err = parse("16 down 1 2\n0 up 1 2").unwrap_err();
assert!(err.contains("time order"), "{err}");
}
#[test]
fn a_line_that_is_not_a_sample_says_which_line() {
let err = parse("0 down 1 2\n16 wiggle 1 2").unwrap_err();
assert!(err.contains("line 2"), "{err}");
assert!(err.contains("down/move/up"), "{err}");
}
}
-8
View File
@@ -1,8 +0,0 @@
mod ui;
mod widget;
pub use ui::*;
pub use widget::*;
use crate::primitive::Color;
pub type UIColor = Color<u8>;
-101
View File
@@ -1,101 +0,0 @@
use crate::{
primitive::{Painter, Primitives},
util::{IDTracker, ID},
HashMap, Widget, WidgetId, WidgetLike, WidgetRef,
};
use std::{
any::{Any, TypeId},
cell::RefCell,
rc::Rc,
};
pub struct UI {
ids: IDTracker,
base: Option<WidgetId>,
pub widgets: Widgets,
}
pub struct Widgets(HashMap<ID, Box<dyn Widget>>);
#[derive(Clone)]
pub struct UIBuilder {
ui: Rc<RefCell<UI>>,
}
impl From<UI> for UIBuilder {
fn from(ui: UI) -> Self {
UIBuilder {
ui: Rc::new(RefCell::new(ui)),
}
}
}
impl UIBuilder {
pub fn add<W: Widget>(&mut self, w: W) -> WidgetRef<W> {
WidgetRef::new(self.clone(), [self.push(w)])
}
pub fn push<W: Widget>(&mut self, w: W) -> WidgetId {
let mut ui = self.ui.borrow_mut();
let id = ui.ids.next();
ui.widgets.insert(id.duplicate(), w);
WidgetId::new(id, TypeId::of::<W>())
}
pub fn finish<WL: WidgetLike<W>, W>(mut self, base: WL) -> UI {
let base = base.id(&mut self).erase_type();
let mut ui = Rc::into_inner(self.ui).unwrap().into_inner();
ui.base = Some(base);
ui
}
}
impl UI {
pub fn build() -> UIBuilder {
Self::empty().into()
}
pub fn empty() -> Self {
Self {
ids: IDTracker::new(),
base: None,
widgets: Widgets::new(),
}
}
pub fn to_primitives(&self) -> Primitives {
let mut painter = Painter::new(&self.widgets);
if let Some(base) = &self.base {
painter.draw(base);
}
painter.finish()
}
}
impl Widgets {
fn new() -> Self {
Self(HashMap::new())
}
pub fn get(&self, id: &WidgetId) -> &dyn Widget {
self.0.get(&id.id).unwrap().as_ref()
}
pub fn get_mut<W: Widget>(&mut self, id: &WidgetId<W>) -> Option<&mut W> {
self.0.get_mut(&id.id).unwrap().as_any_mut().downcast_mut()
}
pub fn insert(&mut self, id: ID, widget: impl Widget) {
self.0.insert(id, Box::new(widget));
}
pub fn insert_any(&mut self, id: ID, widget: Box<dyn Widget>) {
self.0.insert(id, widget);
}
}
impl dyn Widget {
pub fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
-157
View File
@@ -1,157 +0,0 @@
use std::{
any::{Any, TypeId},
marker::PhantomData,
};
use crate::{primitive::Painter, util::ID, UIBuilder};
pub trait Widget: 'static + Any {
fn draw(&self, painter: &mut Painter);
}
impl<W: Widget> Widget for (W,) {
fn draw(&self, painter: &mut Painter) {
self.0.draw(painter);
}
}
#[derive(Eq, Hash, PartialEq, Debug)]
pub struct WidgetId<W = ()> {
pub(super) ty: TypeId,
pub(super) id: ID,
_pd: PhantomData<W>,
}
// TODO: temp
impl Clone for WidgetId {
fn clone(&self) -> Self {
Self {
ty: self.ty,
id: self.id.duplicate(),
_pd: self._pd,
}
}
}
impl<W> WidgetId<W> {
pub(super) fn new(id: ID, ty: TypeId) -> Self {
Self {
ty,
id,
_pd: PhantomData,
}
}
pub fn erase_type(self) -> WidgetId<()> {
self.cast_type()
}
fn cast_type<W2>(self) -> WidgetId<W2> {
WidgetId {
ty: self.ty,
id: self.id,
_pd: PhantomData,
}
}
}
pub trait WidgetLike<W> {
fn id(self, ui: &mut UIBuilder) -> WidgetId<W>;
}
/// wouldn't be needed if negative trait bounds & disjoint impls existed
pub struct WidgetFn<F: FnOnce(&mut UIBuilder) -> W, W>(pub F);
impl<W: Widget, F: FnOnce(&mut UIBuilder) -> W> WidgetLike<W> for WidgetFn<F, W> {
fn id(self, ui: &mut UIBuilder) -> WidgetId<W> {
let w = (self.0)(ui);
ui.add(w).to_id()
}
}
impl<W: Widget> WidgetLike<W> for W {
fn id(self, ui: &mut UIBuilder) -> WidgetId<W> {
ui.add(self).to_id()
}
}
impl<W> WidgetLike<W> for WidgetId<W> {
fn id(self, _: &mut UIBuilder) -> WidgetId<W> {
self
}
}
impl<W> WidgetLike<W> for WidgetArr<1, (W,)> {
fn id(self, _: &mut UIBuilder) -> WidgetId<W> {
let [id] = self.arr;
id.cast_type()
}
}
pub struct WidgetArr<const LEN: usize, Ws> {
pub ui: UIBuilder,
pub arr: [WidgetId<()>; LEN],
_pd: PhantomData<Ws>,
}
impl<const LEN: usize, Ws> WidgetArr<LEN, Ws> {
pub fn new(ui: UIBuilder, arr: [WidgetId<()>; LEN]) -> Self {
Self {
ui,
arr,
_pd: PhantomData,
}
}
}
pub type WidgetRef<W> = WidgetArr<1, (W,)>;
impl<W> WidgetRef<W> {
pub fn handle(&self) -> WidgetId<W> {
let [id] = &self.arr;
id.clone().cast_type()
}
pub fn to_id(self) -> WidgetId<W> {
let [id] = self.arr;
id.cast_type()
}
}
pub trait WidgetArrLike<const LEN: usize, Ws> {
fn ui(self, ui: &mut UIBuilder) -> WidgetArr<LEN, Ws>;
}
impl<const LEN: usize, Ws> WidgetArrLike<LEN, Ws> for WidgetArr<LEN, Ws> {
fn ui(self, _: &mut UIBuilder) -> WidgetArr<LEN, Ws> {
self
}
}
// I hate this language it's so bad why do I even use it
macro_rules! impl_node_arr {
($n:expr;$($T:tt)*) => {
impl<$($T,${concat($T,$T)}: WidgetLike<$T>,)*> WidgetArrLike<$n, ($($T,)*)> for ($(${concat($T,$T)},)*) {
#[allow(unused_variables)]
fn ui(self, ui: &mut UIBuilder) -> WidgetArr<$n, ($($T,)*)> {
#[allow(non_snake_case)]
let ($($T,)*) = self;
WidgetArr::new(
ui.clone(),
[$($T.id(ui).cast_type(),)*],
)
}
}
};
}
impl_node_arr!(1;A);
impl_node_arr!(2;A B);
impl_node_arr!(3;A B C);
impl_node_arr!(4;A B C D);
impl_node_arr!(5;A B C D E);
impl_node_arr!(6;A B C D E F);
impl_node_arr!(7;A B C D E F G);
impl_node_arr!(8;A B C D E F G H);
impl_node_arr!(9;A B C D E F G H I);
impl_node_arr!(10;A B C D E F G H I J);
impl_node_arr!(11;A B C D E F G H I J K);
impl_node_arr!(12;A B C D E F G H I J K L);
+24 -13
View File
@@ -1,16 +1,27 @@
#![feature(macro_metavar_expr_concat)]
#![feature(const_ops)]
#![feature(const_trait_impl)]
#![feature(const_from)]
#![feature(trait_alias)]
#![feature(unboxed_closures)]
#![feature(fn_traits)]
#![feature(associated_type_defaults)]
#![feature(unsize)]
#![feature(option_into_flat_iter)]
#![feature(async_fn_traits)]
mod layout;
mod render;
mod util;
mod base;
pub mod default;
pub mod event;
pub mod harness;
pub mod random;
pub mod widget;
pub use layout::*;
pub use render::*;
pub use base::*;
pub use iris_core as core;
pub use iris_macro as macros;
pub type HashMap<K, V> = std::collections::HashMap<K, V>;
pub mod prelude {
use super::*;
pub use default::*;
pub use event::*;
pub use iris_core::*;
pub use iris_macro::*;
pub use widget::*;
pub use iris_core::util::Vec2;
pub use len_fns::*;
}
-5
View File
@@ -1,5 +0,0 @@
mod testing;
fn main() {
testing::main();
}
+931
View File
@@ -0,0 +1,931 @@
//! 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 widget carrying a size rule, by axis.
pub type Lens = [Option<LayoutLen>; 2];
/// Where one widget carrying an alignment sits, by axis. `None` uses the
/// centered default.
pub type Aligns = [Option<AxisAlign>; 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 rules were put on.
pub sizes: HashMap<usize, Lens>,
/// Which children a span has, by the order the spans were made.
pub spans: HashMap<usize, SpanEdit>,
/// Alignments, by the order they were put on.
pub aligns: HashMap<usize, Aligns>,
/// Which widgets own a movable region, by the order they were offered
/// one. Region nodes change what a move writes and how deep a primitive's
/// chain is, so a tree that never grows one leaves both untested.
pub nodes: HashMap<usize, bool>,
/// Whether a [`Branch`] takes the side it would take at any measurement,
/// rather than the side the one it made says. The oracle wants the
/// measured side -- that is the whole point of a branch, and how a widget
/// believing a measurement a cold start would not have given it becomes a
/// different tree. A rig measuring cost wants this instead: a fixture
/// whose shape moves with the thing being measured cannot be compared
/// with itself across a change to it, and seed 1 at depth 8 went from 88
/// drawn widgets and 2,298 primitive writes a frame to 115 and 8,209
/// across fixed point, which is three and a half times the work behind a
/// number read as three and a half times the cost.
pub fixed_branches: bool,
}
#[derive(Default, Clone)]
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<WidgetId>,
pub aligned: Vec<WidgetId>,
pub nodes: Vec<WidgetId>,
pub spans: Vec<Spanned>,
pub scrolls: Vec<WeakWidget<Scroll>>,
}
/// 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 cut = Len::from_parts(Rel::ZERO, Px::from_int(40));
let top = UiSpan::new(Len::ZERO, cut).shifted_desc();
let measured = painter
.widget_at(&self.probe, top.on_axis(Axis::Y))
.len(Axis::X);
let len = measured.apply_leftover();
let px = painter.to_px(len, Axis::X);
// The range it actually branched on, said the way a container says
// one: pinning the window instead would redraw this widget on every
// resize, which is a fixture that never exercises reuse.
let threshold = Px::from_f32(self.threshold);
let holds = match px > threshold {
true => Holds::from(threshold + Px::STEP..=Px::MAX),
false => Holds::from(Px::MIN..=threshold),
};
painter.window_holds(Axis::X, holds.through(len));
let below = UiSpan::new(cut, painter.region_len(Axis::Y)).shifted_desc();
let place = below.on_axis(Axis::Y);
match px > threshold {
true => painter.widget_at(&self.wide, place),
false => painter.widget_at(&self.narrow, place),
};
Size::LEFTOVER
}
fn size_hint(&self, _: Axis) -> Option<LayoutLen> {
Some(LayoutLen::LEFTOVER)
}
}
pub struct Spanned {
pub id: WeakWidget<Span>,
/// Everything made for this span that it does not hold -- spares never
/// attached and children detached alike. A widget belongs to one parent,
/// and one that belongs to nobody still has to be held here: dropping
/// the last share of it frees its id for the next widget to be given,
/// which puts two trees out of step.
pub spares: Vec<StrongWidget>,
/// How many children it was grown with, before any edit.
pub grown: usize,
}
/// A tree described rather than built: [`plan`] turns a seed into one of
/// these and [`build`] turns it into widgets, where growing did both at once.
///
/// The split is what makes a counterexample readable. A failing seed used to
/// be the entire record of one, because a grower that makes widgets as it
/// draws leaves nothing to take apart -- a shrinker could only grow its own
/// trees and hope to meet the same shape, which in practice it does not. A
/// plan is reduced by [`Plan::smaller`] and built again, so any seed that
/// fails can be cut down until what is left is small enough to read.
#[derive(Clone, Debug, PartialEq)]
pub struct Plan {
pub kind: Kind,
/// The declared size this widget carries. Whoever grows a widget offers
/// it one and the offer is taken or declined; a second offer to the same
/// widget is dropped, because two rules on one widget would settle in the
/// order they were applied rather than in grow order.
pub size: Option<Lens>,
/// The alignment it carries, under the same one-offer rule.
pub align: Option<Aligns>,
/// Whether it was offered a movable region of its own and what it
/// answered. `Some(false)` is an offer declined, which still uses up the
/// one offer, where `None` is an offer never made.
pub region_node: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Kind {
/// Wrapped and unwrapped text, because only one of them reads the width
/// it is given and so only one has to be drawn again for a new one.
Wrapped,
OneLine,
Rect {
color: usize,
alpha: u8,
},
/// Scrolling reads the pixel length of its box, which nothing else here
/// does, and gives its child a box longer than its own.
Scroll {
axis: Axis,
inner: Box<Plan>,
},
/// All three sides are grown either way, so a tree that draws one has the
/// same ids as a tree that draws another.
Branch {
probe: Box<Plan>,
wide: Box<Plan>,
narrow: Box<Plan>,
threshold: f32,
},
/// Each side its own, since a padding that is the same all round hides
/// anything that treats one edge differently from another.
Pad {
padding: [i32; 4],
inner: Box<Plan>,
},
Stack {
children: Vec<Plan>,
},
Span {
dir: usize,
gap: i32,
/// Grown for this span, in the order they are made.
children: Vec<Plan>,
/// Grown beside it whether or not they end up in it, so the widget
/// after them has the same id in a tree that leaves them out as in
/// one that puts them in.
spares: Vec<Plan>,
/// Which of `children` then `spares` are actually in the span, and
/// in what order -- kept apart from the two lists above so that a
/// tree which detaches, attaches or reorders its children still
/// makes the same widgets in the same order, and two builds line up
/// index for index. Anything not named here is built and held
/// rather than dropped, since freeing an id hands it to the next
/// widget and puts two trees out of step.
order: Vec<usize>,
},
}
impl Plan {
/// A widget carrying nothing anybody has offered it yet.
fn bare(kind: Kind) -> Self {
Self {
kind,
size: None,
align: None,
region_node: None,
}
}
/// How many widgets building it makes, spares and detached children
/// included, since those are made either way.
pub fn size(&self) -> usize {
1 + match &self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => inner.size(),
Kind::Branch {
probe,
wide,
narrow,
..
} => probe.size() + wide.size() + narrow.size(),
Kind::Stack { children } => children.iter().map(Plan::size).sum(),
Kind::Span {
children, spares, ..
} => children.iter().chain(spares).map(Plan::size).sum(),
_ => 0,
}
}
/// The trees to try instead of this one when reducing a counterexample,
/// biggest cut first: a shrinker takes the first that still fails, so
/// offering "this subtree alone" before "this subtree with one child
/// fewer" is what gets from six hundred widgets to six rather than to
/// five hundred and ninety.
///
/// Every one of these is a tree the generator could have grown, so a
/// reduced plan is a counterexample in its own right rather than a
/// special case only the shrinker can make.
pub fn smaller(&self) -> Vec<Plan> {
let mut out = Vec::new();
// Standing in for the whole of it, which is the largest cut there is.
for kid in self.kids() {
out.push(kid.clone());
}
// Then what it carries, which costs nothing to put back if it was
// not the thing that mattered.
for dropped in [
self.region_node.map(|_| Plan {
region_node: None,
..self.clone()
}),
self.align.map(|_| Plan {
align: None,
..self.clone()
}),
self.size.map(|_| Plan {
size: None,
..self.clone()
}),
]
.into_iter()
.flatten()
{
out.push(dropped);
}
out.extend(self.kind.smaller().into_iter().map(|kind| Plan {
kind,
..self.clone()
}));
out
}
/// Visits every widget in the order [`build`] makes them, so a count
/// kept by the visitor indexes the same widget as the matching [`Tree`]
/// vector does.
pub fn walk_mut(&mut self, at: &mut impl FnMut(&mut Plan)) {
match &mut self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => inner.walk_mut(at),
Kind::Branch {
probe,
wide,
narrow,
..
} => {
probe.walk_mut(at);
wide.walk_mut(at);
narrow.walk_mut(at);
}
Kind::Stack { children } => {
for child in children {
child.walk_mut(at);
}
}
Kind::Span {
children, spares, ..
} => {
for child in children.iter_mut().chain(spares) {
child.walk_mut(at);
}
}
_ => {}
}
at(self);
}
/// The same tree with `edits` applied, by the indices the generator would
/// have used for them.
///
/// [`plan`] resolves edits while drawing, which needs a seed. A scenario
/// needs them applied to a tree that already exists -- one it has built,
/// and one a shrinker may already have cut down, where no seed grows it
/// any more. Both routes take the same [`Edits`], so a case written
/// against one reads the same against the other.
pub fn edited(&self, edits: &Edits) -> Plan {
let mut out = self.clone();
let (mut sized, mut aligned, mut nodes, mut spans) = (0, 0, 0, 0);
out.walk_mut(&mut |plan| {
if let Kind::Span {
children,
spares,
order,
..
} = &mut plan.kind
{
if let Some(edit) = edits.spans.get(&spans) {
*order = span_edited(order, children.len(), spares.len(), edit);
}
spans += 1;
}
if let Kind::Branch { threshold, .. } = &mut plan.kind
&& edits.fixed_branches
{
*threshold = f32::MIN;
}
if plan.size.is_some() {
if let Some(lens) = edits.sizes.get(&sized) {
plan.size = Some(*lens);
}
sized += 1;
}
if plan.align.is_some() {
if let Some(align) = edits.aligns.get(&aligned) {
plan.align = Some(*align);
}
aligned += 1;
}
if plan.region_node.is_some() {
if let Some(take) = edits.nodes.get(&nodes) {
plan.region_node = Some(*take);
}
nodes += 1;
}
});
out
}
fn kids(&self) -> Vec<&Plan> {
match &self.kind {
Kind::Scroll { inner, .. } | Kind::Pad { inner, .. } => vec![inner],
Kind::Branch {
probe,
wide,
narrow,
..
} => vec![probe, wide, narrow],
Kind::Stack { children } => children.iter().collect(),
Kind::Span { children, .. } => children.iter().collect(),
_ => Vec::new(),
}
}
}
impl Kind {
/// Simplifications of the shape alone, leaving what the widget carries to
/// [`Plan::smaller`]. Replacing a node with one of its children is there
/// rather than here, since it answers with a whole `Plan`.
fn smaller(&self) -> Vec<Kind> {
let mut out = Vec::new();
/// One child reduced at a time, rebuilt into the same shape. Every
/// answer has the same number of children as it was given, so it is
/// for the shapes whose child count is part of what they are.
fn reduced(kids: &[Plan], rebuild: &dyn Fn(Vec<Plan>) -> Kind) -> Vec<Kind> {
let mut out = Vec::new();
for (i, kid) in kids.iter().enumerate() {
for small in kid.smaller() {
let mut next = kids.to_vec();
next[i] = small;
out.push(rebuild(next));
}
}
out
}
/// One child dropped, then [`reduced`]. For the shapes that hold any
/// number of children, where dropping one is the cut that matters.
fn each(kids: &[Plan], rebuild: &dyn Fn(Vec<Plan>) -> Kind) -> Vec<Kind> {
let mut out = Vec::new();
for i in 0..kids.len() {
if kids.len() > 1 {
let mut less = kids.to_vec();
less.remove(i);
out.push(rebuild(less));
}
}
out.extend(reduced(kids, rebuild));
out
}
match self {
// The one leaf that reads the width it is given, then the one
// that does not, then the one that measures nothing at all.
Kind::Wrapped => out.push(Kind::OneLine),
Kind::OneLine => out.push(Kind::Rect {
color: 0,
alpha: 255,
}),
Kind::Rect { .. } => {}
Kind::Scroll { axis, inner } => {
let axis = *axis;
out.extend(each(std::slice::from_ref(inner), &|mut k| Kind::Scroll {
axis,
inner: Box::new(k.remove(0)),
}));
}
Kind::Branch {
probe,
wide,
narrow,
threshold,
} => {
let threshold = *threshold;
// All three sides stay: a branch is the widget that draws
// one of two on a measurement, and one with a side missing
// is a different widget rather than a smaller one. Dropping
// the branch for a side is offered by `Plan::smaller`.
let sides = [(**probe).clone(), (**wide).clone(), (**narrow).clone()];
out.extend(reduced(&sides, &|k| Kind::Branch {
probe: Box::new(k[0].clone()),
wide: Box::new(k[1].clone()),
narrow: Box::new(k[2].clone()),
threshold,
}));
}
Kind::Pad { padding, inner } => {
let padding = *padding;
if padding != [0; 4] {
out.push(Kind::Pad {
padding: [0; 4],
inner: inner.clone(),
});
}
out.extend(each(std::slice::from_ref(inner), &|mut k| Kind::Pad {
padding,
inner: Box::new(k.remove(0)),
}));
}
Kind::Stack { children } => {
out.extend(each(children, &|children| Kind::Stack { children }))
}
Kind::Span {
dir,
gap,
children,
spares,
order,
} => {
let (dir, gap, n) = (*dir, *gap, children.len());
let span = |children: Vec<Plan>, spares: Vec<Plan>, order: Vec<usize>| Kind::Span {
dir,
gap,
children,
spares,
order,
};
let identity: Vec<usize> = (0..n).collect();
// An order the generator did not choose is part of the tree,
// so take that off before taking the tree apart.
if *order != identity {
out.push(span(children.clone(), spares.clone(), identity));
}
// Spares exist to be attached; with none attached they are
// widgets the span never holds.
if !spares.is_empty() && order.iter().all(|&i| i < n) {
out.push(span(children.clone(), Vec::new(), order.clone()));
}
if gap != 0 {
out.push(Kind::Span {
dir,
gap: 0,
children: children.clone(),
spares: spares.clone(),
order: order.clone(),
});
}
for k in 0..n {
if n > 1 {
let mut less = children.clone();
less.remove(k);
// Everything after it shifts down, spares included,
// since they are indexed past the children.
let order = order
.iter()
.filter(|&&i| i != k)
.map(|&i| if i > k { i - 1 } else { i })
.collect();
out.push(span(less, spares.clone(), order));
}
}
for (i, kid) in children.iter().enumerate() {
for small in kid.smaller() {
let mut next = children.clone();
next[i] = small;
out.push(span(next, spares.clone(), order.clone()));
}
}
}
}
out
}
}
/// A [`SpanEdit`] applied to the order a span already holds its children in.
///
/// `detach` names positions in that order and `attach` takes from the front
/// of what the span is not holding, both of which is what a test changing a
/// live span does -- so an edit means the same thing said to a tree and said
/// to the plan it was built from. On a span nobody has edited the order is
/// the children in the order they were grown, and this is then "leave these
/// out and put that many spares on the end".
fn span_edited(order: &[usize], children: usize, spares: usize, edit: &SpanEdit) -> Vec<usize> {
let mut detach = edit.detach.clone();
detach.sort_unstable();
detach.dedup();
let mut next: Vec<usize> = order
.iter()
.enumerate()
.filter(|(at, _)| !detach.contains(at))
.map(|(_, &which)| which)
.collect();
// What the span is not holding, in the order it hands them back: what it
// was already not holding first, in the order the widgets were made, and
// what this edit takes out after that, highest position first. A child
// just detached goes to the back rather than straight back in, which is
// what makes detaching one and attaching one a trade.
let mut free: Vec<usize> = (0..children + spares)
.filter(|i| !order.contains(i))
.collect();
free.extend(detach.iter().rev().filter_map(|&at| order.get(at).copied()));
next.extend(free.into_iter().take(edit.attach));
next
}
/// Plans the tree `seed` describes, `edits` replacing what it would otherwise
/// have given the widgets that carry them.
///
/// The edits are resolved here rather than at build time, so that a plan is
/// the whole of what a tree is and building one has nothing left to decide.
pub fn plan(seed: u64, depth: usize, edits: &Edits) -> Plan {
let mut sow = Sow {
rng: Rng::new(seed),
edits,
sized: 0,
aligned: 0,
nodes: 0,
spans: 0,
};
sow.node(depth)
}
/// Grows the tree `seed` describes, `edits` replacing the declared sizes it
/// would otherwise have given those wrappers.
pub fn grow<Rsc: UiRsc + 'static>(
rsc: &mut Rsc,
seed: u64,
depth: usize,
edits: &Edits,
) -> (StrongWidget, Tree) {
build(rsc, &plan(seed, depth, edits))
}
/// Draws a plan out of the random stream. Every draw happens in the order it
/// always has and before the decision it feeds, including the decisions that
/// are then dropped, because a seed has to keep meaning the same tree.
struct Sow<'a> {
rng: Rng,
edits: &'a Edits,
sized: usize,
aligned: usize,
nodes: usize,
spans: usize,
}
impl Sow<'_> {
fn leaf(&mut self) -> Plan {
Plan::bare(match self.rng.below(4) {
0 => Kind::Wrapped,
1 => Kind::OneLine,
_ => {
let color = self.rng.below(COLORS.len());
let alpha = (self.rng.below(5) * 63) as u8;
Kind::Rect { color, alpha }
}
})
}
fn len(&mut self) -> Option<LayoutLen> {
match self.rng.below(4) {
0 => Some(LayoutLen::px(20.0 + self.rng.below(180) as f32)),
1 => Some(LayoutLen::LEFTOVER),
_ => None,
}
}
fn align(&mut self) -> Aligns {
let axis = |s: &mut Self| match s.rng.below(4) {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
};
let (x, y) = (axis(self), axis(self));
// Aligning on neither axis leaves the branch unexercised.
match x.is_none() && y.is_none() {
true => [Some(AxisAlign::CENTER), y],
false => [x, y],
}
}
/// A declared size over half the tree, kept where a test can change it.
fn sized(&mut self, inner: &mut Plan) {
let take = self.rng.chance();
let lens = [self.len(), self.len()];
if !take || inner.size.is_some() {
return;
}
let idx = self.sized;
self.sized += 1;
inner.size = Some(self.edits.sizes.get(&idx).copied().unwrap_or(lens));
}
/// An alignment over some of the tree, kept where a test can change it.
fn aligned(&mut self, inner: &mut Plan) {
let align = self.align();
if inner.align.is_some() {
return;
}
let idx = self.aligned;
self.aligned += 1;
inner.align = Some(self.edits.aligns.get(&idx).copied().unwrap_or(align));
}
/// A movable region of its own over some of the tree. What it changes is
/// how a move is written and how long a primitive's chain is, neither of
/// which any other branch here varies.
fn noded(&mut self, inner: &mut Plan) {
let take = self.rng.below(4) == 0;
if inner.region_node.is_some() {
return;
}
let idx = self.nodes;
self.nodes += 1;
inner.region_node = Some(self.edits.nodes.get(&idx).copied().unwrap_or(take));
}
fn offered(&mut self, inner: &mut Plan) {
self.sized(inner);
self.noded(inner);
}
fn node(&mut self, depth: usize) -> Plan {
if depth == 0 {
return self.leaf();
}
let positioned = self.rng.below(6);
if positioned == 0 {
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
let axis = if self.rng.chance() { Axis::X } else { Axis::Y };
return Plan::bare(Kind::Scroll {
axis,
inner: Box::new(inner),
});
}
if positioned == 2 {
let probe = self.node(depth - 1);
let wide = self.node(depth - 1);
let narrow = self.node(depth - 1);
// Drawn either way, so the side a fixed branch takes is still a
// side the generator chose -- and it consumes the same randomness
// as a measured one, so the two grow the same ids.
let measured = self.rng.below(500) as f32;
let threshold = match self.edits.fixed_branches {
true => f32::MIN,
false => measured,
};
return Plan::bare(Kind::Branch {
probe: Box::new(probe),
wide: Box::new(wide),
narrow: Box::new(narrow),
threshold,
});
}
if positioned == 1 {
// Carries an alignment and makes no widget of its own, so the
// plan for it is the child it aligned.
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
self.aligned(&mut inner);
return inner;
}
if self.rng.below(4) == 0 {
let mut inner = self.node(depth - 1);
self.offered(&mut inner);
let side = |s: &mut Self| s.rng.below(24) as i32;
let padding = [side(self), side(self), side(self), side(self)];
return Plan::bare(Kind::Pad {
padding,
inner: Box::new(inner),
});
}
let grown = 2 + self.rng.below(3);
let mut children = Vec::with_capacity(grown);
for _ in 0..grown {
let mut child = self.node(depth - 1);
self.offered(&mut child);
children.push(child);
}
if self.rng.chance() {
return Plan::bare(Kind::Stack { children });
}
let spares: Vec<Plan> = (0..SPARES).map(|_| self.leaf()).collect();
let idx = self.spans;
self.spans += 1;
let edit = self.edits.spans.get(&idx).cloned().unwrap_or_default();
let dir = self.rng.below(4);
// A row takes the height it is given rather than its tallest child,
// which is a rule beside it. Derived from an existing choice and
// consuming no randomness: a seed must keep growing the same tree
// when the generator gains another configuration.
let gap = self.rng.below(3) as i32 * 4;
let grown: Vec<usize> = (0..children.len()).collect();
let order = span_edited(&grown, children.len(), spares.len(), &edit);
Plan::bare(Kind::Span {
dir,
gap,
children,
spares,
order,
})
}
}
/// Builds a plan's widgets in the order it describes them, so two builds of
/// one plan line up index for index and their boxes can be compared.
pub fn build<Rsc: UiRsc + 'static>(rsc: &mut Rsc, plan: &Plan) -> (StrongWidget, Tree) {
let mut build = Build {
rsc,
tree: Tree::default(),
};
let root = build.node(plan);
(root, build.tree)
}
struct Build<'a, Rsc> {
rsc: &'a mut Rsc,
tree: Tree,
}
impl<Rsc: UiRsc + 'static> Build<'_, Rsc> {
fn node(&mut self, plan: &Plan) -> StrongWidget {
let built = self.kind(&plan.kind);
let id = built.id();
if let Some(lens) = plan.size {
self.rsc
.ui_mut()
.widgets
.set_size_rules(id, lens[0], lens[1]);
self.tree.sized.push(id);
}
if let Some(align) = plan.align {
let widgets = &mut self.rsc.ui_mut().widgets;
for (axis, align) in [Axis::X, Axis::Y].into_iter().zip(align) {
widgets.set_alignment(id, axis, align.unwrap_or_default());
}
self.tree.aligned.push(id);
}
if let Some(take) = plan.region_node {
self.rsc.ui_mut().widgets.set_region_node(id, take);
self.tree.nodes.push(id);
}
built
}
fn kind(&mut self, kind: &Kind) -> StrongWidget {
let id: StrongWidget = match kind {
Kind::Wrapped => wtext(WORDS).size(16).wrap(true).add_strong(self.rsc),
Kind::OneLine => wtext("one line, overflowing whatever it is given")
.size(16)
.wrap(false)
.add_strong(self.rsc),
Kind::Rect { color, alpha } => rect(COLORS[*color].alpha(*alpha)).add_strong(self.rsc),
Kind::Scroll { axis, inner } => {
let inner = self.node(inner);
let id = Scroll::new(inner, *axis).add(self.rsc);
self.tree.scrolls.push(id);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
}
Kind::Branch {
probe,
wide,
narrow,
threshold,
} => {
let probe = self.node(probe);
let wide = self.node(wide);
let narrow = self.node(narrow);
let id = Branch {
probe,
wide,
narrow,
threshold: *threshold,
}
.add(self.rsc);
self.tree.ids.push(id.id());
return id.add_strong(self.rsc);
}
Kind::Pad { padding, inner } => {
let inner = self.node(inner);
let [left, right, top, bottom] = padding.map(Px::from_int);
let padding = Padding {
left,
right,
top,
bottom,
};
Pad { padding, inner }.add_strong(self.rsc)
}
Kind::Stack { children } => {
let children = children.iter().map(|c| self.node(c)).collect();
Stack {
children,
size: StackSize::Child(0),
}
.add_strong(self.rsc)
}
Kind::Span {
dir,
gap,
children,
spares,
order,
} => {
let grown = children.len();
// Every one of them is made, in this order, whether or not
// the span ends up holding it.
let made: Vec<StrongWidget> = children
.iter()
.chain(spares)
.map(|c| self.node(c))
.collect();
let mut left: Vec<Option<StrongWidget>> = made.into_iter().map(Some).collect();
let children: Vec<StrongWidget> = order
.iter()
.filter_map(|&i| left.get_mut(i).and_then(Option::take))
.collect();
// What the span does not hold is still held here: dropping
// the last share of a widget frees its id for the next one
// to be given, which puts two trees out of step.
let spares: Vec<StrongWidget> = left.into_iter().flatten().collect();
let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][*dir % 4];
let id = Span {
children,
dir,
gap: Px::from_int(*gap),
}
.add(self.rsc);
if dir.axis == Axis::X {
self.rsc
.widgets_mut()
.set_size_rules(id, None, Some(LayoutLen::rel(1.0)));
}
self.tree.ids.push(id.id());
self.tree.spans.push(Spanned { id, spares, grown });
return id.add_strong(self.rsc);
}
};
self.tree.ids.push(id.id());
id
}
}
-34
View File
@@ -1,34 +0,0 @@
use crate::primitive::UIRegion;
use wgpu::VertexAttribute;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PrimitiveInstance {
pub region: UIRegion,
pub ptr: u32,
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct WindowUniform {
pub width: f32,
pub height: f32,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 5] = wgpu::vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
];
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
}
}
-184
View File
@@ -1,184 +0,0 @@
use crate::{
render::{data::PrimitiveInstance, util::ArrBuf},
UI,
};
use data::WindowUniform;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
};
use winit::dpi::PhysicalSize;
mod data;
pub mod primitive;
mod util;
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
pub struct UIRenderNode {
bind_group_layout: BindGroupLayout,
bind_group: BindGroup,
pipeline: RenderPipeline,
window_buffer: Buffer,
instance: ArrBuf<PrimitiveInstance>,
data: ArrBuf<u32>,
}
impl UIRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
if self.instance.len() != 0 {
pass.set_vertex_buffer(0, self.instance.buffer.slice(..));
pass.draw(0..4, 0..self.instance.len() as u32);
}
}
pub fn update(&mut self, device: &Device, queue: &Queue, ui: &UI) {
let primitives = ui.to_primitives();
self.instance.update(device, queue, &primitives.instances);
self.data.update(device, queue, &primitives.data);
self.bind_group = Self::bind_group(
device,
&self.bind_group_layout,
&self.window_buffer,
&self.data.buffer,
)
}
pub fn resize(&mut self, size: &PhysicalSize<u32>, queue: &Queue) {
let slice = &[WindowUniform {
width: size.width as f32,
height: size.height as f32,
}];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
});
let window_uniform = WindowUniform::default();
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("Camera Buffer"),
contents: bytemuck::cast_slice(&[window_uniform]),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
let instance = ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance",
);
let data = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"data",
);
let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("camera_bind_group_layout"),
});
let bind_group = Self::bind_group(device, &bind_group_layout, &window_buffer, &data.buffer);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some("UI Shape Pipeline"),
layout: Some(&pipeline_layout),
vertex: VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[PrimitiveInstance::desc()],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format: config.format,
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: FrontFace::Cw,
cull_mode: Some(Face::Back),
polygon_mode: PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
cache: None,
});
Self {
bind_group_layout,
bind_group,
pipeline,
window_buffer,
instance,
data,
}
}
pub fn bind_group(
device: &Device,
layout: &BindGroupLayout,
window_buffer: &Buffer,
data: &Buffer,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: window_buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: data.as_entire_binding(),
},
],
label: Some("ui_bind_group"),
})
}
}
-47
View File
@@ -1,47 +0,0 @@
#![allow(clippy::multiple_bound_locations)]
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable)]
pub struct Color<T: ColorNum> {
r: T,
g: T,
b: T,
a: T,
}
impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
}
impl<T: ColorNum> Color<T> {
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: T, g: T, b: T) -> Self {
Self { r, g, b, a: T::MAX }
}
}
pub trait ColorNum {
const MIN: Self;
const MID: Self;
const MAX: Self;
}
impl ColorNum for u8 {
const MIN: Self = u8::MIN;
const MID: Self = u8::MAX / 2;
const MAX: Self = u8::MAX;
}
unsafe impl bytemuck::Pod for Color<u8> {}
-14
View File
@@ -1,14 +0,0 @@
use crate::primitive::{Color, PrimitiveData};
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct RoundedRectData {
pub color: Color<u8>,
pub radius: f32,
pub thickness: f32,
pub inner_radius: f32,
}
impl PrimitiveData for RoundedRectData {
const DISCRIM: u32 = 0;
}
-97
View File
@@ -1,97 +0,0 @@
use crate::primitive::{point::point, Point};
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)]
pub struct UIPos {
pub anchor: Point,
pub offset: Point,
}
impl UIPos {
pub const fn anchor_offset(anchor_x: f32, anchor_y: f32, offset_x: f32, offset_y: f32) -> Self {
Self {
anchor: point(anchor_x, anchor_y),
offset: point(offset_x, offset_y),
}
}
pub const fn top_left() -> Self {
Self::anchor_offset(0.0, 0.0, 0.0, 0.0)
}
pub const fn bottom_right() -> Self {
Self::anchor_offset(1.0, 1.0, 0.0, 0.0)
}
pub const fn within(&self, region: &UIRegion) -> UIPos {
let range = region.bot_right.anchor - region.top_left.anchor;
let region_offset = region
.top_left
.offset
.lerp(region.bot_right.offset, self.anchor);
UIPos {
anchor: region.top_left.anchor + self.anchor * range,
offset: self.offset + region_offset,
}
}
pub fn axis_mut(&mut self, axis: Axis) -> UIPosAxisView<'_> {
match axis {
Axis::X => UIPosAxisView {
anchor: &mut self.anchor.x,
offset: &mut self.offset.x,
},
Axis::Y => UIPosAxisView {
anchor: &mut self.anchor.y,
offset: &mut self.offset.y,
},
}
}
}
pub struct UIPosAxisView<'a> {
pub anchor: &'a mut f32,
pub offset: &'a mut f32,
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct UIRegion {
pub top_left: UIPos,
pub bot_right: UIPos,
}
impl UIRegion {
pub const fn full() -> Self {
Self {
top_left: UIPos::top_left(),
bot_right: UIPos::bottom_right(),
}
}
pub fn within(&self, parent: &Self) -> Self {
Self {
top_left: self.top_left.within(parent),
bot_right: self.bot_right.within(parent),
}
}
pub fn select(&mut self, inner: &Self) {
*self = inner.within(self);
}
pub fn axis_mut(&mut self, axis: Axis) -> UIRegionAxisView<'_> {
UIRegionAxisView {
top_left: self.top_left.axis_mut(axis),
bot_right: self.bot_right.axis_mut(axis),
}
}
}
pub struct UIRegionAxisView<'a> {
pub top_left: UIPosAxisView<'a>,
pub bot_right: UIPosAxisView<'a>,
}
#[derive(Copy, Clone)]
pub enum Axis {
X,
Y,
}
-63
View File
@@ -1,63 +0,0 @@
mod color;
mod def;
mod format;
mod point;
pub use color::*;
pub use def::*;
pub use format::*;
pub use point::*;
use crate::{render::data::PrimitiveInstance, WidgetId, Widgets};
use bytemuck::Pod;
#[derive(Default)]
pub struct Primitives {
pub instances: Vec<PrimitiveInstance>,
pub data: Vec<u32>,
}
pub struct Painter<'a> {
nodes: &'a Widgets,
primitives: Primitives,
pub region: UIRegion,
}
/// NOTE: Self must have at least u32 alignment
pub trait PrimitiveData: Pod {
const DISCRIM: u32;
}
impl<'a> Painter<'a> {
pub fn new(nodes: &'a Widgets) -> Self {
Self {
nodes,
primitives: Primitives::default(),
region: UIRegion::full(),
}
}
pub fn write<Data: PrimitiveData>(&mut self, data: Data) {
let ptr = self.primitives.data.len() as u32;
let region = self.region;
self.primitives
.instances
.push(PrimitiveInstance { region, ptr });
self.primitives.data.push(Data::DISCRIM);
self.primitives
.data
.extend_from_slice(bytemuck::cast_slice::<_, u32>(&[data]));
}
pub fn draw(&mut self, node: &WidgetId) {
self.nodes.get(node).draw(self);
}
pub fn draw_within(&mut self, node: &WidgetId, region: UIRegion) {
let old = self.region;
self.region.select(&region);
self.draw(node);
self.region = old;
}
pub fn finish(self) -> Primitives {
self.primitives
}
}
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more