Redesign span layout around retained placement
This commit is contained in:
1 parent
ae0af8f5e3
commit
0aa03cf621
30 files changed
+501
-1321
No files matched your search
@@ -443,16 +443,16 @@ Each exists because something was invisible without it.
|
|||||||
`ArrBuf` reallocated on every length change, so adding one glyph made
|
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||||||
the buffer's contents undefined and forced a full rewrite; a redraw
|
the buffer's contents undefined and forced a full rewrite; a redraw
|
||||||
freed its primitives and pushed new ones, which -- since freed slots
|
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
|
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
|
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
|
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
|
(fling instances 1.0% against a 0.9% floor, stream instances 71.9%
|
||||||
0.6%). The CPU half improved with it, since the freeing and renumbering
|
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.
|
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
|
**Stream instances sit at 71.9%, against a 71.8% floor, and dirty
|
||||||
here.** The list is pinned to the newest end, so a growing reply moves
|
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
|
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
|
`move_offsets` write the layout is not making -- the next thing to look
|
||||||
at, and a layout question rather than an upload one.
|
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)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
This checkout runs in a VM while production runs on its host:
|
||||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
|
||||||
means here:
|
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
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
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
Project-specific only; keep cross-project machine notes out of this file.
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
//! Where the desktop app keeps its enrollment: `crate::client::config`'s
|
//! Where the desktop app keeps its enrollment: `crate::client::config`'s
|
||||||
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
|
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
|
||||||
//!
|
//!
|
||||||
//! Only the directory is this app's -- the file's name, its JSON, and its
|
//! Only the directory is this app's. The shared store owns the file name,
|
||||||
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
|
//! JSON, and owner-only mode required for a bearer token.
|
||||||
//! are the store's, shared with the Android client so the two cannot come
|
|
||||||
//! to disagree about them.
|
|
||||||
|
|
||||||
use crate::client::config::EnrollmentStore;
|
use crate::client::config::EnrollmentStore;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ impl Composer {
|
|||||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
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) {
|
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||||
pad.padding.bottom = Len::abs(inset);
|
pad.padding.bottom = Len::abs(inset);
|
||||||
|
pad.exact_region = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-57
@@ -12,49 +12,6 @@ and six phone-report sections went on 2026-09-08 for that reason.
|
|||||||
|
|
||||||
## Fix
|
## 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`
|
- [ ] **A row moving because the list grew should be one `move_offsets`
|
||||||
write, and today it is a redraw.** Found 2026-09-09 by
|
write, and today it is a redraw.** Found 2026-09-09 by
|
||||||
`scripts/rigs/ui-profile`'s `arena_churn` and left for whoever picks
|
`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.
|
half.
|
||||||
|
|
||||||
The measurement. Over the bench fixture's 401 streamed deltas, the
|
The measurement. Over the bench fixture's 401 streamed deltas, the
|
||||||
instance arena uploads **72.7%** of itself per frame, and that number
|
instance arena uploads **71.9%** of itself per frame against a
|
||||||
*is* the floor -- those entries genuinely differ, so no amount of
|
**71.8%** floor -- those entries genuinely differ, so no amount of
|
||||||
better dirty-tracking touches it. The control that says it is wrong is
|
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
|
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
|
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
|
the subtree (LAYOUT.md section 2) instead of rewriting every
|
||||||
primitive's absolute region.
|
primitive's absolute region.
|
||||||
|
|
||||||
What is different about the streaming path. The list is pinned to the
|
The list is pinned to the newest end, so a growing reply pushes every
|
||||||
newest end, so a growing reply pushes every row above it up by the
|
earlier row up. `draw_inner` now treats sub-pixel size differences as a
|
||||||
amount the last row grew. That is a translation of an already-drawn
|
move and the span refactor removed nearly all repeated draws, but the
|
||||||
subtree -- exactly what `move_offsets` is for -- but it arrives as a
|
instance floor remains 71.8%. The remaining question is why those
|
||||||
new offered region per row, and `draw_inner`'s fast path only takes
|
translations still rewrite primitive regions instead of stopping at
|
||||||
`mov` when `active.region.size() == region.size()`. Worth checking
|
the rows' move slots.
|
||||||
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.
|
|
||||||
|
|
||||||
Done looks like: `arena_churn`'s `what_a_streamed_reply_uploads` shows
|
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`
|
stream instances in the same range as the fling's, and its `whole`
|
||||||
|
|||||||
+27
-196
@@ -25,16 +25,10 @@ having been carried out.
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait Widget: Any {
|
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;
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
/// True if `draw`'s output (both the primitives it writes and the
|
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
|
||||||
/// `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 is_size_independent(&self) -> bool {
|
fn is_size_independent(&self) -> bool {
|
||||||
false
|
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
|
set in the context, which makes this slow and not cool." Folding sizing into
|
||||||
`draw` deletes that duplicate simulation, not just moves it.
|
`draw` deletes that duplicate simulation, not just moves it.
|
||||||
|
|
||||||
**No single-draw alternative was found that does less work per frame.** The
|
`size_hint` is not a second layout pass. It is an optional exact answer for
|
||||||
two-method trait was checked against three properties a real screen needs —
|
an axis the widget declares without painter context or child access. A
|
||||||
a row placing children in sequence, a widget centering on its own content,
|
lying hint fails a debug assertion when the widget is drawn.
|
||||||
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.
|
|
||||||
|
|
||||||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
### 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
|
disappear, which is a second `render` avoided per frame per text widget
|
||||||
that is being measured by a parent.
|
that is being measured by a parent.
|
||||||
|
|
||||||
**"Parent wants the child's height before deciding the width it will
|
`Span` has no size-only pass. It first reads exact, context-free
|
||||||
offer"** — the genuinely circular case named in the brief, e.g. a column
|
`Widget::size_hint(axis)` values. It then draws unknown fixed children
|
||||||
that sizes its own width to its widest child, where that child is wrapped
|
forward from the current cursor, retaining what they paint. Once every
|
||||||
text whose height (which the column's *own* height depends on) depends on
|
length is known, flexible space is allocated and `Painter::place` moves
|
||||||
the width the column has not yet decided. This is not solvable in one pass
|
each retained child into its final box. A child is redrawn only when that
|
||||||
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
box changes the size it was drawn for.
|
||||||
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
|
|
||||||
|
|
||||||
```rust
|
Hints are optional and affect cost, never correctness. `Sized` can report
|
||||||
/// Draw `child` at a provisional region to learn its size under one
|
its declared axis without inspecting its child, which covers the important
|
||||||
/// axis's worth of assumption, discard everything it wrote, then draw it
|
`.height(rest())` case. A debug assertion compares every hint with the
|
||||||
/// again at the region that assumption produced. For the rare parent that
|
eventual `draw` result. Widgets whose answer depends on shaping or on a
|
||||||
/// cannot pick an offered size without already knowing the answer.
|
child return `None`.
|
||||||
/// 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.
|
|
||||||
|
|
||||||
### 5. Caching and invalidation
|
### 5. Caching and invalidation
|
||||||
|
|
||||||
@@ -426,7 +395,7 @@ impl Widget for Aligned {
|
|||||||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||||
(None, None) => full,
|
(None, None) => full,
|
||||||
};
|
};
|
||||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
painter.place(&self.inner, region);
|
||||||
used
|
used
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,9 +404,9 @@ impl Widget for Aligned {
|
|||||||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||||
return type from `()` to `Size`, carrying the child's `draw` result back —
|
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.
|
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
|
`Painter::place` moves an already-drawn child when its used area fits the
|
||||||
was actually drawn and where it belongs and calling the O(1) `mov` from
|
target box, and redraws it when the target changes its size. `SizeCtx` and
|
||||||
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
`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
|
transcript scroll must not pay every frame) but kept for resize-shaped
|
||||||
changes (§3) where the content's own region field, not an ancestor
|
changes (§3) where the content's own region field, not an ancestor
|
||||||
chain, is what has to change.
|
chain, is what has to change.
|
||||||
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
- **A general measurement API** — rejected because it walks the same nested
|
||||||
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
tree again. The narrow `size_hint(axis)` contract is exact,
|
||||||
implement to skip a draw when a cheap answer exists) — considered and
|
context-free, and optional; it exists only for sizes a widget already
|
||||||
rejected: it reintroduces exactly the "two names for one concept" split
|
declares itself.
|
||||||
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.
|
|
||||||
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
||||||
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
||||||
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
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
|
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||||
the better bound.
|
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)
|
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||||
|
|
||||||
Iris asked for a third length kind beside `abs` (physical pixels) and
|
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()`/
|
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||||
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||||
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
|
`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
|
boundary's global logical-space stopgap could come out entirely: window
|
||||||
size, touch coordinates and insets are physical pixels again, matching
|
size, touch coordinates and insets are physical pixels again, matching
|
||||||
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
`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
|
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
|
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.
|
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
|
A move alone cannot fix a changed size; `Painter::place` redraws in that
|
||||||
size.
|
case.
|
||||||
|
|
||||||
The cost is bounded and worth stating, because it is what makes the rule
|
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
|
safe to apply everywhere: the second draw happens only on the frame a
|
||||||
|
|||||||
+12
-29
@@ -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%.
|
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
|
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
|
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
|
names them), and nested provisional layout meant the arena's high-water was
|
||||||
child's size, and containers nest -- meant the arena's high-water was
|
|
||||||
the *transient* push count: 17 million pushes across 401 deltas, and
|
the *transient* push count: 17 million pushes across 401 deltas, and
|
||||||
127,443 slots for 11,569 live primitives, growing linearly with the
|
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
|
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
|
marking, and `arena_churn` prints both numbers so the gap cannot reopen
|
||||||
unnoticed.
|
unnoticed.
|
||||||
|
|
||||||
**A measurement is a mode, not a discarded draw (added later the same
|
**Layout has no measurement mode.** A widget is drawn provisionally only
|
||||||
day).** `Painter::draw_twice(child, first, |used| second)` became
|
when its size cannot be known yet, and that retained drawing is moved into
|
||||||
`Painter::measure` + an ordinary draw, at Iris's request: her objection
|
place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized`
|
||||||
was the shape it forced on the caller, since the arithmetic that picks
|
report an exact `Len`; a debug assertion compares every hint with the real
|
||||||
the real region had to happen inside a closure and anything it wanted to
|
draw result. If final allocation changes a child's size, `Painter::place`
|
||||||
keep came back out through a captured `&mut`. Two statements now say it
|
redraws it in that box. Otherwise placement is one move-offset write.
|
||||||
in the order it happens.
|
|
||||||
|
|
||||||
`DrawMode::Measure` is that draw with everything it *writes* switched
|
Measured over the fixture's 401 streamed events: the busiest frame makes
|
||||||
off -- no arena slot, no mask, no move slot, nothing left in `active`,
|
176 `Widget::draw` calls and the worst widget is called four times.
|
||||||
nothing marked dirty -- so the real draw that follows is an ordinary one
|
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
|
||||||
and cannot be short-circuited by the measurement having "already drawn"
|
Arena size and upload floors are unchanged.
|
||||||
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.
|
|
||||||
|
|
||||||
**What is left, and it is a layout question rather than an upload one.**
|
**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
|
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
|
carry an absolute region. Moving a subtree is supposed to be one
|
||||||
`move_offsets` write (LAYOUT.md section 2); something on this path is
|
`move_offsets` write (LAYOUT.md section 2); something on this path is
|
||||||
|
|||||||
+18
-20
@@ -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:
|
Two widgets have one, and they differ only in how they spend a delta:
|
||||||
|
|
||||||
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
|
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, drawn and then
|
||||||
then slid about as a lump, which is what makes a scroll tick an O(1)
|
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.
|
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
|
||||||
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
|
- **`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
|
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
|
A scrollbar needs a real content length before it can use either, and a
|
||||||
lazy span has none. Do not invent one.
|
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.
|
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
|
2. Offer the child **last frame's** length and read the size it reports.
|
||||||
it. This is free in the common case: the same region as last frame
|
An unchanged child returns from `draw_inner` without running `draw`.
|
||||||
means `draw_inner` returns immediately.
|
3. Apply the pin and clamp against that size.
|
||||||
3. Apply the pin and clamp against the length just measured.
|
4. Place the retained drawing at its exact length and position. It is
|
||||||
4. Draw the child again, at that length and position.
|
redrawn only if its reported size does not fit that box.
|
||||||
|
|
||||||
Only the second draw decides anything, and a frame on which the content
|
There is no measurement mode and no discarded drawing. Placing against
|
||||||
did change pays one real extra draw — a frame on which it was being
|
the old length and letting the next frame correct it is not valid: layout
|
||||||
redrawn anyway. Placing against the hint and letting the next frame fix it
|
must finish from the current state even if no later frame arrives.
|
||||||
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.
|
|
||||||
|
|
||||||
The pin only re-pins on a frame with **no delta of its own**: the pin
|
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
|
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:
|
Measured 2026-09-08, and worth not re-deriving:
|
||||||
|
|
||||||
- A `Span` is skipped entirely in the steady state, but **when it is
|
- A `Span` is skipped entirely in the steady state. When redrawn, it uses
|
||||||
redrawn it costs two draws per child** (21 draws for 10 children):
|
exact hints first, draws unknown fixed children forward from the cursor,
|
||||||
phase 1 offers each child the ambient region to learn its length,
|
and places retained drawings after flexible allocation. A child is
|
||||||
phase 2 offers it its real share. So any mutation of a `Span` redraws
|
redrawn only when its final box changes size.
|
||||||
all of it — 24 draws for 11 children after one prepend.
|
|
||||||
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
|
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
|
||||||
scroll tick offers a same-size moved region, `draw_inner` takes the
|
scroll tick offers a same-size moved region, `draw_inner` takes the
|
||||||
`mov` path, and the child's `draw` never runs. A virtualising child
|
`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
|
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
|
recompute what changed" optimisation would have to beat, and it is why
|
||||||
the walk was left alone.
|
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
|
- 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 —
|
write is moving the content as a unit, which needs a content length —
|
||||||
which a lazy layout cannot supply.
|
which a lazy layout cannot supply.
|
||||||
|
|||||||
@@ -264,22 +264,8 @@ impl Primitives {
|
|||||||
slot as u32
|
slot as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrites a slot this widget already owns, instead of freeing it
|
/// Rewrites a slot this widget already owns. Freed slots are not reusable
|
||||||
/// and allocating another -- the recycle path
|
/// until the end of a frame, so nested provisional redraws must recycle.
|
||||||
/// (`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.
|
|
||||||
///
|
|
||||||
/// The caller has already checked that `h` is the same kind of
|
/// The caller has already checked that `h` is the same kind of
|
||||||
/// primitive in the same layer, which is what makes the slot, its
|
/// 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
|
/// entry in the per-primitive data, and its position in the layer's
|
||||||
|
|||||||
@@ -11,59 +11,15 @@ pub struct ActiveData {
|
|||||||
pub textures: Vec<TextureHandle>,
|
pub textures: Vec<TextureHandle>,
|
||||||
pub primitives: Vec<PrimitiveHandle>,
|
pub primitives: Vec<PrimitiveHandle>,
|
||||||
pub children: Vec<WidgetId>,
|
pub children: Vec<WidgetId>,
|
||||||
/// The mask this widget was drawn **under** (its parent's), not the
|
/// The inherited mask, not `own_mask`.
|
||||||
/// one it set for itself -- see `own_mask` for that.
|
|
||||||
pub mask: MaskIdx,
|
pub mask: MaskIdx,
|
||||||
/// The mask slot this widget allocated for *itself* with
|
/// The widget's retained mask slot, or `MaskIdx::NONE`.
|
||||||
/// `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.
|
|
||||||
pub own_mask: MaskIdx,
|
pub own_mask: MaskIdx,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
/// What `Widget::draw` returned the last time this widget was actually
|
/// The last `Widget::draw` result.
|
||||||
/// drawn -- read by a parent placing this widget again without
|
|
||||||
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
|
|
||||||
/// section 5.
|
|
||||||
pub size: Size,
|
pub size: Size,
|
||||||
/// This widget's slot in `UiData::move_offsets`, assigned on its first
|
/// Retained so descendants' parent links stay valid across redraws.
|
||||||
/// 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.
|
|
||||||
pub move_slot: MoveIdx,
|
pub move_slot: MoveIdx,
|
||||||
/// How much of this widget's own `move_slot` delta is already folded
|
/// The part of this widget's move delta already folded into `region`.
|
||||||
/// 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.
|
|
||||||
pub move_applied: Vec2,
|
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
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Color, DrawMode, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData,
|
Axis, Color, Len, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer,
|
||||||
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
||||||
render::{
|
render::{
|
||||||
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
|
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
|
||||||
PrimitiveHandle, PrimitiveInst, RectPrimitive,
|
PrimitiveHandle, PrimitiveInst, RectPrimitive,
|
||||||
@@ -9,7 +9,6 @@ use crate::{
|
|||||||
util::Vec2,
|
util::Vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// makes your surfaces look pretty
|
|
||||||
pub struct Painter<'a> {
|
pub struct Painter<'a> {
|
||||||
pub(super) state: &'a mut UiRenderState,
|
pub(super) state: &'a mut UiRenderState,
|
||||||
pub(super) rsc: &'a mut dyn UiRsc,
|
pub(super) rsc: &'a mut dyn UiRsc,
|
||||||
@@ -17,43 +16,19 @@ pub struct Painter<'a> {
|
|||||||
pub(super) region: UiRegion,
|
pub(super) region: UiRegion,
|
||||||
pub(super) mask: MaskIdx,
|
pub(super) mask: MaskIdx,
|
||||||
pub(super) move_slot: MoveIdx,
|
pub(super) move_slot: MoveIdx,
|
||||||
/// This widget's own mask slot, reused across redraws -- see
|
/// This widget's retained mask slot.
|
||||||
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
|
|
||||||
/// for the first time in this widget's life.
|
|
||||||
pub(super) own_mask: MaskIdx,
|
pub(super) own_mask: MaskIdx,
|
||||||
pub(super) textures: Vec<TextureHandle>,
|
pub(super) textures: Vec<TextureHandle>,
|
||||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||||
/// The handles this widget owned before *this* draw, offered back to
|
/// Previous handles, consumed in draw order and freed if left over.
|
||||||
/// 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.
|
|
||||||
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
|
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
|
||||||
pub(super) children: Vec<WidgetId>,
|
pub(super) children: Vec<WidgetId>,
|
||||||
|
pub(super) reuse_child_sizes: bool,
|
||||||
pub layer: usize,
|
pub layer: usize,
|
||||||
pub(super) id: WidgetId,
|
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> {
|
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) {
|
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
||||||
self.write_primitive(primitive, region, Drawn::Yes);
|
self.write_primitive(primitive, region, Drawn::Yes);
|
||||||
}
|
}
|
||||||
@@ -74,8 +49,6 @@ impl<'a> Painter<'a> {
|
|||||||
let h = self.recycle.peek()?;
|
let h = self.recycle.peek()?;
|
||||||
let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No);
|
let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No);
|
||||||
if h.binding != binding || h.layer != self.layer || !drawn_matches {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
self.recycle.next()
|
self.recycle.next()
|
||||||
@@ -89,9 +62,6 @@ impl<'a> Painter<'a> {
|
|||||||
region: UiRegion,
|
region: UiRegion,
|
||||||
drawn: Drawn,
|
drawn: Drawn,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
if self.measuring() {
|
|
||||||
return u32::MAX;
|
|
||||||
}
|
|
||||||
let inst = PrimitiveInst {
|
let inst = PrimitiveInst {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
primitive,
|
primitive,
|
||||||
@@ -107,7 +77,6 @@ impl<'a> Painter<'a> {
|
|||||||
None => self.state.write_primitive(self.layer, drawn, inst),
|
None => self.state.write_primitive(self.layer, drawn, inst),
|
||||||
};
|
};
|
||||||
if self.mask != MaskIdx::NONE {
|
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);
|
self.rsc.ui_mut().masks.push_ref(self.mask);
|
||||||
}
|
}
|
||||||
let slot = h.slot;
|
let slot = h.slot;
|
||||||
@@ -157,13 +126,6 @@ impl<'a> Painter<'a> {
|
|||||||
/// so keeps pointing at whichever slot it was drawn under. See
|
/// so keeps pointing at whichever slot it was drawn under. See
|
||||||
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||||
pub fn set_mask(&mut self, region: UiRegion) {
|
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);
|
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
|
||||||
self.set_mask_to(shape);
|
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
|
/// with no radius argument anywhere that could fall out of step with
|
||||||
/// the one being drawn.
|
/// the one being drawn.
|
||||||
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
|
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(|| {
|
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
|
||||||
panic!(
|
panic!(
|
||||||
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
|
"'{}' 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))
|
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 {
|
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
|
||||||
self.children.push(id.id());
|
self.children.push(id.id());
|
||||||
// Passed directly rather than looked up from `self.active`: this
|
// Passed directly rather than looked up from `self.active`: this
|
||||||
@@ -277,66 +243,65 @@ impl<'a> Painter<'a> {
|
|||||||
Some(self.id),
|
Some(self.id),
|
||||||
self.move_slot.idx() as u32,
|
self.move_slot.idx() as u32,
|
||||||
self.mask,
|
self.mask,
|
||||||
self.mode,
|
|
||||||
Retained::default(),
|
Retained::default(),
|
||||||
self.rsc,
|
self.rsc,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask `widget` how big it would be in `region`, **writing nothing**
|
/// Place an already-drawn child's used area, redrawing only if its size changes.
|
||||||
/// -- see [`DrawMode::Measure`]. For the container that cannot choose
|
pub fn place<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
let region = region.within(&self.region);
|
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) {
|
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
|
/// the layer's one instanced draw, so it goes through
|
||||||
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||||
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
||||||
if self.measuring() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
|
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
|
||||||
Some(h) => {
|
Some(h) => {
|
||||||
self.state.primitives.recycle_image(
|
self.state.primitives.recycle_image(
|
||||||
|
|||||||
+104
-211
@@ -2,8 +2,8 @@ use std::sync::Mutex;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, Size,
|
ActiveData, Axis, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
Size, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||||
render::{
|
render::{
|
||||||
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
|
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
|
||||||
RectPrimitive, rounded_rect_coverage,
|
RectPrimitive, rounded_rect_coverage,
|
||||||
@@ -94,65 +94,19 @@ pub struct UiRenderState {
|
|||||||
last_input_at: Mutex<Option<Instant>>,
|
last_input_at: Mutex<Option<Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a draw is producing what goes on screen, or only asking a
|
/// State retained while replacing one draw with another.
|
||||||
/// 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.
|
|
||||||
pub(crate) struct Retained {
|
pub(crate) struct Retained {
|
||||||
/// So children this draw does not draw again can be retired.
|
pub region: Option<UiRegion>,
|
||||||
pub children: Vec<WidgetId>,
|
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 move_slot: Option<MoveIdx>,
|
||||||
pub own_mask: MaskIdx,
|
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>,
|
pub primitives: Vec<PrimitiveHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Retained {
|
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 {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
region: None,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
move_slot: None,
|
move_slot: None,
|
||||||
own_mask: MaskIdx::NONE,
|
own_mask: MaskIdx::NONE,
|
||||||
@@ -453,7 +407,6 @@ impl UiRenderState {
|
|||||||
None,
|
None,
|
||||||
MoveOffset::NONE_PARENT,
|
MoveOffset::NONE_PARENT,
|
||||||
MaskIdx::NONE,
|
MaskIdx::NONE,
|
||||||
DrawMode::Draw,
|
|
||||||
Retained::default(),
|
Retained::default(),
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
@@ -477,7 +430,6 @@ impl UiRenderState {
|
|||||||
.unwrap_or(MoveOffset::NONE_PARENT)
|
.unwrap_or(MoveOffset::NONE_PARENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: should prolly make a DrawInfo struct or smth for everything other than rsc
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn draw_inner(
|
pub(super) fn draw_inner(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -487,120 +439,83 @@ impl UiRenderState {
|
|||||||
parent: Option<WidgetId>,
|
parent: Option<WidgetId>,
|
||||||
parent_move_slot: u32,
|
parent_move_slot: u32,
|
||||||
mask: MaskIdx,
|
mask: MaskIdx,
|
||||||
mode: DrawMode,
|
|
||||||
retained: Retained,
|
retained: Retained,
|
||||||
rsc: &mut dyn UiRsc,
|
rsc: &mut dyn UiRsc,
|
||||||
) -> Size {
|
) -> Size {
|
||||||
let Retained {
|
let Retained {
|
||||||
|
region: mut old_region,
|
||||||
children: mut old_children,
|
children: mut old_children,
|
||||||
move_slot: mut old_move_slot,
|
move_slot: mut old_move_slot,
|
||||||
mut own_mask,
|
mut own_mask,
|
||||||
primitives: mut recycle,
|
primitives: mut recycle,
|
||||||
} = retained;
|
} = retained;
|
||||||
// Consumed here, not merely read: this call *is* the redraw the mark
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
// asked for, and leaving the mark set is what stranded a widget's
|
let requires_exact_region = rsc
|
||||||
// primitives. A measure-then-draw reaches this twice for the same id
|
.widgets()
|
||||||
// in one frame (`LazySpan::place`'s measurement pass), and on the second
|
.get_dyn(id)
|
||||||
// call the still-set mark took the whole `if let` below -- including
|
.is_some_and(|widget| widget.requires_exact_region());
|
||||||
// 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)
|
|
||||||
};
|
|
||||||
|
|
||||||
// What a measurement can answer without drawing at all.
|
if let Some(active) = self.active.get_mut(&id)
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
&& !dirty
|
&& !dirty
|
||||||
|
&& active.layer == layer
|
||||||
|
&& active.mask == mask
|
||||||
{
|
{
|
||||||
// check to see if we can skip drawing first
|
|
||||||
if active.region == region {
|
if active.region == region {
|
||||||
return active.size;
|
return active.size;
|
||||||
} else if active.region.size() == region.size() {
|
} else if Self::same_size(active.region, region, self.output_size) {
|
||||||
// TODO: epsilon?
|
|
||||||
let from = active.region;
|
let from = active.region;
|
||||||
let size = active.size;
|
let size = active.size;
|
||||||
self.mov(id, from, region, rsc);
|
self.mov(id, from, region, rsc);
|
||||||
return size;
|
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
|
} else if rsc
|
||||||
.widgets()
|
.widgets()
|
||||||
.get_dyn(id)
|
.get_dyn(id)
|
||||||
.map(|w| w.is_size_independent())
|
.map(|w| w.is_size_independent())
|
||||||
.unwrap_or(false)
|
.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;
|
let from = active.region;
|
||||||
for h in &active.primitives {
|
for h in &active.primitives {
|
||||||
let r = self.primitives.region_mut(h);
|
let r = self.primitives.region_mut(h);
|
||||||
*r = r.outside(&from).within(®ion);
|
*r = r.outside(&from).within(®ion);
|
||||||
self.region_mut_count += 1;
|
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;
|
active.region = region;
|
||||||
return active.size;
|
return active.size;
|
||||||
}
|
}
|
||||||
// if not, then maintain resize and track old children to remove unneeded
|
|
||||||
let active = self.remove(id, false, true, rsc).unwrap();
|
let active = self.remove(id, false, true, rsc).unwrap();
|
||||||
|
old_region = Some(active.region);
|
||||||
old_children = active.children;
|
old_children = active.children;
|
||||||
old_move_slot = Some(active.move_slot);
|
old_move_slot = Some(active.move_slot);
|
||||||
own_mask = active.own_mask;
|
own_mask = active.own_mask;
|
||||||
recycle = active.primitives;
|
recycle = active.primitives;
|
||||||
} else if dirty && self.active.contains_key(&id) {
|
} else if self.active.contains_key(&id) {
|
||||||
debug_assert!(!mode.measuring());
|
let layer_changed = self
|
||||||
// Dirty and already drawn: none of the fast paths above may be
|
.active
|
||||||
// taken (the widget's own content changed, so its old primitives
|
.get(&id)
|
||||||
// say nothing about its new ones), but they are also the only
|
.is_some_and(|active| active.layer != layer);
|
||||||
// thing that frees them. Same two lines, reached the other way.
|
|
||||||
let active = self.remove(id, false, true, rsc).unwrap();
|
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_children = active.children;
|
||||||
old_move_slot = Some(active.move_slot);
|
old_move_slot = Some(active.move_slot);
|
||||||
own_mask = active.own_mask;
|
own_mask = active.own_mask;
|
||||||
recycle = active.primitives;
|
recycle = active.primitives;
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw widget
|
|
||||||
let reentrant = !self.draw_started.insert(id);
|
let reentrant = !self.draw_started.insert(id);
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
!reentrant,
|
!reentrant,
|
||||||
@@ -608,23 +523,11 @@ impl UiRenderState {
|
|||||||
the second draw's primitives would orphan the first's"
|
the second draw's primitives would orphan the first's"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A measurement writes no primitive, so nothing ever reads this
|
let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc);
|
||||||
// -- 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),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 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 {
|
let mut painter = Painter {
|
||||||
state: self,
|
state: self,
|
||||||
region,
|
region,
|
||||||
@@ -637,23 +540,34 @@ impl UiRenderState {
|
|||||||
primitives: Vec::new(),
|
primitives: Vec::new(),
|
||||||
recycle: recycle.into_iter().peekable(),
|
recycle: recycle.into_iter().peekable(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
mode,
|
reuse_child_sizes,
|
||||||
rsc,
|
rsc,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
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;
|
painter.state.draw_count += 1;
|
||||||
let size = widget.draw(&mut painter);
|
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!(
|
debug_assert!(
|
||||||
size.x.dp == 0.0 && size.y.dp == 0.0,
|
size.x.dp == 0.0 && size.y.dp == 0.0,
|
||||||
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
||||||
report `Len::fold_dp(painter.density())` instead"
|
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);
|
drop(widget);
|
||||||
painter.state.draw_started.remove(&id);
|
painter.state.draw_started.remove(&id);
|
||||||
|
|
||||||
@@ -668,29 +582,11 @@ impl UiRenderState {
|
|||||||
primitives,
|
primitives,
|
||||||
recycle,
|
recycle,
|
||||||
children,
|
children,
|
||||||
|
reuse_child_sizes: _,
|
||||||
layer,
|
layer,
|
||||||
id,
|
id,
|
||||||
mode: _,
|
|
||||||
} = painter;
|
} = 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
|
// Whatever the draw did not claim is genuinely gone: this draw
|
||||||
// wrote fewer primitives than the last one, or stopped matching
|
// wrote fewer primitives than the last one, or stopped matching
|
||||||
// part way. Freeing it here rather than in `remove` is what lets
|
// part way. Freeing it here rather than in `remove` is what lets
|
||||||
@@ -714,7 +610,6 @@ impl UiRenderState {
|
|||||||
move_slot,
|
move_slot,
|
||||||
own_mask,
|
own_mask,
|
||||||
move_applied: Vec2::ZERO,
|
move_applied: Vec2::ZERO,
|
||||||
repositioned: Vec2::ZERO,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// remove old children that weren't kept
|
// remove old children that weren't kept
|
||||||
@@ -782,67 +677,64 @@ impl UiRenderState {
|
|||||||
self.mov_count += 1;
|
self.mov_count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move an already-active widget to `to`. Used by `Painter::reposition`,
|
fn same_size(a: UiRegion, b: UiRegion, output: Vec2) -> bool {
|
||||||
/// for a parent that drew a child provisionally (at the whole region it
|
let a = a.size().to_abs(output);
|
||||||
/// was offered) and now knows where the child actually belongs.
|
let b = b.size().to_abs(output);
|
||||||
///
|
(a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() < 0.01
|
||||||
/// 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
|
pub(super) fn place(
|
||||||
/// offered everything and chose, on its own, to occupy only
|
&mut self,
|
||||||
/// `active.size` of it. By convention every widget in this crate that
|
id: WidgetId,
|
||||||
/// does that anchors its own content at the top-left of whatever it
|
to: UiRegion,
|
||||||
/// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw`
|
rsc: &mut dyn UiRsc,
|
||||||
/// bodies), so that is where this assumes the child was actually
|
) -> Option<Size> {
|
||||||
/// painted, not `active.region` itself (which is the *offered* box,
|
let active = self.active.get(&id)?;
|
||||||
/// usually bigger). A nested `Aligned` whose own child is not top-left
|
if rsc
|
||||||
/// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape
|
.widgets()
|
||||||
/// this does not cover; none of iris's widgets or examples build that
|
.get_dyn(id)
|
||||||
/// today. See LAYOUT.md's "Rejected, and why" / deviations for the
|
.is_some_and(|widget| widget.requires_exact_region())
|
||||||
/// full reasoning.
|
&& !Self::same_size(active.region, to, self.output_size)
|
||||||
///
|
{
|
||||||
/// The delta is overwritten, not accumulated like `mov`'s: `from` is
|
return None;
|
||||||
/// 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;
|
|
||||||
let from = active
|
let from = active
|
||||||
.size
|
.size
|
||||||
.to_uivec2(self.density)
|
.to_uivec2(self.density)
|
||||||
.align(RegionAlign::TOP_LEFT)
|
.align(RegionAlign::TOP_LEFT)
|
||||||
.within(&active.region);
|
.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 slot = active.move_slot;
|
||||||
let from_px = from.top_left().to_abs(self.output_size);
|
let from_px = from.top_left().to_abs(self.output_size);
|
||||||
let to_px = to.top_left().to_abs(self.output_size);
|
let to_px = to.top_left().to_abs(self.output_size);
|
||||||
let delta = to_px - from_px;
|
let delta = to_px - from_px;
|
||||||
// Not `delta` alone: a parent may have `mov`ed this widget to a
|
if delta.x.abs() < 0.01 && delta.y.abs() < 0.01 {
|
||||||
// region that itself moved earlier in the same frame, and that
|
if let Some(active) = self.active.get_mut(&id) {
|
||||||
// part of the slot is `move_applied`'s, not this call's. Writing
|
active.region = to;
|
||||||
// `delta` on its own dropped it and put the content back at the
|
}
|
||||||
// pre-move position. `from` is computed against `active.region`,
|
return Some(size);
|
||||||
// which `mov` already updated, so `delta` is purely the placement
|
}
|
||||||
// inside the region and the two summands never overlap.
|
|
||||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
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];
|
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
|
||||||
if let Some(active) = self.active.get_mut(&id) {
|
if let Some(active) = self.active.get_mut(&id) {
|
||||||
active.repositioned = delta;
|
active.region = to;
|
||||||
|
active.move_applied += delta;
|
||||||
}
|
}
|
||||||
self.mov_count += 1;
|
self.mov_count += 1;
|
||||||
|
Some(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
|
/// 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
|
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||||
/// transcript is tens of thousands and made a debug build on a phone
|
/// transcript is tens of thousands and made a debug build on a phone
|
||||||
/// too slow to finish a benchmark run.
|
/// too slow to finish a benchmark run.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
fn primitive_counts_agree(&self) -> bool {
|
fn primitive_counts_agree(&self) -> bool {
|
||||||
let live: usize = self.primitives.live_count();
|
let live: usize = self.primitives.live_count();
|
||||||
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||||
@@ -1316,8 +1209,8 @@ impl UiRenderState {
|
|||||||
parent,
|
parent,
|
||||||
parent_move_slot,
|
parent_move_slot,
|
||||||
active.mask,
|
active.mask,
|
||||||
DrawMode::Draw,
|
|
||||||
Retained {
|
Retained {
|
||||||
|
region: Some(active.region),
|
||||||
children: active.children,
|
children: active.children,
|
||||||
move_slot: Some(active.move_slot),
|
move_slot: Some(active.move_slot),
|
||||||
own_mask: active.own_mask,
|
own_mask: active.own_mask,
|
||||||
|
|||||||
+17
-31
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Painter, Size};
|
use crate::{Axis, Len, Painter, Size};
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
mod data;
|
mod data;
|
||||||
@@ -16,46 +16,28 @@ pub use view::*;
|
|||||||
pub use widgets::*;
|
pub use widgets::*;
|
||||||
|
|
||||||
pub trait Widget: Any {
|
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;
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
/// True if `draw`'s output (both the primitives it writes and the
|
/// An exact, context-free length known without drawing or inspecting children.
|
||||||
/// `Size` it returns) is the same for any `painter.region()` of the
|
fn size_hint(&self, _axis: Axis) -> Option<Len> {
|
||||||
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
None
|
||||||
/// 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.
|
/// Whether the draw result is independent of the offered region.
|
||||||
fn is_size_independent(&self) -> bool {
|
fn is_size_independent(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What kind of control this is, for the AccessKit tree `ui::access`
|
fn requires_exact_region(&self) -> bool {
|
||||||
/// builds (RUST.md's I4). Only consulted for a widget that also has an
|
false
|
||||||
/// 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
|
/// The AccessKit role for a labelled widget.
|
||||||
/// generic control with no more specific semantics); a widget with a
|
|
||||||
/// real platform equivalent -- `TextEdit`'s `MultilineTextInput` --
|
|
||||||
/// overrides it.
|
|
||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
accesskit::Role::Unknown
|
accesskit::Role::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance whatever this widget is animating to `now`, and say whether
|
/// Advance an animation and report whether it needs another frame.
|
||||||
/// 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.
|
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn tick(&mut self, now: std::time::Instant) -> bool {
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
false
|
false
|
||||||
@@ -70,6 +52,10 @@ impl Widget for () {
|
|||||||
fn is_size_independent(&self) -> bool {
|
fn is_size_independent(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self, _axis: Axis) -> Option<Len> {
|
||||||
|
Some(Len::ZERO)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl dyn Widget {
|
impl dyn Widget {
|
||||||
|
|||||||
+51
-17
@@ -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
|
/// 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
|
/// 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
|
/// 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 masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
|
||||||
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
|
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
|
// 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
|
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
|
||||||
// still read zero after the scroll. The visible counterpart of this
|
// 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
|
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
|
||||||
// window, then a further keystroke -- the redraw that must land in the
|
// window, then a further keystroke -- the redraw that must land in the
|
||||||
// bar's new (also short) region, not whatever region a provisional
|
// 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.resize((1080.0, 1478.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
field.edit(&mut rsc).insert("b");
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((800.0, 600.0));
|
render.resize((800.0, 600.0));
|
||||||
render.update(&root, &mut rsc);
|
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();
|
let first = render.window_region(&below_w, &rsc).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(first.top_left.y - 100.0).abs() < 0.01,
|
(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
|
/// 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
|
/// `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
|
/// 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").
|
/// 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),
|
UiScalar::abs(self.place_top + 40.0),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
painter.reposition(&self.inner, place);
|
painter.place(&self.inner, place);
|
||||||
Size::default()
|
Size::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
|
/// Placement must preserve a move already applied in the same frame.
|
||||||
/// 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.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
||||||
let mut rsc = TestRsc {
|
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 {
|
let padded = rsc.ui.widgets.add_strong(Pad {
|
||||||
padding: Padding::uniform(PAD),
|
padding: Padding::uniform(PAD),
|
||||||
inner: sized.any(),
|
inner: sized.any(),
|
||||||
|
exact_region: false,
|
||||||
});
|
});
|
||||||
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
|
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
|
||||||
let card = rsc.ui.widgets.add_strong(Stack {
|
let card = rsc.ui.widgets.add_strong(Stack {
|
||||||
|
|||||||
@@ -7,18 +7,17 @@ pub struct Image {
|
|||||||
|
|
||||||
impl Widget for Image {
|
impl Widget for Image {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
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();
|
let size = self.handle.size();
|
||||||
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
|
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
|
||||||
Size::abs(size)
|
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 {
|
fn is_size_independent(&self) -> bool {
|
||||||
true // a decoded image's primitive never depends on the region it is offered
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-16
@@ -1,18 +1,6 @@
|
|||||||
use crate::prelude::*;
|
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 {
|
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 shape: Option<StrongWidget>,
|
||||||
pub inner: StrongWidget,
|
pub inner: StrongWidget,
|
||||||
}
|
}
|
||||||
@@ -20,9 +8,6 @@ pub struct Masked {
|
|||||||
impl Widget for Masked {
|
impl Widget for Masked {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
match &self.shape {
|
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) => {
|
Some(shape) => {
|
||||||
painter.child_layer();
|
painter.child_layer();
|
||||||
painter.widget(shape);
|
painter.widget(shape);
|
||||||
@@ -31,6 +16,12 @@ impl Widget for Masked {
|
|||||||
}
|
}
|
||||||
None => painter.set_mask(painter.region()),
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,30 +7,18 @@ pub struct Aligned {
|
|||||||
|
|
||||||
impl Widget for Aligned {
|
impl Widget for Aligned {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
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 used = painter.widget(&self.inner);
|
||||||
let density = painter.density();
|
let density = painter.density();
|
||||||
let region = match self.align.tuple() {
|
let (x, y) = self.align.tuple();
|
||||||
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
let region = UiRegion::new(
|
||||||
(Some(x), None) => {
|
used.x
|
||||||
let x = used.x.apply_rest(density).align(x);
|
.apply_rest(density)
|
||||||
UiRegion::new(x, UiSpan::FULL)
|
.align(x.unwrap_or(AxisAlign::Neg)),
|
||||||
}
|
used.y
|
||||||
(None, Some(y)) => {
|
.apply_rest(density)
|
||||||
let y = used.y.apply_rest(density).align(y);
|
.align(y.unwrap_or(AxisAlign::Neg)),
|
||||||
UiRegion::new(UiSpan::FULL, y)
|
);
|
||||||
}
|
painter.place(&self.inner, region);
|
||||||
(None, None) => UiRegion::FULL,
|
|
||||||
};
|
|
||||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
|
||||||
used
|
used
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ impl Widget for LayerOffset {
|
|||||||
for _ in 0..self.offset {
|
for _ in 0..self.offset {
|
||||||
painter.next_layer();
|
painter.next_layer();
|
||||||
}
|
}
|
||||||
painter.widget(&self.inner)
|
let used = painter.widget(&self.inner);
|
||||||
|
painter.place_used(&self.inner, used, UiRegion::FULL);
|
||||||
|
used
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,169 +1,9 @@
|
|||||||
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
|
//! A virtualised, variable-height span laid out from an anchor.
|
||||||
//! an anchor rather than eagerly like `Span`.
|
|
||||||
//!
|
//!
|
||||||
//! **`docs/SCROLL.md` is the overview** -- how this widget and `ScrollArea`
|
//! It owns a `ScrollController`, draws only rows overlapping the viewport,
|
||||||
//! divide the work, the one sign convention, the `Widget` handoff, and
|
//! and leaves clipping to an optional `.masked()` wrapper. Cached row heights
|
||||||
//! what is still open. Read it first; this file is the detail.
|
//! let retained rows move directly; a new or resized row is drawn and then
|
||||||
//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out
|
//! placed at the exact extent it reports. See `docs/SCROLL.md`.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
use iris_core::util::HashMap;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -299,13 +139,7 @@ pub struct LazySpan {
|
|||||||
last_viewport_len: f32,
|
last_viewport_len: f32,
|
||||||
pending_tap: Option<f32>,
|
pending_tap: Option<f32>,
|
||||||
extents: HashMap<RowKey, RowExtent>,
|
extents: HashMap<RowKey, RowExtent>,
|
||||||
/// Each row's height as of its last real draw, kept across frames so
|
/// Last reported height, pruned when its row is evicted.
|
||||||
/// 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.
|
|
||||||
heights: HashMap<RowKey, f32>,
|
heights: HashMap<RowKey, f32>,
|
||||||
/// Whether the last walk found no more content before the leading
|
/// Whether the last walk found no more content before the leading
|
||||||
/// edge *and* nothing left to give back there -- what
|
/// edge *and* nothing left to give back there -- what
|
||||||
@@ -1003,62 +837,8 @@ impl LazySpan {
|
|||||||
region
|
region
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Place one slot (a real row or a sentinel) per `placement`, caching
|
/// Place a row and remember its height for later walks.
|
||||||
/// 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.
|
|
||||||
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
|
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!(
|
debug_assert!(
|
||||||
self.slot_exists(slot),
|
self.slot_exists(slot),
|
||||||
"place() called with a slot that doesn't exist: {slot:?}"
|
"place() called with a slot that doesn't exist: {slot:?}"
|
||||||
@@ -1077,16 +857,6 @@ impl LazySpan {
|
|||||||
let key = self.slot_key(slot);
|
let key = self.slot_key(slot);
|
||||||
let cached = key.and_then(|k| self.heights.get(&k).copied());
|
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 {
|
if let Some(h) = cached {
|
||||||
let (lead, trail) = placement.edges(h);
|
let (lead, trail) = placement.edges(h);
|
||||||
if !self.intersects_viewport(lead, trail) {
|
if !self.intersects_viewport(lead, trail) {
|
||||||
@@ -1096,56 +866,31 @@ impl LazySpan {
|
|||||||
|
|
||||||
let widget = self.slot_widget(slot);
|
let widget = self.slot_widget(slot);
|
||||||
let height = match cached {
|
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) => {
|
Some(h) => {
|
||||||
let (lead, trail) = placement.edges(h);
|
let (lead, trail) = placement.edges(h);
|
||||||
let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail));
|
let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail));
|
||||||
let height = resolve(used);
|
let height = resolve(used);
|
||||||
if height != h {
|
if height != h {
|
||||||
let (lead, trail) = placement.edges(height);
|
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
|
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 => {
|
None => {
|
||||||
let measure_from = match placement {
|
let measure_from = match placement {
|
||||||
Placement::Leading(lead) => lead,
|
Placement::Leading(lead) => lead,
|
||||||
Placement::Trailing(_) => 0.0,
|
Placement::Trailing(_) => 0.0,
|
||||||
};
|
};
|
||||||
let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING);
|
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);
|
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
|
height
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (lead, trail) = placement.edges(height);
|
let (lead, trail) = placement.edges(height);
|
||||||
if let Some(k) = key {
|
if let Some(k) = key {
|
||||||
self.heights.insert(k, height);
|
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) {
|
if self.intersects_viewport(lead, trail) {
|
||||||
self.extents.insert(k, RowExtent { slot, 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
|
/// Primary-axis room for a row whose extent is not cached yet.
|
||||||
/// 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.
|
|
||||||
const GENEROUS_PADDING: f32 = 100_000.0;
|
const GENEROUS_PADDING: f32 = 100_000.0;
|
||||||
|
|
||||||
impl LazySpan {
|
impl LazySpan {
|
||||||
@@ -1321,6 +1057,10 @@ impl Widget for LazySpan {
|
|||||||
self.ctl.set_travel(self.travel());
|
self.ctl.set_travel(self.travel());
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self, _axis: Axis) -> Option<Len> {
|
||||||
|
Some(Len::REST)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1719,7 +1459,7 @@ mod tests {
|
|||||||
fn a_fill_shaped_background_is_not_left_oversized() {
|
fn a_fill_shaped_background_is_not_left_oversized() {
|
||||||
// Regression test for a real bug found building the I3 example:
|
// Regression test for a real bug found building the I3 example:
|
||||||
// `place`'s Bottom-known branch used to measure a row at an
|
// `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
|
// translation) it into its final box. A row's own natural height
|
||||||
// is independent of that oversized offer (true for wrapped text),
|
// is independent of that oversized offer (true for wrapped text),
|
||||||
// but a `Rect` background is *defined* to fill whatever it is
|
// but a `Rect` background is *defined* to fill whatever it is
|
||||||
@@ -1751,7 +1491,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
(height - 20.0).abs() < 0.5,
|
(height - 20.0).abs() < 0.5,
|
||||||
"background rect should be exactly the row's height (20px), got {height}px \
|
"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
|
/// Replacing a streamed row must not grow the active widget set.
|
||||||
/// 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).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
|
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
@@ -2129,9 +1853,6 @@ mod tests {
|
|||||||
|
|
||||||
let before = render.active_widgets();
|
let before = render.active_widgets();
|
||||||
for i in 0..400u32 {
|
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);
|
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
|
||||||
rsc.ui
|
rsc.ui
|
||||||
.widgets
|
.widgets
|
||||||
@@ -2151,26 +1872,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
|
/// An ancestor redraw must retire every replaced primitive.
|
||||||
/// 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).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
|
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
|
|||||||
@@ -7,17 +7,12 @@ pub struct MaxSize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
|
||||||
let Some(max) = max else {
|
let Some(max) = max else {
|
||||||
return len;
|
return len;
|
||||||
};
|
};
|
||||||
let len_px = len.apply_rest(density).to_abs(output);
|
let len_px = len.apply_rest(density).to_abs(output);
|
||||||
let max_px = max.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 {
|
if len_px > max_px {
|
||||||
max.fold_dp(density)
|
max.fold_dp(density)
|
||||||
} else {
|
} 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 {
|
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
|
||||||
let Some(max) = max else {
|
let Some(max) = max else {
|
||||||
return UiSpan::FULL;
|
return UiSpan::FULL;
|
||||||
@@ -55,9 +44,11 @@ impl Widget for MaxSize {
|
|||||||
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||||
};
|
};
|
||||||
let used = painter.widget_within(&self.inner, region);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
Size {
|
let size = Size {
|
||||||
x: Self::clamp(used.x, self.x, output.x, density),
|
x: Self::clamp(used.x, self.x, output.x, density),
|
||||||
y: Self::clamp(used.y, self.y, output.y, density),
|
y: Self::clamp(used.y, self.y, output.y, density),
|
||||||
}
|
};
|
||||||
|
painter.place_used(&self.inner, size, UiRegion::FULL);
|
||||||
|
size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ pub struct Offset {
|
|||||||
impl Widget for Offset {
|
impl Widget for Offset {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let region = UiRegion::FULL.offset(self.amt);
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,29 +3,37 @@ use crate::prelude::*;
|
|||||||
pub struct Pad {
|
pub struct Pad {
|
||||||
pub padding: Padding,
|
pub padding: Padding,
|
||||||
pub inner: StrongWidget,
|
pub inner: StrongWidget,
|
||||||
|
/// Cleared after the reported size fits the offered region.
|
||||||
|
pub exact_region: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Pad {
|
impl Widget for Pad {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let density = painter.density();
|
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 =
|
let width =
|
||||||
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||||
let height =
|
let height =
|
||||||
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||||
Size {
|
let size = Size {
|
||||||
x: used.x + Len::abs(width),
|
x: used.x + Len::abs(width),
|
||||||
y: used.y + Len::abs(height),
|
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 struct Padding {
|
||||||
pub left: Len,
|
pub left: Len,
|
||||||
pub right: Len,
|
pub right: Len,
|
||||||
|
|||||||
@@ -7,34 +7,11 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// A scrolling view over a child that is a fixed lump: it is measured
|
/// A scrolling view that moves one fixed child as a subtree.
|
||||||
/// 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.
|
|
||||||
pub struct ScrollArea {
|
pub struct ScrollArea {
|
||||||
inner: StrongWidget,
|
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,
|
ctl: ScrollController,
|
||||||
container_len: f32,
|
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>,
|
content_len: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,73 +26,23 @@ impl Scrollable for ScrollArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget 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 {
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
self.tick_fling(now)
|
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 {
|
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 axis = self.ctl.axis();
|
||||||
let container_len = painter.px_size().axis(axis);
|
let container_len = painter.px_size().axis(axis);
|
||||||
self.container_len = container_len;
|
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());
|
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 delta = self.ctl.take_delta();
|
||||||
let travelled = self.ctl.amt() - delta;
|
let travelled = self.ctl.amt() - delta;
|
||||||
self.ctl.set_amt(travelled);
|
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);
|
let hint = self.content_len.unwrap_or(container_len);
|
||||||
// A **measurement**: this asks how long the content is, and the
|
let used = painter.widget_within(&self.inner, self.child_region(hint));
|
||||||
// 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));
|
|
||||||
|
|
||||||
// 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
|
let measured = used
|
||||||
.axis(axis)
|
.axis(axis)
|
||||||
.apply_rest(painter.density())
|
.apply_rest(painter.density())
|
||||||
@@ -123,18 +50,6 @@ impl Widget for ScrollArea {
|
|||||||
self.content_len = Some(measured);
|
self.content_len = Some(measured);
|
||||||
let range = (measured - container_len).max(0.0);
|
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 {
|
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
|
||||||
range
|
range
|
||||||
} else {
|
} else {
|
||||||
@@ -147,15 +62,7 @@ impl Widget for ScrollArea {
|
|||||||
fwd: range - amt,
|
fwd: range - amt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The **content's** size, not the container's. A parent that can
|
painter.place(&self.inner, self.child_region(measured))
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,12 +83,7 @@ impl ScrollArea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the child sits for a given content length: a box that long
|
/// A content-sized box offset by the current scroll amount.
|
||||||
/// 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.
|
|
||||||
fn child_region(&self, content_len: f32) -> UiRegion {
|
fn child_region(&self, content_len: f32) -> UiRegion {
|
||||||
let axis = self.ctl.axis();
|
let axis = self.ctl.axis();
|
||||||
let mut region = UiRegion::FULL;
|
let mut region = UiRegion::FULL;
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ pub struct Sized {
|
|||||||
|
|
||||||
impl Widget for Sized {
|
impl Widget for Sized {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
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 density = painter.density();
|
||||||
let mut region = UiRegion::FULL;
|
let mut region = UiRegion::FULL;
|
||||||
if let Some(x) = self.x {
|
if let Some(x) = self.x {
|
||||||
@@ -26,12 +17,18 @@ impl Widget for Sized {
|
|||||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||||
}
|
}
|
||||||
let used = painter.widget_within(&self.inner, region);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
// `fold_dp` on the way out: a declared size is a `Len` the caller
|
let size = Size {
|
||||||
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
|
|
||||||
// unresolved `dp` -- see `Len::fold_dp`.
|
|
||||||
Size {
|
|
||||||
x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
|
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),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,6 @@ use std::marker::PhantomData;
|
|||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub children: Vec<StrongWidget>,
|
pub children: Vec<StrongWidget>,
|
||||||
pub dir: Dir,
|
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,
|
pub gap: Len,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,38 +12,43 @@ impl Widget for Span {
|
|||||||
let axis = self.dir.axis;
|
let axis = self.dir.axis;
|
||||||
let gap = self.gap.apply_rest(painter.density()).abs;
|
let gap = self.gap.apply_rest(painter.density()).abs;
|
||||||
|
|
||||||
// Phase 1: ask each child how long it is along the layout axis,
|
let mut lens: Vec<Option<Len>> = self
|
||||||
// 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
|
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.map(|child| painter.widget(child).axis(axis))
|
.map(|child| painter.known_len(child, axis))
|
||||||
.collect();
|
.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 gap_total = gap * self.children.len().saturating_sub(1) as f32;
|
||||||
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
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 start = UiScalar::rel_min();
|
||||||
let mut ortho_len = Len::ZERO;
|
let mut ortho_len = Len::ZERO;
|
||||||
let mut ortho_mixed = false;
|
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;
|
let mut span = UiSpan::FULL;
|
||||||
span.start = start;
|
span.start = start;
|
||||||
if len.rest > 0.0 {
|
if len.rest > 0.0 {
|
||||||
@@ -64,7 +64,12 @@ impl Widget for Span {
|
|||||||
if self.dir.sign == Sign::Neg {
|
if self.dir.sign == Sign::Neg {
|
||||||
child_region.flip(axis);
|
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;
|
start.abs += gap;
|
||||||
|
|
||||||
let ortho = used.axis(!axis);
|
let ortho = used.axis(!axis);
|
||||||
@@ -76,6 +81,14 @@ impl Widget for Span {
|
|||||||
}
|
}
|
||||||
if ortho_mixed {
|
if ortho_mixed {
|
||||||
ortho_len = Len::default();
|
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 {
|
let along = if total.rest == 0.0 && total.rel == 0.0 {
|
||||||
|
|||||||
@@ -9,25 +9,54 @@ pub struct Stack {
|
|||||||
|
|
||||||
impl Widget for Stack {
|
impl Widget for Stack {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let mut picked = None;
|
let density = painter.density();
|
||||||
let mut iter = self.children.iter().enumerate();
|
let known = match self.size {
|
||||||
if let Some((i, child)) = iter.next() {
|
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();
|
painter.child_layer();
|
||||||
let used = painter.widget(child);
|
used.push(match region {
|
||||||
if matches!(self.size, StackSize::Child(j) if j == i) {
|
Some(region) => painter.widget_within(child, region),
|
||||||
picked = Some(used);
|
None => painter.widget(child),
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
for (i, child) in iter {
|
for child in iter {
|
||||||
painter.next_layer();
|
painter.next_layer();
|
||||||
let used = painter.widget(child);
|
used.push(match region {
|
||||||
if matches!(self.size, StackSize::Child(j) if j == i) {
|
Some(region) => painter.widget_within(child, region),
|
||||||
picked = Some(used);
|
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 {
|
match self.size {
|
||||||
StackSize::Default => Size::default(),
|
StackSize::Default => Some(Len::REST),
|
||||||
StackSize::Child(_) => picked.unwrap_or_default(),
|
StackSize::Child(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ pub struct WidgetPtr {
|
|||||||
impl Widget for WidgetPtr {
|
impl Widget for WidgetPtr {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
if let Some(id) = &self.inner {
|
if let Some(id) = &self.inner {
|
||||||
painter.widget(id)
|
let used = painter.widget(id);
|
||||||
|
painter.place_used(id, used, UiRegion::FULL);
|
||||||
|
used
|
||||||
} else {
|
} else {
|
||||||
Size::ZERO
|
Size::ZERO
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-34
@@ -3,12 +3,6 @@ use crate::prelude::*;
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub color: UiColor,
|
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 radius: Len,
|
||||||
pub thickness: f32,
|
pub thickness: f32,
|
||||||
pub inner_radius: f32,
|
pub inner_radius: f32,
|
||||||
@@ -37,40 +31,15 @@ impl Widget for Rect {
|
|||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.primitive(RectPrimitive {
|
painter.primitive(RectPrimitive {
|
||||||
color: self.color,
|
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,
|
radius: self.radius.fold_dp(painter.density()).abs,
|
||||||
thickness: self.thickness,
|
thickness: self.thickness,
|
||||||
inner_radius: self.inner_radius,
|
inner_radius: self.inner_radius,
|
||||||
});
|
});
|
||||||
Size::REST // fills whatever it was given -- used == available
|
Size::REST
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **No** -- despite drawing one primitive and nothing else.
|
fn size_hint(&self, _axis: Axis) -> Option<Len> {
|
||||||
///
|
Some(Len::REST)
|
||||||
/// `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(®ion)` 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ impl Widget for TextEdit {
|
|||||||
used
|
used
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_exact_region(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// I4 (RUST.md): the one override that exists so far -- everything
|
/// I4 (RUST.md): the one override that exists so far -- everything
|
||||||
/// else falls back to `Widget::access_role`'s default `Unknown`.
|
/// else falls back to `Widget::access_role`'s default `Unknown`.
|
||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ impl Widget for Text {
|
|||||||
self.update_buf();
|
self.update_buf();
|
||||||
self.view.draw(painter)
|
self.view.draw(painter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_exact_region(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for Text {
|
impl Deref for Text {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ widget_trait! {
|
|||||||
|state| Pad {
|
|state| Pad {
|
||||||
padding: padding.into(),
|
padding: padding.into(),
|
||||||
inner: self.add_strong(state),
|
inner: self.add_strong(state),
|
||||||
|
exact_region: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user