Redesign span layout around retained placement

This commit is contained in:
iris committed 2026-09-09 15:11:57 -04:00
1 parent ae0af8f5e3
commit 0aa03cf621
30 files changed
+501 -1321

No files matched your search

+7 -10
View File
@@ -443,16 +443,16 @@ Each exists because something was invisible without it.
`ArrBuf` reallocated on every length change, so adding one glyph made
the buffer's contents undefined and forced a full rewrite; a redraw
freed its primitives and pushed new ones, which -- since freed slots
are only reusable next frame and `Painter::draw_twice` nests -- grew
are only reusable next frame and provisional layout nested -- grew
the arena to **127,443 slots for 11,569 live primitives**; and nothing
tracked *which* entries changed. Now: the stream arena is 11,569 slots
for 11,569 live, and every array uploads within a hair of its floor
(fling instances 3.4% against a 3.3% floor, stream glyphs 0.6% against
0.6%). The CPU half improved with it, since the freeing and renumbering
(fling instances 1.0% against a 0.9% floor, stream instances 71.9%
against 71.8%, and stream glyphs 0.6% against 0.6%). The CPU half improved with it, since the freeing and renumbering
went away: a streamed frame is p50 1.39ms, from 2.20ms.
**Stream instances sit at 72.7%, which is the floor and not a defect
here.** The list is pinned to the newest end, so a growing reply moves
**Stream instances sit at 71.9%, against a 71.8% floor, and dirty
tracking is not the defect here.** The list is pinned to the newest end, so a growing reply moves
every row, and a row's instances carry an absolute region. That is a
`move_offsets` write the layout is not making -- the next thing to look
at, and a layout question rather than an upload one.
@@ -532,9 +532,7 @@ thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
means here:
This checkout runs in a VM while production runs on its host:
- **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates.
@@ -626,8 +624,7 @@ where it was instead of half-deleted.
## Things that have bitten
Project-specific only — a lesson that would bite any project on this machine
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
Project-specific only; keep cross-project machine notes out of this file.
- **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache
+2 -4
View File
@@ -1,10 +1,8 @@
//! Where the desktop app keeps its enrollment: `crate::client::config`'s
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
//!
//! Only the directory is this app's -- the file's name, its JSON, and its
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
//! are the store's, shared with the Android client so the two cannot come
//! to disagree about them.
//! Only the directory is this app's. The shared store owns the file name,
//! JSON, and owner-only mode required for a bearer token.
use crate::client::config::EnrollmentStore;
use std::path::PathBuf;
+1
View File
@@ -62,6 +62,7 @@ impl Composer {
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
pad.exact_region = true;
}
}
}
+8 -57
View File
@@ -12,49 +12,6 @@ and six phone-report sections went on 2026-09-08 for that reason.
## Fix
- [ ] **`Span` draws every child twice, and making the first one a
measurement moves the layout.** Found 2026-09-09; the safe half landed
and this is the part that needs a decision.
`Span::draw`'s phase 1 draws each child at `UiRegion::FULL` purely to
learn its length along the axis, then phase 2 draws it at its real
share. The provisional slot is the whole span, so it is wrong by
construction for every child, and the doubling compounds through nested
spans: one streamed frame of the bench fixture made **1,083
`Widget::draw` calls over 113 distinct widgets** before `ScrollArea`'s
probe became a `Painter::measure`, and **453** after, with 102 of the
113 still at 4 draws (2 real, 2 measurements).
Changing phase 1 to `painter.measure(child, UiRegion::FULL)` takes it
to 2, and **it renders differently**: 28,771 pixels, and the headless
phone shot shows the transcript shifted a few pixels vertically. The
layout is intact -- panels, code fences and text all draw correctly --
so this is a position difference, not a broken frame, but which of the
two is *right* was not established and it must be before this lands.
The mechanism to check first. With phase 1 drawing, the child's
`active.region` is the full span when phase 2 asks, so phase 2 always
finds a different size and does a real redraw. With phase 1 measuring,
`active.region` is still *last frame's* share, which usually matches,
so phase 2 takes `draw_inner`'s `mov` branch -- one `move_offsets`
write instead of a redraw, which is the intended win. But `mov`
accumulates (`entry.delta += delta`) where a redraw recomputes from
scratch, so the suspicion is float drift that phase 1's unconditional
redraw was hiding. If that is it, the fix is in `mov`, not in `Span`.
Iris's framing, which is the target shape (2026-09-09): *"if you draw
one child in a list of fixed sized children, then you know immediately
where the second one must be and shouldn't need to probe its size
again. The only time redraws should actually be needed are if you're
using a `rest` length, where you don't know how long it's gonna be
until you draw everything else first, and so you have to move things.
Even then it should just be moving, not redrawing."* So the endpoint is
no probe phase at all for `abs` children -- draw each in turn at
`[cursor, end]`, read its length, advance the cursor -- with a `rest`
child forcing a reposition pass over what follows it rather than a
redraw. `Aligned` already has exactly this shape (one draw, then
`Painter::reposition`) and is the example to copy.
- [ ] **A row moving because the list grew should be one `move_offsets`
write, and today it is a redraw.** Found 2026-09-09 by
`scripts/rigs/ui-profile`'s `arena_churn` and left for whoever picks
@@ -62,8 +19,8 @@ and six phone-report sections went on 2026-09-08 for that reason.
half.
The measurement. Over the bench fixture's 401 streamed deltas, the
instance arena uploads **72.7%** of itself per frame, and that number
*is* the floor -- those entries genuinely differ, so no amount of
instance arena uploads **71.9%** of itself per frame against a
**71.8%** floor -- those entries genuinely differ, so no amount of
better dirty-tracking touches it. The control that says it is wrong is
the fling phase on the same screen and the same content: it moves the
same primitives every frame and uploads **3.3%**, because a scroll
@@ -71,18 +28,12 @@ and six phone-report sections went on 2026-09-08 for that reason.
the subtree (LAYOUT.md section 2) instead of rewriting every
primitive's absolute region.
What is different about the streaming path. The list is pinned to the
newest end, so a growing reply pushes every row above it up by the
amount the last row grew. That is a translation of an already-drawn
subtree -- exactly what `move_offsets` is for -- but it arrives as a
new offered region per row, and `draw_inner`'s fast path only takes
`mov` when `active.region.size() == region.size()`. Worth checking
first: whether `LazySpan::place` is offering each row a region whose
*size* differs (it computes `edges(height)` fresh each frame, so an
identical height should compare equal -- unless a float differs in the
last bit, which the `// TODO: epsilon?` beside that comparison already
suspects), or whether something upstream marks the rows dirty so the
fast path is skipped entirely.
The list is pinned to the newest end, so a growing reply pushes every
earlier row up. `draw_inner` now treats sub-pixel size differences as a
move and the span refactor removed nearly all repeated draws, but the
instance floor remains 71.8%. The remaining question is why those
translations still rewrite primitive regions instead of stopping at
the rows' move slots.
Done looks like: `arena_churn`'s `what_a_streamed_reply_uploads` shows
stream instances in the same range as the fling's, and its `whole`
+27 -196
View File
@@ -25,16 +25,10 @@ having been carried out.
```rust
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in.
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn is_size_independent(&self) -> bool {
false
}
@@ -55,17 +49,9 @@ own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
**No single-draw alternative was found that does less work per frame.** The
two-method trait was checked against three properties a real screen needs —
a row placing children in sequence, a widget centering on its own content,
and wrapped text — and in every one, `draw` already has to visit the child
to get a size that is *this specific one's* answer, which today's
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
the same layout the draw pass will do again. So the two-method trait is not
"measure once, draw once" in the general case; it is "measure once per axis,
then draw once," i.e. up to three visits per widget per frame, against one
under the design here. The single-draw model is therefore adopted as
proposed, not merely accepted as a preference.
`size_hint` is not a second layout pass. It is an optional exact answer for
an axis the widget declares without painter context or child access. A
lying hint fails a debug assertion when the widget is drawn.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
@@ -304,35 +290,18 @@ new caching is needed here; the two now-redundant call sites
disappear, which is a second `render` avoided per frame per text widget
that is being measured by a parent.
**"Parent wants the child's height before deciding the width it will
offer"** — the genuinely circular case named in the brief, e.g. a column
that sizes its own width to its widest child, where that child is wrapped
text whose height (which the column's *own* height depends on) depends on
the width the column has not yet decided. This is not solvable in one pass
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
(`span.rs:98-136`) already hits exactly this today and already resolves it
by an explicit second, throwaway pass (its own comment: "this literally
copies draw ... which makes this slow and not cool"). The design keeps that
resolution, made explicit rather than accidental: `Painter` gets
`Span` has no size-only pass. It first reads exact, context-free
`Widget::size_hint(axis)` values. It then draws unknown fixed children
forward from the current cursor, retaining what they paint. Once every
length is known, flexible space is allocated and `Painter::place` moves
each retained child into its final box. A child is redrawn only when that
box changes the size it was drawn for.
```rust
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw it
/// again at the region that assumption produced. For the rare parent that
/// cannot pick an offered size without already knowing the answer.
/// Twice the cost of one `draw`; every other case in this file avoids it.
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
```
implemented as: draw at `first`, record `Size`, remove the widget and its
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
"if not \[same region\], maintain resize and track old children," `:97-100`,
which already frees the old primitives before redrawing) — reusing that
path rather than adding a second one — draw again at `second(size)`, return
the final `Size`. It is opt-in and named for its cost, so a widget only
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
call site converted to it, replacing the hand-rolled duplicate loop.
Hints are optional and affect cost, never correctness. `Sized` can report
its declared axis without inspecting its child, which covers the important
`.height(rest())` case. A debug assertion compares every hint with the
eventual `draw` result. Widgets whose answer depends on shaping or on a
child return `None`.
### 5. Caching and invalidation
@@ -426,7 +395,7 @@ impl Widget for Aligned {
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
painter.place(&self.inner, region);
used
}
}
@@ -435,9 +404,9 @@ impl Widget for Aligned {
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
`Painter::reposition` is new, computing the delta between where a child
was actually drawn and where it belongs and calling the O(1) `mov` from
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
`Painter::place` moves an already-drawn child when its used area fits the
target box, and redraws it when the target changes its size. `SizeCtx` and
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
@@ -455,17 +424,10 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
transcript scroll must not pay every frame) but kept for resize-shaped
changes (§3) where the content's own region field, not an ancestor
chain, is what has to change.
- **A second, size-only trait method kept alongside `draw`** (e.g.
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
implement to skip a draw when a cheap answer exists) — considered and
rejected: it reintroduces exactly the "two names for one concept" split
this change removes, for a saving `is_size_independent` (§1, §3b)
already covers for the cases where it would actually help (fixed-size
leaves). A widget whose size is cheap to compute but whose *drawing* is
not (unlikely in this codebase's widget set, but conceivable) is better
served by that widget caching its own draw output internally — exactly
the pattern `TextView::render` already uses (§4) — than by a second
trait method every implementor has to reason about.
- **A general measurement API** — rejected because it walks the same nested
tree again. The narrow `size_hint(axis)` contract is exact,
context-free, and optional; it exists only for sizes a widget already
declares itself.
- **Passing `available` as an explicit parameter to `draw`** (mirroring
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
@@ -478,137 +440,6 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path was not one of the migration's measured
conditions, so the remaining slack was accepted rather than chased
further.
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
Iris asked for a third length kind beside `abs` (physical pixels) and
@@ -632,7 +463,7 @@ desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
`UiRenderState::reposition` itself). This also meant the Android
`UiRenderState::place` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
@@ -817,8 +648,8 @@ why it must), and `.background(rect(..))` is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A `reposition` is not the fix and cannot be: it writes an offset, never a
size.
A move alone cannot fix a changed size; `Painter::place` redraws in that
case.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the second draw happens only on the frame a
+12 -29
View File
@@ -650,8 +650,7 @@ strategy alone. Three things, in the order they had to be fixed:
dirty. This alone took the glyph array from 95% re-uploaded to 3%.
2. **A redraw freed its primitives and pushed new ones.** Freed slots are
not reusable until the end of the frame (a layer's draw order still
names them), and `Painter::draw_twice` -- how a container learns a
child's size, and containers nest -- meant the arena's high-water was
names them), and nested provisional layout meant the arena's high-water was
the *transient* push count: 17 million pushes across 401 deltas, and
127,443 slots for 11,569 live primitives, growing linearly with the
transcript. A redraw now gets its old handles back as a recycle pool
@@ -674,36 +673,20 @@ changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
marking, and `arena_churn` prints both numbers so the gap cannot reopen
unnoticed.
**A measurement is a mode, not a discarded draw (added later the same
day).** `Painter::draw_twice(child, first, |used| second)` became
`Painter::measure` + an ordinary draw, at Iris's request: her objection
was the shape it forced on the caller, since the arithmetic that picks
the real region had to happen inside a closure and anything it wanted to
keep came back out through a captured `&mut`. Two statements now say it
in the order it happens.
**Layout has no measurement mode.** A widget is drawn provisionally only
when its size cannot be known yet, and that retained drawing is moved into
place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized`
report an exact `Len`; a debug assertion compares every hint with the real
draw result. If final allocation changes a child's size, `Painter::place`
redraws it in that box. Otherwise placement is one move-offset write.
`DrawMode::Measure` is that draw with everything it *writes* switched
off -- no arena slot, no mask, no move slot, nothing left in `active`,
nothing marked dirty -- so the real draw that follows is an ordinary one
and cannot be short-circuited by the measurement having "already drawn"
the widget at that region. A `debug_assert` at the end of `draw_inner`
catches a `Painter` method that forgets to check the mode, because the
failure would otherwise be one leaked primitive per measured widget per
frame.
The amplification this removes, measured: a streamed frame makes **1,083
`Widget::draw` calls over 113 distinct widgets**, and the worst widgets
are drawn **11 times** at nesting depth 7-8. It is not two draws, it is
two to the power of how many measuring ancestors a widget has. Only the
*writes* go away, not the traversals -- the walk and the region
arithmetic still happen 11 times, and removing those needs a size that
can be answered without drawing, which is what LAYOUT.md section 5 rules
out. Worth what it cost: the streamed frame went p50 1.39ms -> 1.22ms
and p99 4.75ms -> 3.58ms, and the upload numbers did not move, because
recycling had already made the discarded writes free in arena terms.
Measured over the fixture's 401 streamed events: the busiest frame makes
176 `Widget::draw` calls and the worst widget is called four times.
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
Arena size and upload floors are unchanged.
**What is left, and it is a layout question rather than an upload one.**
Stream instances upload 72.7%, which *is* the floor: the list is pinned to
Stream instances upload 71.9%, against a 71.8% floor: the list is pinned to
the newest end, so a growing reply moves every row, and a row's instances
carry an absolute region. Moving a subtree is supposed to be one
`move_offsets` write (LAYOUT.md section 2); something on this path is
+18 -20
View File
@@ -21,8 +21,8 @@ controller back, and gets `scroll`, `fling`, `drag`, `amt`,
Two widgets have one, and they differ only in how they spend a delta:
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
then slid about as a lump, which is what makes a scroll tick an O(1)
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, drawn and then
slid about as a lump, which is what makes a scroll tick an O(1)
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
so it cannot be a lump and is not wrapped in anything. Its own
@@ -126,21 +126,18 @@ The same direction for both owners, and a different origin:
A scrollbar needs a real content length before it can use either, and a
lazy span has none. Do not invent one.
## `ScrollArea::draw`measure, then place
## `ScrollArea::draw`draw, then place
1. `take_delta`, and move to where it asks.
2. Draw the child in a box as long as **last frame's** length, to measure
it. This is free in the common case: the same region as last frame
means `draw_inner` returns immediately.
3. Apply the pin and clamp against the length just measured.
4. Draw the child again, at that length and position.
2. Offer the child **last frame's** length and read the size it reports.
An unchanged child returns from `draw_inner` without running `draw`.
3. Apply the pin and clamp against that size.
4. Place the retained drawing at its exact length and position. It is
redrawn only if its reported size does not fit that box.
Only the second draw decides anything, and a frame on which the content
did change pays one real extra draw — a frame on which it was being
redrawn anyway. Placing against the hint and letting the next frame fix it
is what hung the composer's text half a line outside its box on Iris's
phone: **layout is a pure function of the state, not of how many frames
have been drawn**, and there may be no next frame.
There is no measurement mode and no discarded drawing. Placing against
the old length and letting the next frame correct it is not valid: layout
must finish from the current state even if no later frame arrives.
The pin only re-pins on a frame with **no delta of its own**: the pin
means "stay flush with the end as the content grows", and a reader who has
@@ -157,11 +154,10 @@ can say how far it may go, so nothing above it is in a position to.
Measured 2026-09-08, and worth not re-deriving:
- A `Span` is skipped entirely in the steady state, but **when it is
redrawn it costs two draws per child** (21 draws for 10 children):
phase 1 offers each child the ambient region to learn its length,
phase 2 offers it its real share. So any mutation of a `Span` redraws
all of it — 24 draws for 11 children after one prepend.
- A `Span` is skipped entirely in the steady state. When redrawn, it uses
exact hints first, draws unknown fixed children forward from the cursor,
and places retained drawings after flexible allocation. A child is
redrawn only when its final box changes size.
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
scroll tick offers a same-size moved region, `draw_inner` takes the
`mov` path, and the child's `draw` never runs. A virtualising child
@@ -282,7 +278,9 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
even enter the widget. This is the number any "store the edges and only
recompute what changed" optimisation would have to beat, and it is why
the walk was left alone.
- `Span`, redrawn: two draws per child (see above).
- A fully hinted `Span` draws each child once. Unknown fixed children draw
provisionally and move; region-dependent children redraw if their final
box has a different size.
- The one design that would collapse those 31 moves into a single delta
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.
+2 -16
View File
@@ -264,22 +264,8 @@ impl Primitives {
slot as u32
}
/// Rewrites a slot this widget already owns, instead of freeing it
/// and allocating another -- the recycle path
/// (`Painter::write_primitive`).
///
/// **Why a redraw must be able to do this.** Freed slots do not
/// become reusable until the end of the frame (`freed`), so a widget
/// that frees its primitives and immediately draws again takes fresh
/// slots every time. Since a container learns a child's size by
/// drawing it (`Painter::measure`, and the real draw that follows)
/// and containers nest, that made the arena's
/// high-water the *transient* push count rather than the live one:
/// measured over the bench fixture's 401 streamed deltas
/// (`scripts/rigs/ui-profile`'s `arena_churn`) at 17 million pushes
/// and 127,443 slots for 11,569 live primitives, growing linearly
/// with the transcript.
///
/// Rewrites a slot this widget already owns. Freed slots are not reusable
/// until the end of a frame, so nested provisional redraws must recycle.
/// The caller has already checked that `h` is the same kind of
/// primitive in the same layer, which is what makes the slot, its
/// entry in the per-primitive data, and its position in the layer's
+5 -49
View File
@@ -11,59 +11,15 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>,
/// The mask this widget was drawn **under** (its parent's), not the
/// one it set for itself -- see `own_mask` for that.
/// The inherited mask, not `own_mask`.
pub mask: MaskIdx,
/// The mask slot this widget allocated for *itself* with
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
/// a fresh slot each draw left every already-drawn descendant --
/// which `draw_inner`'s unchanged-region fast path does not revisit --
/// clipping to the *old* slot's region, so a composer whose bar had
/// since been placed at the bottom of the screen was still being
/// clipped to a box at the top of it and drew nothing (measured
/// 2026-09-06: four mask entries live, none of them the widget's
/// current region). Its path out is the `undraw` branch of
/// `UiRenderState::remove`, which drops the self-ownership ref taken
/// when the slot was allocated.
/// The widget's retained mask slot, or `MaskIdx::NONE`.
pub own_mask: MaskIdx,
pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
/// section 5.
/// The last `Widget::draw` result.
pub size: Size,
/// This widget's slot in `UiData::move_offsets`, assigned on its first
/// draw and kept for the rest of its life (redraws reuse it in place
/// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2.
/// Retained so descendants' parent links stay valid across redraws.
pub move_slot: MoveIdx,
/// How much of this widget's own `move_slot` delta is already folded
/// into `region` above, in window pixels. The two mechanisms that
/// write that slot disagree about this and cannot be told apart from
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
/// together (the *offered* region genuinely moved), while
/// `Painter::reposition` writes only the delta (`region` stays the
/// offered box and the delta says where inside it the content was
/// placed). So anything that wants the widget's real position --
/// `resolved_region`, and through it every hit test -- must subtract
/// this from the chain sum. Without it a panned widget's own hit box
/// sits at twice the pan while its descendants' are correct, which is
/// how it went unnoticed: the composer's field became untappable
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
/// is really redrawn, since `draw_inner` zeroes the slot then too.
/// The part of this widget's move delta already folded into `region`.
pub move_applied: Vec2,
/// The offset the last `Painter::reposition` placed this widget's
/// content at *within* `region`, in window pixels. The move slot has
/// exactly one owner and one meaning:
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
/// adds to the first, `reposition` overwrites the second (it
/// recomputes `from` afresh every call, so repeating it must land on
/// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one
/// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself.
pub repositioned: Vec2,
}
+68 -106
View File
@@ -1,6 +1,6 @@
use crate::{
Color, DrawMode, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
Axis, Color, Len, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer,
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
PrimitiveHandle, PrimitiveInst, RectPrimitive,
@@ -9,7 +9,6 @@ use crate::{
util::Vec2,
};
/// makes your surfaces look pretty
pub struct Painter<'a> {
pub(super) state: &'a mut UiRenderState,
pub(super) rsc: &'a mut dyn UiRsc,
@@ -17,43 +16,19 @@ pub struct Painter<'a> {
pub(super) region: UiRegion,
pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
/// This widget's own mask slot, reused across redraws -- see
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
/// for the first time in this widget's life.
/// This widget's retained mask slot.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
/// The handles this widget owned before *this* draw, offered back to
/// it in the order it wrote them last time -- see
/// [`Self::take_recycled`]. Empty for a widget being drawn for the
/// first time. Whatever is left when the draw ends is genuinely gone,
/// and `UiRenderState::draw_inner` frees the remainder.
///
/// An iterator rather than a vec and a cursor because a
/// `PrimitiveHandle` is an ownership token and deliberately not
/// `Clone`: `peek` asks whether the next one fits without taking it,
/// `next` takes it, and what is left is exactly what nothing claimed.
/// Previous handles, consumed in draw order and freed if left over.
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
pub(super) children: Vec<WidgetId>,
pub(super) reuse_child_sizes: bool,
pub layer: usize,
pub(super) id: WidgetId,
/// Whether this draw produces what goes on screen or only a size --
/// see [`crate::DrawMode`]. Inherited by every child this widget
/// draws, so one `measure` at the top makes the whole subtree
/// write-free.
pub(super) mode: DrawMode,
}
impl<'a> Painter<'a> {
/// True while this draw is only being asked how big the widget would
/// be. **Every method here that writes anything must return early on
/// it** -- a widget's own `draw` never has to check, which is the
/// point: measuring is a property of the painter, not something each
/// widget re-implements.
pub fn measuring(&self) -> bool {
self.mode == DrawMode::Measure
}
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.write_primitive(primitive, region, Drawn::Yes);
}
@@ -74,8 +49,6 @@ impl<'a> Painter<'a> {
let h = self.recycle.peek()?;
let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No);
if h.binding != binding || h.layer != self.layer || !drawn_matches {
// Left un-taken deliberately: this handle and everything after
// it is freed together when the draw ends.
return None;
}
self.recycle.next()
@@ -89,9 +62,6 @@ impl<'a> Painter<'a> {
region: UiRegion,
drawn: Drawn,
) -> u32 {
if self.measuring() {
return u32::MAX;
}
let inst = PrimitiveInst {
id: self.id,
primitive,
@@ -107,7 +77,6 @@ impl<'a> Painter<'a> {
None => self.state.write_primitive(self.layer, drawn, inst),
};
if self.mask != MaskIdx::NONE {
// TODO: I have no clue if this works at all :joy:
self.rsc.ui_mut().masks.push_ref(self.mask);
}
let slot = h.slot;
@@ -157,13 +126,6 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
// Clipping changes no widget's reported size, so a measurement
// skips it whole -- not just the shape primitive, but the mask
// slot and its refs, which would otherwise be a leaked slot per
// masked widget per measured frame.
if self.measuring() {
return;
}
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
self.set_mask_to(shape);
}
@@ -176,12 +138,6 @@ impl<'a> Painter<'a> {
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
// Same as `set_mask`, and doubly so: a measurement leaves nothing
// in `active`, so the shape widget has drawn no primitive to
// point at and this would panic on its own message.
if self.measuring() {
return;
}
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
@@ -261,6 +217,16 @@ impl<'a> Painter<'a> {
self.widget_at(id, region.within(&self.region))
}
pub fn known_len<W: ?Sized>(&self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> {
if let Some(len) = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis) {
return Some(len.fold_dp(self.density()));
}
if !self.reuse_child_sizes || self.rsc.widgets().needs_redraw.contains(&id.id()) {
return None;
}
self.state.active.get(&id.id()).map(|a| a.size.axis(axis))
}
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.children.push(id.id());
// Passed directly rather than looked up from `self.active`: this
@@ -277,66 +243,65 @@ impl<'a> Painter<'a> {
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
self.mode,
Retained::default(),
self.rsc,
)
}
/// Ask `widget` how big it would be in `region`, **writing nothing**
/// -- see [`DrawMode::Measure`]. For the container that cannot choose
/// what to offer a child without already knowing the child's size:
/// measure, work out the real region, then draw it for real.
///
/// ```ignore
/// let used = painter.measure(&child, generous);
/// painter.widget_within(&child, self.box_for(used));
/// ```
///
/// This replaced a `draw_twice(child, first, |used| second)`, which
/// made the same two draws but had the caller express the second
/// region as a closure returning it -- so the interesting arithmetic
/// happened inside a callback and anything it wanted to keep had to
/// be written out through a captured `&mut`. Two statements say the
/// same thing in the order it happens (CODE_RULES' "compose
/// linearly"), and the measurement costs no arena slot now rather
/// than allocating one and freeing it.
///
/// The measured widget is left exactly as it was -- not in `active`
/// if it was not there before, and untouched if it was -- so the draw
/// that follows is an ordinary one and cannot be short-circuited by
/// the measurement having "already drawn" it at that region.
pub fn measure<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.state.draw_inner(
self.layer,
id.id(),
region.within(&self.region),
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
DrawMode::Measure,
Retained::default(),
self.rsc,
)
}
/// Move an already-drawn child from wherever it currently sits to
/// `region` (resolved against this widget's own region, matching
/// `widget_within`) without a second draw -- an O(1) offset write via
/// `UiRenderState::mov`. For a container that draws a child
/// provisionally to learn its size (e.g. `Aligned`) and then places it
/// for real. Only valid when the target keeps the child's drawn size;
/// if the shape actually changes, the normal `widget_within` dispatch
/// (which detects that from the stored region) does the right thing
/// instead.
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
// Moves an *already-drawn* child, of which a measurement has
// none.
if self.measuring() {
return;
}
/// Place an already-drawn child's used area, redrawing only if its size changes.
pub fn place<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
let region = region.within(&self.region);
self.state.reposition(id.id(), region, self.rsc);
let retained = self
.state
.active
.get(&id.id())
.map(|active| (active.layer, active.mask));
if let Some(size) = self.state.place(id.id(), region, self.rsc) {
size
} else if let Some((layer, mask)) = retained {
self.children.push(id.id());
self.rsc.widgets_mut().needs_redraw.insert(id.id());
self.state.draw_inner(
layer,
id.id(),
region,
Some(self.id),
self.move_slot.idx() as u32,
mask,
Retained::default(),
self.rsc,
)
} else {
self.widget_at(id, region)
}
}
pub fn place_used<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
used: Size,
within: UiRegion,
) -> Size {
let region = self.fit_region(used, within);
self.place(id, region)
}
pub fn fit_region(&mut self, used: Size, mut within: UiRegion) -> UiRegion {
let mut region = used
.to_uivec2(self.density())
.align(RegionAlign::TOP_LEFT)
.within(&within);
let output = self.output_size();
for axis in [Axis::X, Axis::Y] {
let mut actual = region.within(&self.region);
let mut available = within.within(&self.region);
if actual.axis(axis).len().to_abs(output.axis(axis))
> available.axis(axis).len().to_abs(output.axis(axis))
{
*region.axis_mut(axis) = *within.axis(axis);
}
}
region
}
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
@@ -358,9 +323,6 @@ impl<'a> Painter<'a> {
/// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
if self.measuring() {
return;
}
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
Some(h) => {
self.state.primitives.recycle_image(
+104 -211
View File
@@ -2,8 +2,8 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::{
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, Size,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ActiveData, Axis, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
Size, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
render::{
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
RectPrimitive, rounded_rect_coverage,
@@ -94,65 +94,19 @@ pub struct UiRenderState {
last_input_at: Mutex<Option<Instant>>,
}
/// Whether a draw is producing what goes on screen, or only asking a
/// widget how big it would be.
///
/// **There is no size query without a draw** (LAYOUT.md section 5):
/// `Widget::draw` reports the size it used, and nothing else can answer
/// it. A container that cannot choose what to offer a child without
/// already knowing the child's size therefore has to draw it -- so
/// [`Self::Measure`] is that draw with everything it *writes* switched
/// off. It allocates no arena slot, no mask, no move slot, leaves nothing
/// in `active` and marks nothing dirty; the widget is walked and its text
/// is shaped (which is memoized, and is the expensive half anyway), and
/// only the returned `Size` survives.
///
/// Because it leaves no trace, the real draw that follows is an ordinary
/// first draw or redraw and cannot be short-circuited by the measurement
/// having "already drawn" the widget at that region -- which is the trap
/// the discarded-draw approach it replaced had to work around.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DrawMode {
/// Write primitives, keep the result in `active`.
Draw,
/// Report a size and write nothing.
Measure,
}
impl DrawMode {
fn measuring(self) -> bool {
self == Self::Measure
}
}
/// What a widget being redrawn keeps from the draw it is replacing.
///
/// These four always travel together -- they are read off one
/// `ActiveData` that was just taken out of `active` and handed straight
/// to the draw that replaces it -- and they were four positional
/// parameters of [`UiRenderState::draw_inner`] until 2026-09-09, next to
/// six others. [`Default`] is the "nothing to keep" case: a widget drawn
/// for the first time, and the root of a full relayout.
/// State retained while replacing one draw with another.
pub(crate) struct Retained {
/// So children this draw does not draw again can be retired.
pub region: Option<UiRegion>,
pub children: Vec<WidgetId>,
/// Reused in place with its delta reset, never reallocated: a
/// descendant that is not itself redrawn still points at it. See
/// LAYOUT.md section 2.
pub move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx,
/// Slots the draw may write into instead of allocating -- see
/// `Painter::take_recycled`. Anything it does not claim is freed when
/// the draw ends.
pub primitives: Vec<PrimitiveHandle>,
}
impl Default for Retained {
/// Nothing kept: no children to retire, no move slot to reuse, no
/// mask of its own yet, nothing to recycle. Hand-written because
/// `MaskIdx`'s zero is a real slot rather than "none".
fn default() -> Self {
Self {
region: None,
children: Vec::new(),
move_slot: None,
own_mask: MaskIdx::NONE,
@@ -453,7 +407,6 @@ impl UiRenderState {
None,
MoveOffset::NONE_PARENT,
MaskIdx::NONE,
DrawMode::Draw,
Retained::default(),
rsc,
);
@@ -477,7 +430,6 @@ impl UiRenderState {
.unwrap_or(MoveOffset::NONE_PARENT)
}
// TODO: should prolly make a DrawInfo struct or smth for everything other than rsc
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_inner(
&mut self,
@@ -487,120 +439,83 @@ impl UiRenderState {
parent: Option<WidgetId>,
parent_move_slot: u32,
mask: MaskIdx,
mode: DrawMode,
retained: Retained,
rsc: &mut dyn UiRsc,
) -> Size {
let Retained {
region: mut old_region,
children: mut old_children,
move_slot: mut old_move_slot,
mut own_mask,
primitives: mut recycle,
} = retained;
// Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's
// primitives. A measure-then-draw reaches this twice for the same id
// in one frame (`LazySpan::place`'s measurement pass), and on the second
// call the still-set mark took the whole `if let` below -- including
// the `remove` that frees the first draw's primitives -- out of play,
// so `active.insert` at the end overwrote the only handles that could
// ever have freed them. The result is a full second copy of the row,
// drawn every frame from then on at the oversized measurement region
// and, with `LazySpan` setting no mask, outside the list's own bounds:
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first.
// A measurement **peeks** at the mark rather than consuming it: it
// is not the redraw the mark asked for, and swallowing it would
// leave the widget stale until something else marked it again.
let dirty = if mode.measuring() {
rsc.widgets().needs_redraw.contains(&id)
} else {
rsc.widgets_mut().needs_redraw.remove(&id)
};
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
let requires_exact_region = rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.requires_exact_region());
// What a measurement can answer without drawing at all.
//
// A widget's reported size is a function of its own state and the
// size it was offered -- not of where it was offered. So an
// undirtied widget already drawn at a region of this size has
// *already answered this question*, and `active.size` is that
// answer. This is the same assumption `mov` below already makes
// (same offered size, therefore identical output, therefore a
// translation rather than a redraw); it is only stated here as a
// size rather than acted on as a move.
//
// Without it a measurement costs a full recursive walk of the
// subtree, and since the containers that measure nest, that walk
// is what made one streamed frame 1,083 `Widget::draw` calls over
// 113 distinct widgets.
if mode.measuring() {
if let Some(active) = self.active.get(&id)
&& !dirty
&& active.region.size() == region.size()
{
return active.size;
}
} else if let Some(active) = self.active.get_mut(&id)
if let Some(active) = self.active.get_mut(&id)
&& !dirty
&& active.layer == layer
&& active.mask == mask
{
// check to see if we can skip drawing first
if active.region == region {
return active.size;
} else if active.region.size() == region.size() {
// TODO: epsilon?
} else if Self::same_size(active.region, region, self.output_size) {
let from = active.region;
let size = active.size;
self.mov(id, from, region, rsc);
return size;
} else if !requires_exact_region
&& Self::same_size(
active
.size
.to_uivec2(self.density)
.align(RegionAlign::TOP_LEFT)
.within(&active.region),
region,
self.output_size,
)
{
return self.place(id, region, rsc).unwrap();
} else if rsc
.widgets()
.get_dyn(id)
.map(|w| w.is_size_independent())
.unwrap_or(false)
{
// The offered region changed shape, but this widget's own
// drawn output does not depend on it (a fixed-size leaf) --
// rewrite its own primitives' regions in place (O(primitives
// owned directly by this widget, which for a leaf is O(1))
// instead of redrawing. See LAYOUT.md section 3.
let from = active.region;
for h in &active.primitives {
let r = self.primitives.region_mut(h);
*r = r.outside(&from).within(&region);
self.region_mut_count += 1;
}
// `move_applied` is deliberately **not** touched here,
// unlike in `mov`: it counts the part of this widget's own
// move-slot delta that `region` has already absorbed, and
// this branch writes no delta at all -- the primitives were
// moved directly. Counting one would make
// `resolved_region` subtract a distance the chain never
// held, putting the hit box short of the drawing by
// exactly this step. See `ActiveData::move_applied`, and
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
active.region = region;
return active.size;
}
// if not, then maintain resize and track old children to remove unneeded
let active = self.remove(id, false, true, rsc).unwrap();
old_region = Some(active.region);
old_children = active.children;
old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
recycle = active.primitives;
} else if dirty && self.active.contains_key(&id) {
debug_assert!(!mode.measuring());
// Dirty and already drawn: none of the fast paths above may be
// taken (the widget's own content changed, so its old primitives
// say nothing about its new ones), but they are also the only
// thing that frees them. Same two lines, reached the other way.
} else if self.active.contains_key(&id) {
let layer_changed = self
.active
.get(&id)
.is_some_and(|active| active.layer != layer);
let active = self.remove(id, false, true, rsc).unwrap();
if layer_changed {
rsc.on_undraw(&active);
}
old_region = Some(active.region);
old_children = active.children;
old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
recycle = active.primitives;
}
// draw widget
let reentrant = !self.draw_started.insert(id);
debug_assert!(
!reentrant,
@@ -608,23 +523,11 @@ impl UiRenderState {
the second draw's primitives would orphan the first's"
);
// A measurement writes no primitive, so nothing ever reads this
// -- and allocating one would leak a slot per measured widget per
// frame, since `move_offsets` only frees on a widget's removal.
let move_slot = match mode {
DrawMode::Measure => Id::preset(MoveOffset::NONE_PARENT),
DrawMode::Draw => Self::move_slot_for(old_move_slot, parent_move_slot, rsc),
};
let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc);
// The mask this widget was drawn *under*, kept aside because
// `Painter::set_mask` overwrites `painter.mask` with the widget's
// own new one -- and `ActiveData::mask`'s only consumer is
// `redraw`, which feeds it back in as the *inherited* mask. Storing
// the set one instead handed a `Masked` its own mask on every
// targeted redraw -- an abort the first time the composer's scroll
// area was redrawn on the emulator, and now (masks nest) a mask
// whose parent is itself, which `set_mask`'s own assert names.
let inherited_mask = mask;
let reuse_child_sizes =
old_region.is_some_and(|old| Self::same_size(old, region, self.output_size));
let mut painter = Painter {
state: self,
region,
@@ -637,23 +540,34 @@ impl UiRenderState {
primitives: Vec::new(),
recycle: recycle.into_iter().peekable(),
children: Vec::new(),
mode,
reuse_child_sizes,
rsc,
};
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
let density = painter.density();
let hints = [
widget.size_hint(Axis::X).map(|len| len.fold_dp(density)),
widget.size_hint(Axis::Y).map(|len| len.fold_dp(density)),
];
painter.state.draw_count += 1;
let size = widget.draw(&mut painter);
// A reported length is consumed by containers that read `abs`,
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
// addition), so an unresolved `dp` in one is silently worth zero
// -- see `Len::fold_dp`, which is what a widget reporting a
// caller-declared size has to put it through.
debug_assert!(
size.x.dp == 0.0 && size.y.dp == 0.0,
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
report `Len::fold_dp(painter.density())` instead"
);
for (axis, hint) in [Axis::X, Axis::Y]
.into_iter()
.zip(hints)
.filter_map(|(axis, hint)| hint.map(|hint| (axis, hint)))
{
debug_assert_eq!(
size.axis(axis),
hint,
"widget {id:?}'s {axis:?} size hint differs from its draw result"
);
}
drop(widget);
painter.state.draw_started.remove(&id);
@@ -668,29 +582,11 @@ impl UiRenderState {
primitives,
recycle,
children,
reuse_child_sizes: _,
layer,
id,
mode: _,
} = painter;
if mode.measuring() {
// Nothing to unwind: a measurement allocates no slot, no
// mask, no move offset and no `ActiveData`, so the size is
// the whole of what it produced. Asserted rather than
// assumed, because a `Painter` method that forgot to check
// the mode would otherwise leak silently -- one primitive per
// measured widget per frame, which a screen redrawn every
// frame turns into an arena that grows without bound.
debug_assert!(
primitives.is_empty() && textures.is_empty(),
"measuring {id:?} wrote {} primitive(s) and {} texture(s); \
every `Painter` write must check `Painter::measuring`",
primitives.len(),
textures.len(),
);
return size;
}
// Whatever the draw did not claim is genuinely gone: this draw
// wrote fewer primitives than the last one, or stopped matching
// part way. Freeing it here rather than in `remove` is what lets
@@ -714,7 +610,6 @@ impl UiRenderState {
move_slot,
own_mask,
move_applied: Vec2::ZERO,
repositioned: Vec2::ZERO,
};
// remove old children that weren't kept
@@ -782,67 +677,64 @@ impl UiRenderState {
self.mov_count += 1;
}
/// Move an already-active widget to `to`. Used by `Painter::reposition`,
/// for a parent that drew a child provisionally (at the whole region it
/// was offered) and now knows where the child actually belongs.
///
/// Unlike `mov` (called by `draw_inner`'s own dispatch, where the
/// *offered* region really did move and `active.region` already tracks
/// it), the child here was not offered a smaller region -- it was
/// offered everything and chose, on its own, to occupy only
/// `active.size` of it. By convention every widget in this crate that
/// does that anchors its own content at the top-left of whatever it
/// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw`
/// bodies), so that is where this assumes the child was actually
/// painted, not `active.region` itself (which is the *offered* box,
/// usually bigger). A nested `Aligned` whose own child is not top-left
/// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape
/// this does not cover; none of iris's widgets or examples build that
/// today. See LAYOUT.md's "Rejected, and why" / deviations for the
/// full reasoning.
///
/// The delta is overwritten, not accumulated like `mov`'s: `from` is
/// recomputed fresh from `active.size`/`active.region` every call, so
/// repeating the same `reposition` (e.g. an unrelated redraw elsewhere
/// re-running this widget's parent without its own layout changing)
/// must land on the same answer, not drift further each time.
pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get(&id) else {
return;
};
let move_applied = active.move_applied;
let repositioned = active.repositioned;
fn same_size(a: UiRegion, b: UiRegion, output: Vec2) -> bool {
let a = a.size().to_abs(output);
let b = b.size().to_abs(output);
(a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() < 0.01
}
pub(super) fn place(
&mut self,
id: WidgetId,
to: UiRegion,
rsc: &mut dyn UiRsc,
) -> Option<Size> {
let active = self.active.get(&id)?;
if rsc
.widgets()
.get_dyn(id)
.is_some_and(|widget| widget.requires_exact_region())
&& !Self::same_size(active.region, to, self.output_size)
{
return None;
}
let from = active
.size
.to_uivec2(self.density)
.align(RegionAlign::TOP_LEFT)
.within(&active.region);
if !Self::same_size(from, to, self.output_size) {
return None;
}
let size = active.size;
if active.region == to {
return Some(size);
}
if Self::same_size(active.region, to, self.output_size) {
let region = active.region;
self.mov(id, region, to, rsc);
return Some(size);
}
let move_applied = active.move_applied;
let slot = active.move_slot;
let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px;
// Not `delta` alone: a parent may have `mov`ed this widget to a
// region that itself moved earlier in the same frame, and that
// part of the slot is `move_applied`'s, not this call's. Writing
// `delta` on its own dropped it and put the content back at the
// pre-move position. `from` is computed against `active.region`,
// which `mov` already updated, so `delta` is purely the placement
// inside the region and the two summands never overlap.
if delta.x.abs() < 0.01 && delta.y.abs() < 0.01 {
if let Some(active) = self.active.get_mut(&id) {
active.region = to;
}
return Some(size);
}
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
debug_assert_eq!(
entry.delta,
[
move_applied.x + repositioned.x,
move_applied.y + repositioned.y
],
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
);
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
if let Some(active) = self.active.get_mut(&id) {
active.repositioned = delta;
active.region = to;
active.move_applied += delta;
}
self.mov_count += 1;
Some(size)
}
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
@@ -1073,6 +965,7 @@ impl UiRenderState {
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
/// transcript is tens of thousands and made a debug build on a phone
/// too slow to finish a benchmark run.
#[cfg(debug_assertions)]
fn primitive_counts_agree(&self) -> bool {
let live: usize = self.primitives.live_count();
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
@@ -1316,8 +1209,8 @@ impl UiRenderState {
parent,
parent_move_slot,
active.mask,
DrawMode::Draw,
Retained {
region: Some(active.region),
children: active.children,
move_slot: Some(active.move_slot),
own_mask: active.own_mask,
+17 -31
View File
@@ -1,4 +1,4 @@
use crate::{Painter, Size};
use crate::{Axis, Len, Painter, Size};
use std::any::Any;
mod data;
@@ -16,46 +16,28 @@ pub use view::*;
pub use widgets::*;
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in. See LAYOUT.md.
/// An exact, context-free length known without drawing or inspecting children.
fn size_hint(&self, _axis: Axis) -> Option<Len> {
None
}
/// Whether the draw result is independent of the offered region.
fn is_size_independent(&self) -> bool {
false
}
/// What kind of control this is, for the AccessKit tree `ui::access`
/// builds (RUST.md's I4). Only consulted for a widget that also has an
/// explicit `.label()` -- an unnamed widget is never visited by that
/// tree at all, named or not, so the default here costs nothing except
/// at the handful of call sites that opt in. Default `Unknown` (a
/// generic control with no more specific semantics); a widget with a
/// real platform equivalent -- `TextEdit`'s `MultilineTextInput` --
/// overrides it.
fn requires_exact_region(&self) -> bool {
false
}
/// The AccessKit role for a labelled widget.
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
}
/// Advance whatever this widget is animating to `now`, and say whether
/// it is still animating afterwards. Default: nothing is, so a widget
/// opts in by overriding this *and* by something calling
/// [`crate::UiData::animate`] with its id when the animation starts --
/// which is that animation's path out, since the driver
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
/// answers `false`.
///
/// Called once per frame, before the frame's draw, by whichever
/// backend owns the surface; a `true` answer is what makes that
/// backend ask for another frame. So this is the only thing in iris
/// that moves without an input event, and a widget that animates
/// without registering simply never moves -- which is exactly how a
/// finger fling looked on Iris's phone before this existed.
/// Advance an animation and report whether it needs another frame.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
@@ -70,6 +52,10 @@ impl Widget for () {
fn is_size_independent(&self) -> bool {
true
}
fn size_hint(&self, _axis: Axis) -> Option<Len> {
Some(Len::ZERO)
}
}
impl dyn Widget {
+51 -17
View File
@@ -23,6 +23,51 @@ impl UiRsc for TestRsc {
}
}
struct FixedRect(f32);
impl Widget for FixedRect {
fn draw(&mut self, painter: &mut Painter) -> Size {
let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST);
painter.primitive_within(
RectPrimitive::color(UiColor::WHITE),
size.to_uivec2(painter.density())
.align(RegionAlign::TOP_LEFT),
);
size
}
}
#[test]
fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let first = rsc.ui.widgets.add_strong(FixedRect(40.0));
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let fill = rsc.ui.widgets.add_strong(Sized {
inner: fill.any(),
x: None,
y: Some(Len::REST),
});
let last = rsc.ui.widgets.add_strong(FixedRect(40.0));
let last_w = last.weak();
let mut span = Span::empty(Dir::DOWN);
span.push(first.any());
span.push(fill.any());
span.push(last.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((200.0, 300.0));
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
assert_eq!(draws, 5);
assert_eq!(moves, 1);
let last = render.window_region(&last_w, &rsc).unwrap();
assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}");
}
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
/// enough that an O(N) regression in the move path would show up as a
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
@@ -227,7 +272,7 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
// `Masked` itself is never the target of a `mov`/`reposition` here --
// `Masked` itself is never the target of a `mov`/`place` here --
// only its scrolled child is -- so the slot its own mask references
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
// still read zero after the scroll. The visible counterpart of this
@@ -307,7 +352,7 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
// window, then a further keystroke -- the redraw that must land in the
// bar's new (also short) region, not whatever region a provisional
// measurement pass used along the way.
// provisional placement used along the way.
render.resize((1080.0, 1478.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("b");
@@ -583,9 +628,6 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
// `Span` draws each child once at the full region to measure it and
// then places it, so this widget has already been through the branch
// once by the end of the very first frame.
let first = render.window_region(&below_w, &rsc).unwrap();
assert!(
(first.top_left.y - 100.0).abs() < 0.01,
@@ -604,7 +646,7 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
}
/// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and `reposition`s it inside that box in the same frame -- what
/// offers) and places it inside that box in the same frame -- what
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
@@ -634,21 +676,12 @@ impl Widget for MoveThenPlace {
UiScalar::abs(self.place_top + 40.0),
),
);
painter.reposition(&self.inner, place);
painter.place(&self.inner, place);
Size::default()
}
}
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
/// overwrites it, and both can legitimately land on one widget in one
/// frame (see `MoveThenPlace`). `reposition` used to write its own delta
/// alone, which dropped the move and put the child back at the position
/// the offered box had *before* it moved; a `debug_assert!` that
/// `move_applied` was zero hid that behind a panic instead of fixing it.
/// The slot has one owner and one meaning now --
/// `move_applied + repositioned` -- so the child stays where it was
/// placed however its offered box moves. Fails at the offer's position
/// (200) rather than the placement's (100) without that.
/// Placement must preserve a move already applied in the same frame.
#[test]
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
let mut rsc = TestRsc {
@@ -1025,6 +1058,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
let padded = rsc.ui.widgets.add_strong(Pad {
padding: Padding::uniform(PAD),
inner: sized.any(),
exact_region: false,
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
+5 -6
View File
@@ -7,18 +7,17 @@ pub struct Image {
impl Widget for Image {
fn draw(&mut self, painter: &mut Painter) -> Size {
// Drawn at its own natural size, anchored top-left of whatever it
// was offered, not stretched to fill it -- its primitive is
// independent of the offered region, matching `is_size_independent`
// below. A caller that wants it placed differently wraps it (e.g.
// `.center()`, `.align(...)`).
let size = self.handle.size();
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
Size::abs(size)
}
fn size_hint(&self, axis: Axis) -> Option<Len> {
Some(Len::abs(self.handle.size().axis(axis)))
}
fn is_size_independent(&self) -> bool {
true // a decoded image's primitive never depends on the region it is offered
true
}
}
+7 -16
View File
@@ -1,18 +1,6 @@
use crate::prelude::*;
/// Clips `inner` -- and everything below it -- to a shape.
///
/// The shape is a **primitive**, never a rectangle or a radius stored
/// here: with `shape`, the widget named there is drawn behind `inner`
/// filling the same box and the clip is its first primitive, so a rounded
/// container's corner and the corner its content is cut to are the same
/// arithmetic and cannot fall out of step. Without one, this writes an
/// undrawn rect at its own region, which is the plain "clip to my box"
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
/// shape".
pub struct Masked {
/// The widget whose first primitive is the clip, drawn behind
/// `inner`, or `None` for this widget's own box.
pub shape: Option<StrongWidget>,
pub inner: StrongWidget,
}
@@ -20,9 +8,6 @@ pub struct Masked {
impl Widget for Masked {
fn draw(&mut self, painter: &mut Painter) -> Size {
match &self.shape {
// Layered the way `Stack` layers a background under its
// content, and for the same reason: within one layer the draw
// order is undefined once anything has been freed.
Some(shape) => {
painter.child_layer();
painter.widget(shape);
@@ -31,6 +16,12 @@ impl Widget for Masked {
}
None => painter.set_mask(painter.region()),
}
painter.widget(&self.inner)
let used = painter.widget(&self.inner);
painter.place_used(&self.inner, used, UiRegion::FULL);
used
}
fn requires_exact_region(&self) -> bool {
true
}
}
+10 -22
View File
@@ -7,30 +7,18 @@ pub struct Aligned {
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
// Draw once at the whole region this widget was offered to learn
// the child's real size -- this placement is provisional and
// corrected below without a second draw. `painter.widget` (not
// `widget_within(..., painter.region())`) is what "my whole,
// already-resolved region, unmodified" means: `widget_within`
// composes its argument as a *local*, `UiRegion::FULL`-relative
// box against `painter.region()`, so handing it the
// already-resolved region double-applies that composition and is
// wrong for any widget nested below the root.
let used = painter.widget(&self.inner);
let density = painter.density();
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
(Some(x), None) => {
let x = used.x.apply_rest(density).align(x);
UiRegion::new(x, UiSpan::FULL)
}
(None, Some(y)) => {
let y = used.y.apply_rest(density).align(y);
UiRegion::new(UiSpan::FULL, y)
}
(None, None) => UiRegion::FULL,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
let (x, y) = self.align.tuple();
let region = UiRegion::new(
used.x
.apply_rest(density)
.align(x.unwrap_or(AxisAlign::Neg)),
used.y
.apply_rest(density)
.align(y.unwrap_or(AxisAlign::Neg)),
);
painter.place(&self.inner, region);
used
}
}
+3 -1
View File
@@ -10,6 +10,8 @@ impl Widget for LayerOffset {
for _ in 0..self.offset {
painter.next_layer();
}
painter.widget(&self.inner)
let used = painter.widget(&self.inner);
painter.place_used(&self.inner, used, UiRegion::FULL);
used
}
}
+19 -317
View File
@@ -1,169 +1,9 @@
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
//! an anchor rather than eagerly like `Span`.
//! A virtualised, variable-height span laid out from an anchor.
//!
//! **`docs/SCROLL.md` is the overview** -- how this widget and `ScrollArea`
//! divide the work, the one sign convention, the `Widget` handoff, and
//! what is still open. Read it first; this file is the detail.
//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out
//! of primitives that design already provides (`Painter::widget`/
//! `widget_within`/`reposition`, and `draw_inner`'s own old-children
//! diffing) rather than adding a second move mechanism.
//!
//! ## Design
//!
//! **It knows nothing about masks.** What it does is *cull*: a row that
//! falls entirely outside the region this widget was offered is never
//! drawn (`intersects_viewport`). A row that *straddles* an edge is drawn
//! in full, because virtualisation decides which rows are drawn and never
//! how much of one -- so the overhang past this list's box reaches the
//! screen unless something clips it, and clipping is `.masked()`, which
//! the caller adds when it wants one. Iris, 2026-09-08: **"Why does the
//! mask matter at all. If you want a mask then you add `.masked()`. It
//! should just prevent rows that aren't in its region at all from drawing
//! ... Just like the opt in scrollable, masking should be opt in."**
//!
//! Two shapes this file went through before that, both worse. It asserted
//! `Painter::is_masked` and refused to draw otherwise, which made an
//! ordinary full-screen list -- every benchmark, every simple app --
//! panic for want of ceremony it did not need; and the case that actually
//! bites passed the check anyway, since a mask *larger* than the list's
//! box satisfies `is_masked` while still letting the overhang through.
//! Then it set a mask of its own, which is this widget deciding something
//! that is not its to decide: a caller that wants the overhang (or that
//! is already clipped by something bigger) has no way to say so, and the
//! transcript ended up double-masked.
//!
//! The fault a caller is opting *out* of, when it leaves `.masked()` off,
//! is the transcript panned to its top edge drawing code through the
//! header bar above it, on Iris's phone (docs/IRIS_TODO.md, 2026-09-07).
//!
//! **Rows are keyed by a `u64` (`RowKey`), not a generic type.** Every real
//! row source in this codebase (a transcript's monotonic sequence number, a
//! chat message id) is already an integer; a generic key would cost every
//! call site a type parameter for a capability nothing here needs yet --
//! the simplest thing that works, per the code rules.
//!
//! **Composed only while visible, for free.** `LazySpan` does not maintain its
//! own "which widgets are alive" bookkeeping. Its `draw` calls
//! `painter.widget`/`widget_within` only for the rows currently in view;
//! `UiRenderState::draw_inner` already diffs a redrawn widget's new
//! `children` against its old ones and frees (`remove_rec`) whatever is no
//! longer called (LAYOUT.md section on caching, and the "old_children"
//! removal in `draw_inner`). A row that scrolls out is therefore dropped
//! and its primitives freed the very next time `LazySpan` redraws -- no new
//! mechanism, just relying on the one LAYOUT.md already built.
//!
//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `LazySpan`
//! is laid out outward from one **anchor** row (`Anchor { slot, edge,
//! offset }`: a slot index, which of its edges is pinned, and that edge's
//! pixel offset from the viewport's leading edge) rather than from a
//! single scroll amount measured from the top of all content -- there is
//! no "top of all content" to measure without walking every row, which is
//! exactly the O(N) cost virtualisation exists to avoid. Rows below the
//! anchor are placed **top-known** (`Placement::Leading`): offered an exact
//! top and a generous, oversized bottom, drawn once with
//! `painter.widget_within`, and their real height read back from the
//! returned `Size`. Rows above the anchor are placed **bottom-known**
//! (`Placement::Trailing`): since every widget in this crate paints itself
//! anchored top-left of whatever it is offered (LAYOUT.md's deviation 2),
//! placing a row so its *bottom* lands at an exact pixel needs the same
//! "learn the size, then move" trick `Aligned` already uses --
//! `painter.widget` at the full offered region to measure, then
//! `painter.reposition` (an O(1) offset write, no second draw) to the
//! exact box. On an ordinary scroll tick only `Anchor::offset` changes;
//! every already-visible row keeps the same *size* it was offered last
//! frame (top-known rows: same generous bottom bound; bottom-known rows:
//! the same full-region measurement, which `draw_inner`'s own
//! `active.region == region` check turns into a **no draw at all**, its
//! cached `Size` returned for free) so the per-row cost of a tick is one
//! `mov()`/`reposition()` write, never a redraw -- verified in this file's
//! `moves_stay_o1_across_list_size` test and in `benches/message_lazy_span.rs`.
//!
//! **The scroll anchor survives a row inserted above it.** The anchor
//! names a row by its *slot index*, not by an absolute content offset
//! measured from the top -- so `push_front` only has to shift the
//! anchor's slot by one (`+= 1`, an O(1) write) to keep it pointing at the
//! same logical row; nothing about where that row is drawn changes, and
//! rows outside the loaded window are never touched. This is the same
//! reason `push_back`/`pop_front`/`pop_back` are all O(1): the widget
//! never computes "total content height," only the local heights of the
//! rows it is actively placing.
//!
//! **"More" sentinels are two ordinary optional widgets, not a second
//! data model.** `more_before`/`more_after` are each `Option<StrongWidget>`
//! set by the caller (`WidgetPtr`'s own idiom); when present, the walk
//! outward from the anchor treats the sentinel as one more slot past the
//! real rows (`BEFORE_SLOT`/`AFTER_SLOT`, reserved `isize` values below
//! `0`/above any real index) rather than special-casing it, so a sentinel
//! costs nothing extra to place or to virtualise away.
//!
//! **"Hold the edge nearest the tap," done in the layout pass.**
//! `note_tap(viewport_pos)` records where the user last touched the list,
//! in viewport-relative pixels, without forcing a redraw by itself -- the
//! app is expected to call it and then mutate whatever row is expanding
//! (e.g. toggling a collapsed message), which is what actually marks that
//! row (and, by the existing resize-bubble in `UiRenderState::redraw`,
//! `LazySpan` itself) dirty. The *next* time `LazySpan::draw` runs, before placing
//! anything, it looks at `extents` (each visible row's on-screen box as of
//! the *previous* frame, cached while walking) to find which row contains
//! the tap, decides whether the tap was nearer that row's top or bottom
//! edge, and re-anchors to exactly that row/edge/pixel -- so the row this
//! frame draws at its *new* height with the chosen edge pinned to the same
//! screen position it already occupied, and only the far side visibly
//! grows or shrinks. This is a layout decision made before any primitive
//! is written for the frame, not a correction applied to an already-drawn
//! wrong frame.
//!
//! **A row's height is cached by key once measured**, and reused directly
//! (one `widget_within` at the exact box, no re-measurement) on every later
//! placement of that row -- not merely an optimisation: see `place`'s doc
//! for why a row that fills whatever it is offered (a `.background(rect
//! (...))`) needs this to ever be placed at the right size at all, and why
//! re-measuring every frame instead would defeat `draw_inner`'s own
//! skip-or-move caching. Only a row's first-ever appearance pays the
//! two-draw measurement; nothing here estimates a height for an off-screen
//! row that has never been measured, so this stays independent of how many
//! rows exist outside the loaded window.
//!
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
//! One rule, `intersects_viewport`, used by both halves of that sentence:
//! a row straddling either edge is drawn in full and clipped by the
//! `.masked()` its caller must place it in (`LazySpan::draw` asserts that),
//! and a row that has left the viewport is not drawn at all. The walk
//! still traverses whatever lies between the anchor and the viewport, and
//! `rehome_anchor` moves the anchor back onto a visible row every frame so
//! that "whatever lies between" stays empty however far the list is
//! panned.
//!
//! **This widget scrolls itself, and everything that is not its layout
//! lives in a [`ScrollController`] it owns** -- the position, the gesture,
//! the fling and the pin, the same struct a `ScrollArea` holds
//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be:
//! a scroll tick offers a moved region of the same size, `draw_inner`
//! takes the `mov` path, and a virtualising child inside it would never
//! update which rows it shows. `.scrollable()` here is the span's own
//! inherent one, registering the wheel and the drag against that
//! controller.
//!
//! What is left in this file is the layout: an anchor, a walk outward
//! from it, and an honest answer about how far it can go
//! ([`Self::travel`]).
//!
//! **Overscroll cannot be entered by scrolling, and is taken back within
//! the frame when something else causes it.** The controller clamps a
//! delta to the travel the last walk reported, so a delta that runs off a
//! wall already in view is simply cut short. What that cannot cover is a
//! wall this span has not walked to yet -- with rows loaded past an edge
//! there is no bound to report -- or the content or viewport changing
//! under a settled anchor, and for both
//! `overscroll_gap` measures the gap from the ends the walk already
//! placed and `draw` moves the anchor by it and walks a second time
//! **before the frame ends** -- layout is a pure function of the state,
//! not of how many frames have been drawn (Iris, 2026-09-08), the same
//! rule `ScrollArea::draw` follows. A span shorter than its viewport is not
//! overscrolled and is left alone, still pinned to the end it was built
//! with.
//! It owns a `ScrollController`, draws only rows overlapping the viewport,
//! and leaves clipping to an optional `.masked()` wrapper. Cached row heights
//! let retained rows move directly; a new or resized row is drawn and then
//! placed at the exact extent it reports. See `docs/SCROLL.md`.
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
@@ -299,13 +139,7 @@ pub struct LazySpan {
last_viewport_len: f32,
pending_tap: Option<f32>,
extents: HashMap<RowKey, RowExtent>,
/// Each row's height as of its last real draw, kept across frames so
/// an already-measured row is placed directly at its exact box next
/// time (one `widget_within`, no oversized measurement pass) --
/// see `place`'s doc for why a fresh measurement can't be skipped
/// merely by translating an already-drawn primitive. Pruned when a
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded.
/// Last reported height, pruned when its row is evicted.
heights: HashMap<RowKey, f32>,
/// Whether the last walk found no more content before the leading
/// edge *and* nothing left to give back there -- what
@@ -1003,62 +837,8 @@ impl LazySpan {
region
}
/// Place one slot (a real row or a sentinel) per `placement`, caching
/// its resolved extent (for the next frame's `note_tap` resolution) and
/// height (for its own next placement, see below), and return its
/// resolved `(leading, trailing)` edges in viewport pixels.
///
/// A row already measured on some earlier frame is placed directly at
/// its cached height's exact box -- one `widget_within`/`reposition`
/// pass, the same as any other widget placed by an already-known
/// region. A row seen for the first time has no cached height to place
/// it *at*, so it is measured first (an oversized, fixed-size region)
/// and then drawn a *second* time at the tight box that measurement
/// implies, via `Painter::measure` -- not `reposition` (a pure
/// translation, no resize). This distinction is required, not just an
/// optimisation: a row is not always plain wrapped text --
/// `.background(rect(tint))` is an ordinary way to style one, and
/// `Rect::draw` is `is_size_independent` specifically because it fills
/// *whatever region it is given* (`Size::REST`, see `rect.rs`).
/// Measuring such a row at the oversized box has it paint an oversized
/// rect there; `reposition` only ever writes an offset, never a size,
/// so an every-frame reposition-only scheme would leave that primitive
/// oversized forever. Measuring on *every* frame would fix
/// that but break the opposite property: its two calls use two
/// different regions, so whichever one `ActiveData.region` ends up
/// holding always disagrees with the *next* frame's first call,
/// forcing a real redraw every single frame instead of the cheap
/// skip-or-move `draw_inner` already provides for an unchanged or
/// merely-translated widget. Caching the height once measured is what
/// lets an already-seen row go back to that cheap path while a
/// first-seen one still gets a correctly-sized initial paint.
///
/// **A row whose measurement disagrees with the box it was offered is
/// drawn again, this frame, at the box its own height implies** --
/// both placements, since both offer a cached height and both can be
/// wrong the frame a row's content changes size. This is not an
/// optimisation to skip: a row is routinely `.background(rect(..))`
/// (a tool card *is* one), and `Rect::draw` fills whatever region it
/// is handed, so a row offered last frame's height paints its
/// background at last frame's height while its text lays out at the
/// new one -- Iris's 2026-09-08 report that "collapsing and opening an
/// edit card draws the card background a frame late, so it looks
/// closed even when there's text". A `reposition` does not fix it
/// (it writes an offset, never a size), which is what the bottom-
/// anchored half used to do. The extra draw happens only on the frame
/// a row actually changes height, which is a frame that was already
/// redrawing that row.
/// Place a row and remember its height for later walks.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
// Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but
// that invariant is enforced by convention across three call
// sites, not by this function, which would otherwise fail with a
// bare "index out of bounds" and no context (review,
// 2026-09-06). `slot_widget`, called from
// here, is what actually indexes/`.expect`s on it. Stays a
// `debug_assert!` under R1's rule: this runs once per row placed
// per frame, and its release failure is the `.expect` below rather
// than something silently wrong on screen.
debug_assert!(
self.slot_exists(slot),
"place() called with a slot that doesn't exist: {slot:?}"
@@ -1077,16 +857,6 @@ impl LazySpan {
let key = self.slot_key(slot);
let cached = key.and_then(|k| self.heights.get(&k).copied());
// A row entirely outside the viewport is traversed but not drawn
// -- see `intersects_viewport`. The walk still has to *pass
// through* it, because its height is what says where the rows
// behind it land, but nothing about it reaches the screen, so
// drawing it costs a redraw (and, unclipped, paints over whatever
// is above the list) for content nobody can see. Only possible
// for a row whose height is already known: a first-time row has
// to be drawn to be measured at all, which is why the extent
// below is recorded from the intersection test rather than from
// "was this drawn".
if let Some(h) = cached {
let (lead, trail) = placement.edges(h);
if !self.intersects_viewport(lead, trail) {
@@ -1096,56 +866,31 @@ impl LazySpan {
let widget = self.slot_widget(slot);
let height = match cached {
// Offered a box sized to the *cached* height (cheap to compare
// against last frame's offer, see `place`'s doc), but the
// height kept is what this draw actually reported -- if the
// row's real content grew since it was cached (and was
// therefore redrawn: an unchanged widget never disagrees with
// its own cache), it is drawn again here, this frame, at the
// box its own height implies rather than waiting a frame to
// self-correct.
Some(h) => {
let (lead, trail) = placement.edges(h);
let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail));
let height = resolve(used);
if height != h {
let (lead, trail) = placement.edges(height);
painter.widget_within(widget, Self::abs_region(dir, lead, trail));
painter.place(widget, Self::abs_region(dir, lead, trail));
}
height
}
// Never measured, so there is no height to place it at: it is
// measured at an oversized region first and drawn again at the
// box that measurement implies (`Painter::measure` then a
// real draw, not `reposition`, which writes an offset and
// never a size).
//
// A bottom-known row measures at a *zero-anchored* region
// rather than at its own box: using the real box would make
// the measurement's offered size track this list's own height,
// so a sibling growing taller (the input-box case) would look
// like a resize to every bottom-known row and force a full
// redraw of each -- despite a row's content depending only on
// width.
None => {
let measure_from = match placement {
Placement::Leading(lead) => lead,
Placement::Trailing(_) => 0.0,
};
let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING);
let height = resolve(painter.measure(widget, first));
let height = resolve(painter.widget_within(widget, first));
let (lead, trail) = placement.edges(height);
painter.widget_within(widget, Self::abs_region(dir, lead, trail));
painter.place(widget, Self::abs_region(dir, lead, trail));
height
}
};
let (lead, trail) = placement.edges(height);
if let Some(k) = key {
self.heights.insert(k, height);
// `extents` is what is *on screen* (`key_at`'s doc, and
// `rehome_anchor` below reads it as exactly that), so a
// first-time row that had to be drawn to be measured and
// turned out to be off-screen does not go in it.
if self.intersects_viewport(lead, trail) {
self.extents.insert(k, RowExtent { slot, lead, trail });
}
@@ -1154,16 +899,7 @@ impl LazySpan {
}
}
/// The oversized bound offered along the primary axis when a row's real
/// extent isn't known yet (a fresh top-known placement) or is deliberately
/// discarded (a bottom-known measurement, see `place`). Large enough that
/// no real row's content is taller than this -- rows do not clip to the
/// height they're offered, only width drives a wrapped row's height -- and
/// a fixed module constant rather than derived from `viewport_len`, since
/// deriving it from a value that changes whenever the list itself resizes
/// (a sibling growing) would make the offered region's *size* change too,
/// defeating the same-size-different-position fast path `place` depends
/// on for an O(1) move.
/// Primary-axis room for a row whose extent is not cached yet.
const GENEROUS_PADDING: f32 = 100_000.0;
impl LazySpan {
@@ -1321,6 +1057,10 @@ impl Widget for LazySpan {
self.ctl.set_travel(self.travel());
Size::REST
}
fn size_hint(&self, _axis: Axis) -> Option<Len> {
Some(Len::REST)
}
}
#[cfg(test)]
@@ -1719,7 +1459,7 @@ mod tests {
fn a_fill_shaped_background_is_not_left_oversized() {
// Regression test for a real bug found building the I3 example:
// `place`'s Bottom-known branch used to measure a row at an
// oversized, fixed-size region and `reposition` (a pure
// oversized, fixed-size region and placement (a pure
// translation) it into its final box. A row's own natural height
// is independent of that oversized offer (true for wrapped text),
// but a `Rect` background is *defined* to fill whatever it is
@@ -1751,7 +1491,7 @@ mod tests {
assert!(
(height - 20.0).abs() < 0.5,
"background rect should be exactly the row's height (20px), got {height}px \
-- an oversized measurement region leaking through would show as ~100000px"
-- an oversized provisional region leaking through would show as ~100000px"
);
}
}
@@ -2094,23 +1834,7 @@ mod tests {
}
}
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
/// replaced row's primitives drawn a second time, overlapping the
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
/// `ReplaceLast` case drives up to 400 times during a streamed reply
/// (`bench_client.rs`'s stream phase): the last slot's widget is
/// swapped for a brand-new one, same key, and (since a fresh widget
/// has no cached height) placed via `place`'s measure-then-draw path every
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
/// documents as the one place in this crate that deliberately draws a
/// widget twice. If `draw_inner`'s old-children diffing or
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
/// the evicted widget (or the provisional draw's own primitives), it
/// would show up here as `active_widgets` growing without bound.
/// **Passes as written** -- this pins the widget-arena layer as
/// correct in isolation; see the P0 box for where the duplicate was
/// actually chased to instead (`Span`'s two-phase draw and the
/// `redraw_all`-vs-`redraw_updates` split, still open).
/// Replacing a streamed row must not grow the active widget set.
#[test]
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
let mut rsc = TestRsc {
@@ -2129,9 +1853,6 @@ mod tests {
let before = render.active_widgets();
for i in 0..400u32 {
// A varying height keeps every replace on the measure-then-draw
// (cache-miss) path rather than settling into the O(1)
// same-size `mov` fast path once the height happens to repeat.
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
rsc.ui
.widgets
@@ -2151,26 +1872,7 @@ mod tests {
);
}
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
/// iris-phone-v2-2026-09-06.md), reproduced at its mechanism.
///
/// `replacing_the_last_row_many_times_does_not_leak_primitives` above
/// counts *widgets*, which is why it passed all along: the orphan's
/// owner is very much alive -- it is an earlier set of that same
/// widget's primitives that got stranded. What strands them is a row
/// marked dirty and then reached by its **ancestor's** redraw rather
/// than by its own: `draw_inner` only *read* the dirty mark, so the
/// whole branch that frees a redrawn widget's previous primitives was
/// skipped, and the fresh `ActiveData` overwrote the only handles that
/// could ever have freed them. `LazySpan` sets no mask, so that copy then
/// draws every frame at whatever region it last had -- including,
/// where the row was being measured at `GENEROUS_PADDING`, well below
/// the list's own box and under the composer.
///
/// Two rows, two shapes of the same fault: row 2 has a cached height
/// (one `widget_within`), row 4 is replaced so it has none (`place`'s
/// measure-then-draw, which reaches `draw_inner` twice for one id in one
/// frame and so orphans a copy even with no ancestor involved).
/// An ancestor redraw must retire every replaced primitive.
#[test]
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
let mut rsc = TestRsc {
+4 -13
View File
@@ -7,17 +7,12 @@ pub struct MaxSize {
}
impl MaxSize {
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
/// rel/abs/rest components are not otherwise comparable.
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
let Some(max) = max else {
return len;
};
let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output);
// `fold_dp`, not the caller's `max` as written: a reported `Len`
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
// collapsed composer bar this caused.
if len_px > max_px {
max.fold_dp(density)
} else {
@@ -25,12 +20,6 @@ impl MaxSize {
}
}
/// The span (in this widget's own local, `UiRegion::FULL`-relative
/// terms) to actually offer the child: unconstrained if it already fits
/// within `max`, or a box of exactly `max`, anchored at this axis's
/// start, if it does not. Needed so the child is never painted bigger
/// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`.
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
let Some(max) = max else {
return UiSpan::FULL;
@@ -55,9 +44,11 @@ impl Widget for MaxSize {
y: Self::clamp_region(offered.y, self.y, output.y, density),
};
let used = painter.widget_within(&self.inner, region);
Size {
let size = Size {
x: Self::clamp(used.x, self.x, output.x, density),
y: Self::clamp(used.y, self.y, output.y, density),
}
};
painter.place_used(&self.inner, size, UiRegion::FULL);
size
}
}
+3 -1
View File
@@ -8,6 +8,8 @@ pub struct Offset {
impl Widget for Offset {
fn draw(&mut self, painter: &mut Painter) -> Size {
let region = UiRegion::FULL.offset(self.amt);
painter.widget_within(&self.inner, region)
let used = painter.widget_within(&self.inner, region);
painter.place_used(&self.inner, used, region);
used
}
}
+16 -8
View File
@@ -3,29 +3,37 @@ use crate::prelude::*;
pub struct Pad {
pub padding: Padding,
pub inner: StrongWidget,
/// Cleared after the reported size fits the offered region.
pub exact_region: bool,
}
impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) -> Size {
let density = painter.density();
let used = painter.widget_within(&self.inner, self.padding.region(density));
let offered = painter.px_size();
let region = self.padding.region(density);
let used = painter.widget_within(&self.inner, region);
painter.place_used(&self.inner, used, region);
let width =
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
let height =
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
Size {
let size = Size {
x: used.x + Len::abs(width),
y: used.y + Len::abs(height),
};
let needed = size.to_uivec2(density).to_abs(painter.output_size());
if needed.x <= offered.x + 0.01 && needed.y <= offered.y + 0.01 {
self.exact_region = false;
}
size
}
fn requires_exact_region(&self) -> bool {
self.exact_region
}
}
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
/// against the display's density the same way any other size does -- see
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
/// `Len`, exactly as a bare number always has meant elsewhere in this
/// crate.
pub struct Padding {
pub left: Len,
pub right: Len,
+4 -102
View File
@@ -7,34 +7,11 @@
use crate::prelude::*;
use std::time::Instant;
/// A scrolling view over a child that is a fixed lump: it is measured
/// whole and then moved, which is what makes a scroll tick an O(1) move of
/// one subtree rather than a redraw.
///
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
/// a child that lays out lazily cannot be measured whole or moved as a
/// lump, and virtualising it inside one of these would never update which
/// rows it shows, since a scroll tick offers a same-size moved region and
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
/// owns a controller of its own instead of being wrapped in one of these.
/// A scrolling view that moves one fixed child as a subtree.
pub struct ScrollArea {
inner: StrongWidget,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, shared with
/// `LazySpan` rather than reimplemented beside it.
ctl: ScrollController,
container_len: f32,
/// How long the content is along the axis, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not tell
/// them apart: on the first frame the clamp computed a scroll range of
/// zero, concluded from `amt == range` that the area was sitting at
/// its end, and pinned it -- so the next frame, now knowing the real
/// length, jumped to it. A code fence therefore opened at the end of
/// its longest line, mid-word (`iris/run-headless.sh phone`,
/// 2026-09-08).
content_len: Option<f32>,
}
@@ -49,73 +26,23 @@ impl Scrollable for ScrollArea {
}
impl Widget for ScrollArea {
/// A scroll area animates exactly one thing, its fling. The
/// registration that makes this run is `UiData::animate`, which
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
/// reason: nothing drawn may depend on a length measured last frame.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint*, and it exists
/// only so that the usual case, where the content's length did not
/// change, offers the same region twice: `draw_inner` then makes the
/// first call an O(1) `mov` and returns at the first line of the
/// second. A frame on which the content did grow or shrink pays one
/// real extra draw, and that is a frame on which the content was being
/// redrawn anyway.
///
/// The alternative -- place against the hint and let the next frame
/// fix it -- is what Iris found on her phone (2026-09-08): every
/// newline typed into the composer drew the field in a box one line
/// short of its text, and since that text is centred in its box it
/// hung half a line past each end. There was no next frame: nothing
/// dirtied that subtree again, so the stale placement was the last one
/// drawn, until the keyboard closed and its inset rewrite forced a
/// redraw ("it fixes itself"). **Layout is a pure function of the
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
/// -- a correction that needs a second frame is a frame drawn wrong.
fn draw(&mut self, painter: &mut Painter) -> Size {
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a scroll area is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
let axis = self.ctl.axis();
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.ctl.set_density(painter.density());
// Where the delta asked for since the last frame puts the content.
// Already inside the range the previous frame published, so it is
// the position to *measure* against; the clamp below is what the
// length just measured has to say about it.
let delta = self.ctl.take_delta();
let travelled = self.ctl.amt() - delta;
self.ctl.set_amt(travelled);
// The container's own length stands in as the hint until anything
// has been measured: a zero-length region on the first frame would
// place the child's primitives against a box of no size.
let hint = self.content_len.unwrap_or(container_len);
// A **measurement**: this asks how long the content is, and the
// box it asks about is built from a hint that the answer below is
// about to correct. Drawing it here painted the whole content at a
// provisional offset and then painted it again at the real one.
let used = painter.measure(&self.inner, self.child_region(hint));
let used = painter.widget_within(&self.inner, self.child_region(hint));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
let measured = used
.axis(axis)
.apply_rest(painter.density())
@@ -123,18 +50,6 @@ impl Widget for ScrollArea {
self.content_len = Some(measured);
let range = (measured - container_len).max(0.0);
// The end-pin, and then the clamp, against the length just
// measured. Deliberately not also run before the measuring draw
// above -- clamping against the hint would let a stale length
// reduce `amt` in a way this pass cannot undo, and then where the
// content sits would depend on the previous frame after all.
//
// Only a frame with no delta of its own re-pins: the pin means
// "stay flush with the end as the content grows", and a reader who
// just scrolled away from that end has said otherwise. (A delta
// cannot be moving *toward* the end here -- the travel published
// below is zero that way while pinned, so `take_delta` has already
// clipped it.)
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
range
} else {
@@ -147,15 +62,7 @@ impl Widget for ScrollArea {
fwd: range - amt,
});
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and never
// recovers. What keeps the content inside the offered box is the
// mask a caller puts around it (`.scrollable(..).masked()`), not
// this number.
painter.widget_within(&self.inner, self.child_region(measured))
painter.place(&self.inner, self.child_region(measured))
}
}
@@ -176,12 +83,7 @@ impl ScrollArea {
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. The length is taken as
/// a parameter rather than read from `content_len`, because `draw`
/// places twice -- once against last frame's length and once against
/// the one it has just measured -- and the two must be the same
/// arithmetic.
/// A content-sized box offset by the current scroll amount.
fn child_region(&self, content_len: f32) -> UiRegion {
let axis = self.ctl.axis();
let mut region = UiRegion::FULL;
+10 -13
View File
@@ -8,15 +8,6 @@ pub struct Sized {
impl Widget for Sized {
fn draw(&mut self, painter: &mut Painter) -> Size {
// The child is drawn within a region that actually carves out the
// fixed axes, not whatever region this widget itself happened to
// be offered -- needed so the painted geometry matches the
// declared size returned below regardless of how much room a
// parent offers. `Aligned`'s single-draw pattern (LAYOUT.md
// section 6) draws its child once at its own *full* region to
// learn its size, then moves it into place with a pure
// translation; that translation is only valid if what got painted
// is already the reported size, anchored the same way both times.
let density = painter.density();
let mut region = UiRegion::FULL;
if let Some(x) = self.x {
@@ -26,12 +17,18 @@ impl Widget for Sized {
region.y = y.apply_rest(density).align(AxisAlign::Neg);
}
let used = painter.widget_within(&self.inner, region);
// `fold_dp` on the way out: a declared size is a `Len` the caller
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
// unresolved `dp` -- see `Len::fold_dp`.
Size {
let size = Size {
x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
};
painter.place_used(&self.inner, size, UiRegion::FULL);
size
}
fn size_hint(&self, axis: Axis) -> Option<Len> {
match axis {
Axis::X => self.x,
Axis::Y => self.y,
}
}
}
+41 -28
View File
@@ -4,11 +4,6 @@ use std::marker::PhantomData;
pub struct Span {
pub children: Vec<StrongWidget>,
pub dir: Dir,
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
/// density the same way any other size in the tree does -- see
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
/// were never supported for a gap and still are not.
pub gap: Len,
}
@@ -17,38 +12,43 @@ impl Widget for Span {
let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs;
// Phase 1: ask each child how long it is along the layout axis,
// at the ambient (unmodified, full) region.
//
// A **measurement**, so it writes nothing: the provisional slot
// this asks about is the whole span rather than the child's own
// share, so it is almost never where the child ends up, and
// painting it there meant every child of every `Span` was drawn
// twice -- once at a slot that was wrong by construction and once
// where it belongs. With containers nested that doubling
// compounds, which is where a streamed frame's 1,083 draws over
// 113 widgets came from.
let lens: Vec<Len> = self
let mut lens: Vec<Option<Len>> = self
.children
.iter()
.map(|child| painter.widget(child).axis(axis))
.map(|child| painter.known_len(child, axis))
.collect();
let mut drawn = vec![false; self.children.len()];
let mut cursor = UiScalar::rel_min();
for (i, child) in self.children.iter().enumerate() {
let len = match lens[i] {
Some(len) => len,
None => {
let mut slot = UiSpan::new(cursor, UiScalar::rel_max());
if self.dir.sign == Sign::Neg {
slot.flip();
}
let region = UiRegion::from_axis(axis, slot, UiSpan::FULL);
let len = painter.widget_within(child, region).axis(axis);
lens[i] = Some(len);
drawn[i] = true;
len
}
};
cursor.abs += len.abs + gap;
cursor.rel += len.rel;
}
let lens: Vec<Len> = lens.into_iter().map(Option::unwrap).collect();
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
// Phase 2: draw each child for real, using the lengths just
// learned -- the same arithmetic this loop always used. This is
// the only draw a child gets; where its size is unchanged and only
// its position moved, `draw_inner` turns it into one `move_offsets`
// write rather than a redraw. The cross-axis length of *this* draw
// (used for `Span`'s own reported size below) falls out of each
// child's real, resolved-width `used` here for free -- this is what replaces `desired_ortho`'s former
// duplicate simulation of this same loop (see LAYOUT.md section 4).
let mut start = UiScalar::rel_min();
let mut ortho_len = Len::ZERO;
let mut ortho_mixed = false;
for (child, &len) in self.children.iter().zip(&lens) {
let mut placed = Vec::with_capacity(self.children.len());
for (i, (child, &len)) in self.children.iter().zip(&lens).enumerate() {
let mut span = UiSpan::FULL;
span.start = start;
if len.rest > 0.0 {
@@ -64,7 +64,12 @@ impl Widget for Span {
if self.dir.sign == Sign::Neg {
child_region.flip(axis);
}
let used = painter.widget_within(child, child_region);
let used = if drawn[i] {
painter.place(child, child_region)
} else {
painter.widget_within(child, child_region)
};
placed.push(child_region);
start.abs += gap;
let ortho = used.axis(!axis);
@@ -76,6 +81,14 @@ impl Widget for Span {
}
if ortho_mixed {
ortho_len = Len::default();
} else {
let ortho = ortho_len
.apply_rest(painter.density())
.align(AxisAlign::Neg);
for (child, mut region) in self.children.iter().zip(placed) {
*region.axis_mut(!axis) = ortho;
painter.place(child, region);
}
}
let along = if total.rest == 0.0 && total.rel == 0.0 {
+42 -13
View File
@@ -9,25 +9,54 @@ pub struct Stack {
impl Widget for Stack {
fn draw(&mut self, painter: &mut Painter) -> Size {
let mut picked = None;
let mut iter = self.children.iter().enumerate();
if let Some((i, child)) = iter.next() {
let density = painter.density();
let known = match self.size {
StackSize::Default => Some(Size::REST),
StackSize::Child(i) => self.children.get(i).and_then(|child| {
Some(Size {
x: painter.known_len(child, Axis::X)?,
y: painter.known_len(child, Axis::Y)?,
})
}),
};
let region = known.map(|size| size.to_uivec2(density).align(RegionAlign::TOP_LEFT));
let mut used = Vec::with_capacity(self.children.len());
let mut iter = self.children.iter();
if let Some(child) = iter.next() {
painter.child_layer();
let used = painter.widget(child);
if matches!(self.size, StackSize::Child(j) if j == i) {
picked = Some(used);
}
used.push(match region {
Some(region) => painter.widget_within(child, region),
None => painter.widget(child),
});
}
for (i, child) in iter {
for child in iter {
painter.next_layer();
let used = painter.widget(child);
if matches!(self.size, StackSize::Child(j) if j == i) {
picked = Some(used);
used.push(match region {
Some(region) => painter.widget_within(child, region),
None => painter.widget(child),
});
}
let size = match self.size {
StackSize::Default => Size::default(),
StackSize::Child(i) => used.get(i).copied().unwrap_or_default(),
};
if known.is_none() {
let final_region = size.to_uivec2(density).align(RegionAlign::TOP_LEFT);
for (child, child_size) in self.children.iter().zip(used) {
let child_region = child_size
.to_uivec2(density)
.align(RegionAlign::TOP_LEFT)
.within(&final_region);
painter.place(child, child_region);
}
}
size
}
fn size_hint(&self, _axis: Axis) -> Option<Len> {
match self.size {
StackSize::Default => Size::default(),
StackSize::Child(_) => picked.unwrap_or_default(),
StackSize::Default => Some(Len::REST),
StackSize::Child(_) => None,
}
}
}
+3 -1
View File
@@ -8,7 +8,9 @@ pub struct WidgetPtr {
impl Widget for WidgetPtr {
fn draw(&mut self, painter: &mut Painter) -> Size {
if let Some(id) = &self.inner {
painter.widget(id)
let used = painter.widget(id);
painter.place_used(id, used, UiRegion::FULL);
used
} else {
Size::ZERO
}
+3 -34
View File
@@ -3,12 +3,6 @@ use crate::prelude::*;
#[derive(Clone, Copy)]
pub struct Rect {
pub color: UiColor,
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
/// and come out the same physical size on every display -- resolved
/// against `Painter::density` in [`Rect::draw`], the same place every
/// other `dp` is resolved. A plain number still works and still means
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
/// a hairline wants.
pub radius: Len,
pub thickness: f32,
pub inner_radius: f32,
@@ -37,40 +31,15 @@ impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive {
color: self.color,
// `rel` has no meaning for a corner (a rect that fills its
// parent has no length of its own to take a fraction of), so
// only the `abs`/`dp` halves are folded.
radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness,
inner_radius: self.inner_radius,
});
Size::REST // fills whatever it was given -- used == available
Size::REST
}
/// **No** -- despite drawing one primitive and nothing else.
///
/// `is_size_independent` asks whether the widget's *content* is
/// unaffected by how big a region it was given, so that
/// `draw_inner` may keep the primitives it already has and rewrite
/// their regions in place. A `Rect`'s content **is** its region: it
/// returns `Size::REST` and fills whatever it was handed, so the fast
/// path's `r.outside(&from).within(&region)` remap has to reproduce
/// the whole of `draw` -- and it does not, because a region carries
/// `rel` and `abs` components that the round trip cannot recover
/// separately.
///
/// What that looked like: a fenced code block's background
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
/// region draw `Span` does in its first phase, so one fence's panel
/// covered every block below it -- and every row below that -- while
/// the text itself was laid out correctly. Visible in
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
/// crate's `transcript` example. Answering `false` costs a redraw of
/// one primitive when a rect is resized, which is what the fast path
/// was saving.
fn is_size_independent(&self) -> bool {
false
fn size_hint(&self, _axis: Axis) -> Option<Len> {
Some(Len::REST)
}
}
+4
View File
@@ -125,6 +125,10 @@ impl Widget for TextEdit {
used
}
fn requires_exact_region(&self) -> bool {
true
}
/// I4 (RUST.md): the one override that exists so far -- everything
/// else falls back to `Widget::access_role`'s default `Unknown`.
fn access_role(&self) -> accesskit::Role {
+4
View File
@@ -145,6 +145,10 @@ impl Widget for Text {
self.update_buf();
self.view.draw(painter)
}
fn requires_exact_region(&self) -> bool {
true
}
}
impl Deref for Text {
+1
View File
@@ -9,6 +9,7 @@ widget_trait! {
|state| Pad {
padding: padding.into(),
inner: self.add_strong(state),
exact_region: false,
}
}