Move LazySpan rows through one retained offset

This commit is contained in:
iris committed 2026-09-09 16:15:09 -04:00
1 parent 0aa03cf621
commit fffed42f9e
13 files changed
+503 -104

No files matched your search

+9 -8
View File
@@ -446,16 +446,17 @@ Each exists because something was invisible without it.
are only reusable next frame and provisional layout nested -- 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 1.0% against a 0.9% floor, stream instances 71.9% 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 71.9%, against a 71.8% floor, and dirty The remaining layout cost was then removed at the framework boundary:
tracking is not the defect here.** The list is pinned to the newest end, so a growing reply moves `Painter::set_child_offset` gives a container one retained coordinate slot
every row, and a row's instances carry an absolute region. That is a for its child subtree, and `LazySpan` keeps row boxes stable behind it.
`move_offsets` write the layout is not making -- the next thing to look Pinned growth now uploads instances at **2.9% against a 2.9% floor**, from
at, and a layout question rather than an upload one. 71.9% against 71.8%; p50 instance upload is **1,728 bytes**, from 176,496.
`Primitives` also cancels dirty marks for provisional writes restored before
upload, so CPU-only layout states never become GPU work.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08; - **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at in software -- while its GLES *is* the host's real GPU through virgl at
-29
View File
@@ -12,35 +12,6 @@ and six phone-report sections went on 2026-09-08 for that reason.
## Fix ## Fix
- [ ] **A row moving because the list grew should be one `move_offsets`
write, and today it is a redraw.** Found 2026-09-09 by
`scripts/rigs/ui-profile`'s `arena_churn` and left for whoever picks
this up next; the upload half of it is done and this is the layout
half.
The measurement. Over the bench fixture's 401 streamed deltas, the
instance arena uploads **71.9%** of itself per frame against a
**71.8%** floor -- those entries genuinely differ, so no amount of
better dirty-tracking touches it. The control that says it is wrong is
the fling phase on the same screen and the same content: it moves the
same primitives every frame and uploads **3.3%**, because a scroll
reaches `UiRenderState::mov` and writes one `move_offsets` delta for
the subtree (LAYOUT.md section 2) instead of rewriting every
primitive's absolute region.
The list is pinned to the newest end, so a growing reply pushes every
earlier row up. `draw_inner` now treats sub-pixel size differences as a
move and the span refactor removed nearly all repeated draws, but the
instance floor remains 71.8%. The remaining question is why those
translations still rewrite primitive regions instead of stopping at
the rows' move slots.
Done looks like: `arena_churn`'s `what_a_streamed_reply_uploads` shows
stream instances in the same range as the fling's, and its `whole`
column stops being the interesting one. The rig prints floor,
uploaded and whole per array precisely so this is checkable rather
than argued.
- [ ] **Where the scroll *pin* lives.** The rest of "scrolling moves out - [ ] **Where the scroll *pin* lives.** The rest of "scrolling moves out
of the list" landed on 2026-09-08 -- `List` is `LazySpan`, the physics of the list" landed on 2026-09-08 -- `List` is `LazySpan`, the physics
and the gesture live in one `ScrollController`, `.scrollable()` is the and the gesture live in one `ScrollController`, `.scrollable()` is the
+15
View File
@@ -94,6 +94,15 @@ resolved in the vertex shader.**
- `mov(id, delta)` becomes: look up `id`'s slot, write - `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no `move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively. recursion, since descendants already reference this slot transitively.
- A container may also retain one optional **child-coordinate slot** between
its own slot and every direct child's slot. `Painter::set_child_offset`
creates that boundary before the first child is drawn and can update it
after measuring a child on later redraws. The container's own primitives,
hit region and mask stay fixed; its whole child subtree moves through one
write and every existing GPU, hit-test and accessibility chain sees the
same result. `LazySpan` uses this while still walking visible rows for
virtualisation: row boxes stay in stable local coordinates and the shared
boundary carries the changing screen translation.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in - `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a `move_idx → move_offsets[i].parent` for a bounded number of steps (a
@@ -120,6 +129,12 @@ for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's §3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices. shape doesn't change and one field write already suffices.
Provisional layout can still write an instance at an intermediate position
and restore it before upload. `Primitives::set_instance` remembers the value
at the first write in a frame and clears the dirty bit when the final bytes
match it. The GPU therefore observes final layout state, not CPU-only
measurement work.
### 2b. Two more readers of "where is this widget," and masks ### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no Moving the offset into the vertex shader means `ActiveData.region` is no
+14 -8
View File
@@ -670,8 +670,10 @@ The trap that only the rig could have caught: writing an entry is not the
same as changing it. Recycling rewrote every glyph of every moved row with same as changing it. Recycling rewrote every glyph of every moved row with
identical bytes, marking 73% of the glyph array against 0.6% genuinely identical bytes, marking 73% of the glyph array against 0.6% genuinely
changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
marking, and `arena_churn` prints both numbers so the gap cannot reopen marking. Layout can also write a provisional instance and restore it within
unnoticed. one frame; `Primitives` remembers the pre-frame bytes and cancels that dirty
bit when the GPU-visible result is unchanged. `arena_churn` prints both
numbers so either gap cannot reopen unnoticed.
**Layout has no measurement mode.** A widget is drawn provisionally only **Layout has no measurement mode.** A widget is drawn provisionally only
when its size cannot be known yet, and that retained drawing is moved into when its size cannot be known yet, and that retained drawing is moved into
@@ -685,12 +687,16 @@ Measured over the fixture's 401 streamed events: the busiest frame makes
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change. Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
Arena size and upload floors are unchanged. Arena size and upload floors are unchanged.
**What is left, and it is a layout question rather than an upload one.** Pinned growth now uses the same subtree translation as scrolling. A
Stream instances upload 71.9%, against a 71.8% floor: the list is pinned to container can retain a child-coordinate move slot through
the newest end, so a growing reply moves every row, and a row's instances `Painter::set_child_offset`; `LazySpan` keeps retained rows in stable local
carry an absolute region. Moving a subtree is supposed to be one boxes and changes that one slot when its anchor moves. It still walks the
`move_offsets` write (LAYOUT.md section 2); something on this path is visible run to virtualise it, but unchanged rows no longer acquire new
redrawing instead. absolute primitive regions. Over the fixture's 401 streamed events, instance
upload is **2.9% against a 2.9% floor**, from 71.9% against 71.8%; median
instance bytes per frame are **1,728**, from 176,496. This is framework
layout/rendering behaviour and the transcript screen contains no special
case for it.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09) ### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
+12 -9
View File
@@ -272,18 +272,21 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
## Measurements worth not re-taking ## Measurements worth not re-taking
- A settled scroll tick of a `LazySpan` with 31 rows on screen: - A settled scroll tick of a `LazySpan`, for 20, 200 or 2,000 total rows:
**1 real draw and 31 move-slot writes**, no primitive rewrites and no **1 real draw and 1 child-coordinate move-slot write**, no primitive
text reshaped. An idle frame is `(0, 0, 0, 0)``draw_inner` does not rewrites and no text reshaped. The visible-row walk remains: it is what
even enter the widget. This is the number any "store the edges and only admits and retires rows at the viewport boundary, and a newly admitted row
recompute what changed" optimisation would have to beat, and it is why has real initial-placement work of its own. An idle frame is `(0, 0, 0,
the walk was left alone. 0)` — `draw_inner` does not even enter the widget.
- A fully hinted `Span` draws each child once. Unknown fixed children draw - A fully hinted `Span` draws each child once. Unknown fixed children draw
provisionally and move; region-dependent children redraw if their final provisionally and move; region-dependent children redraw if their final
box has a different size. box has a different size.
- The one design that would collapse those 31 moves into a single delta - Moving the currently retained run as a unit does **not** require the lazy
write is moving the content as a unit, which needs a content length — span's unknowable total content length. Its anchor supplies the relation
which a lazy layout cannot supply. between stable local row boxes and their desired screen boxes; one retained
child-coordinate slot carries that translation. The offset is occasionally
rebased after 65,536 pixels to preserve `f32` precision, a rare O(visible)
move-slot pass rather than steady-state work.
## Tests that pin the behaviour ## Tests that pin the behaviour
+71 -2
View File
@@ -143,6 +143,13 @@ macro_rules! primitives {
/// compacted, so a `Mask` can hold a slot across frames. /// compacted, so a `Mask` can hold a slot across frames.
pub struct Primitives { pub struct Primitives {
instances: Vec<PrimitiveInstance>, instances: Vec<PrimitiveInstance>,
/// The value a slot held before its first rewrite since the last
/// upload. Layout may place a widget provisionally and restore it in
/// the same frame; remembering the pre-frame value lets `set_instance`
/// clear that dirty bit instead of uploading a change the GPU never
/// needs to observe. Entries are overwritten on the next clean-to-dirty
/// transition, so no separate end-of-frame sweep is needed.
original_instances: Vec<Option<PrimitiveInstance>>,
assoc: Vec<WidgetId>, assoc: Vec<WidgetId>,
/// Where each slot's [`PrimitiveHandle`] sits in its owner's /// Where each slot's [`PrimitiveHandle`] sits in its owner's
/// `ActiveData::primitives` -- the index that makes /// `ActiveData::primitives` -- the index that makes
@@ -179,6 +186,7 @@ impl Default for Primitives {
fn default() -> Self { fn default() -> Self {
Self { Self {
instances: Default::default(), instances: Default::default(),
original_instances: Default::default(),
assoc: Default::default(), assoc: Default::default(),
handle_idx: Default::default(), handle_idx: Default::default(),
freed: Vec::new(), freed: Vec::new(),
@@ -251,11 +259,13 @@ impl Primitives {
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 { fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
let slot = if let Some(i) = self.reusable.pop() { let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst; self.instances[i] = inst;
self.original_instances[i] = None;
self.assoc[i] = id; self.assoc[i] = id;
self.handle_idx[i] = Self::NO_HANDLE; self.handle_idx[i] = Self::NO_HANDLE;
i i
} else { } else {
self.instances.push(inst); self.instances.push(inst);
self.original_instances.push(None);
self.assoc.push(id); self.assoc.push(id);
self.handle_idx.push(Self::NO_HANDLE); self.handle_idx.push(Self::NO_HANDLE);
self.instances.len() - 1 self.instances.len() - 1
@@ -342,8 +352,18 @@ impl Primitives {
if bytemuck::bytes_of(&self.instances[slot]) == bytemuck::bytes_of(&inst) { if bytemuck::bytes_of(&self.instances[slot]) == bytemuck::bytes_of(&inst) {
return; return;
} }
if !self.dirty.contains(slot) {
self.original_instances[slot] = Some(self.instances[slot]);
}
self.instances[slot] = inst; self.instances[slot] = inst;
self.dirty.mark(slot); if self.original_instances[slot]
.is_some_and(|original| bytemuck::bytes_of(&original) == bytemuck::bytes_of(&inst))
{
self.original_instances[slot] = None;
self.dirty.unmark(slot);
} else {
self.dirty.mark(slot);
}
} }
/// Retires a slot, answering the mask it was drawn under so the caller /// Retires a slot, answering the mask it was drawn under so the caller
@@ -392,6 +412,7 @@ impl Primitives {
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.dirty.mark_all(); self.dirty.mark_all();
self.instances.clear(); self.instances.clear();
self.original_instances.clear();
self.assoc.clear(); self.assoc.clear();
self.handle_idx.clear(); self.handle_idx.clear();
self.freed.clear(); self.freed.clear();
@@ -464,7 +485,13 @@ impl Primitives {
} }
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion { pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.dirty.mark(h.slot as usize); let slot = h.slot as usize;
// The caller receives unrestricted mutable access, so this path
// cannot tell whether a later write restored the prior bytes. Leave
// the entry dirty rather than letting a stale `set_instance` baseline
// cancel it.
self.original_instances[slot] = None;
self.dirty.mark(slot);
&mut self.instances[h.slot as usize].region &mut self.instances[h.slot as usize].region
} }
} }
@@ -785,3 +812,45 @@ impl<T> Deref for PrimitiveVec<T> {
&self.vec &self.vec
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{UiVec2, Widgets, util::Id};
#[test]
fn an_instance_restored_before_upload_is_clean() {
let mut primitives = Primitives::default();
let mut widgets = Widgets::new();
let widget = widgets.add_strong(());
let owner = widget.id();
let original = PrimitiveInstance {
region: UiRegion::FULL,
binding: RectPrimitive::BINDING,
idx: 0,
mask_idx: MaskIdx::NONE,
move_idx: Id::preset(0),
};
primitives.push(original, owner);
primitives.dirty.clear();
let mut moved = original;
moved.region = moved.region.offset(UiVec2::abs((0.0, 20.0)));
primitives.set_instance(0, moved, owner);
assert!(!primitives.dirty.is_clean());
primitives.set_instance(0, original, owner);
assert!(
primitives.dirty.is_clean(),
"the GPU never observes the provisional position"
);
// A subsequent frame takes its baseline from the value currently in
// the arena, rather than reusing the now-stale original above.
primitives.set_instance(0, moved, owner);
assert!(!primitives.dirty.is_clean());
primitives.dirty.clear();
primitives.set_instance(0, original, owner);
assert!(!primitives.dirty.is_clean());
}
}
+4
View File
@@ -20,6 +20,10 @@ pub struct ActiveData {
pub size: Size, pub size: Size,
/// Retained so descendants' parent links stay valid across redraws. /// Retained so descendants' parent links stay valid across redraws.
pub move_slot: MoveIdx, pub move_slot: MoveIdx,
/// The optional coordinate boundary between this widget and its direct
/// children. Descendants retain links to it across redraws, just as they
/// do to `move_slot`.
pub child_move_slot: Option<MoveIdx>,
/// The part of this widget's move delta already folded into `region`. /// The part of this widget's move delta already folded into `region`.
pub move_applied: Vec2, pub move_applied: Vec2,
} }
+6 -5
View File
@@ -18,11 +18,12 @@ pub struct UiData {
pub textures: Textures, pub textures: Textures,
pub text: TextData, pub text: TextData,
pub masks: TrackedArena<Mask, u32>, pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, forming the parent-linked chain /// One entry per widget ever drawn, plus optional child-coordinate
/// `resolve_move` walks in both shader stages. Allocated once on a /// boundaries owned by containers. Together they form the parent-linked
/// widget's first draw and reused for every later redraw of the same /// chain `resolve_move` walks in both shader stages. A widget's ordinary
/// id (never reallocated), so a retained descendant's `parent` index /// entry is allocated once on its first draw and reused for every later
/// never goes stale -- see LAYOUT.md section 2. /// redraw of the same id, so a retained descendant's `parent` index never
/// goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>, pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the /// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `LazySpan` coasting through a fling. Added by /// next frame -- today, a `LazySpan` coasting through a fling. Added by
+49 -4
View File
@@ -1,6 +1,7 @@
use crate::{ use crate::{
Axis, Color, Len, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, Axis, Color, Len, MoveOffset, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs,
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, TextBuffer, 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,
@@ -16,6 +17,7 @@ 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,
pub(super) child_move_slot: Option<MoveIdx>,
/// This widget's retained mask slot. /// This widget's retained mask slot.
pub(super) own_mask: MaskIdx, pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
@@ -217,6 +219,47 @@ impl<'a> Painter<'a> {
self.widget_at(id, region.within(&self.region)) self.widget_at(id, region.within(&self.region))
} }
/// Translate this widget's children as one retained subtree, in output
/// pixels. The first call must happen before drawing a child, because the
/// slot becomes the parent of every direct child's ordinary move slot.
/// Once retained, it may be updated later in a redraw (for example after
/// measuring a changed child). All deeper descendants inherit it and the
/// CPU hit-test walk resolves the same translation as the shader.
///
/// This offsets the child coordinate space, not this widget: its own
/// primitives and hit region remain fixed. Once allocated, the boundary
/// stays in the chain across redraws; set it to zero to return children to
/// their unshifted positions.
pub fn set_child_offset(&mut self, offset: Vec2) {
let slot = match self.child_move_slot {
Some(slot) => slot,
None => {
assert!(
self.children.is_empty(),
"a child offset must be created before drawing a child"
);
let parent = self.move_slot.idx() as u32;
let slot = self
.rsc
.ui_mut()
.move_offsets
.push(MoveOffset::new([offset.x, offset.y], parent));
// One ref for this widget's ownership and one on the
// up-link. Direct children take their own refs when their
// move slots are allocated.
self.rsc.ui_mut().move_offsets.push_ref(slot);
self.rsc.ui_mut().move_offsets.push_ref(self.move_slot);
self.child_move_slot = Some(slot);
return;
}
};
let next = [offset.x, offset.y];
if self.rsc.ui().move_offsets[slot.idx()].delta != next {
self.rsc.ui_mut().move_offsets.get_mut(slot).delta = next;
self.state.note_move();
}
}
pub fn known_len<W: ?Sized>(&self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> { 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) { if let Some(len) = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis) {
return Some(len.fold_dp(self.density())); return Some(len.fold_dp(self.density()));
@@ -236,12 +279,13 @@ impl<'a> Painter<'a> {
// call -- would always find nothing. `self.move_slot` is this // call -- would always find nothing. `self.move_slot` is this
// widget's own slot, already known, and always correct regardless // widget's own slot, already known, and always correct regardless
// of insertion order. See `UiRenderState::move_parent_of`. // of insertion order. See `UiRenderState::move_parent_of`.
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
self.state.draw_inner( self.state.draw_inner(
self.layer, self.layer,
id.id(), id.id(),
region, region,
Some(self.id), Some(self.id),
self.move_slot.idx() as u32, parent_move_slot.idx() as u32,
self.mask, self.mask,
Retained::default(), Retained::default(),
self.rsc, self.rsc,
@@ -261,12 +305,13 @@ impl<'a> Painter<'a> {
} else if let Some((layer, mask)) = retained { } else if let Some((layer, mask)) = retained {
self.children.push(id.id()); self.children.push(id.id());
self.rsc.widgets_mut().needs_redraw.insert(id.id()); self.rsc.widgets_mut().needs_redraw.insert(id.id());
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
self.state.draw_inner( self.state.draw_inner(
layer, layer,
id.id(), id.id(),
region, region,
Some(self.id), Some(self.id),
self.move_slot.idx() as u32, parent_move_slot.idx() as u32,
mask, mask,
Retained::default(), Retained::default(),
self.rsc, self.rsc,
+25
View File
@@ -99,6 +99,7 @@ pub(crate) struct Retained {
pub region: Option<UiRegion>, pub region: Option<UiRegion>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
pub move_slot: Option<MoveIdx>, pub move_slot: Option<MoveIdx>,
pub child_move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx, pub own_mask: MaskIdx,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
} }
@@ -109,6 +110,7 @@ impl Default for Retained {
region: None, region: None,
children: Vec::new(), children: Vec::new(),
move_slot: None, move_slot: None,
child_move_slot: None,
own_mask: MaskIdx::NONE, own_mask: MaskIdx::NONE,
primitives: Vec::new(), primitives: Vec::new(),
} }
@@ -173,6 +175,10 @@ impl UiRenderState {
) )
} }
pub(super) fn note_move(&mut self) {
self.mov_count += 1;
}
/// Writes a primitive into the arena and, unless it is /// Writes a primitive into the arena and, unless it is
/// [`Drawn::No`], into `layer`'s draw order. /// [`Drawn::No`], into `layer`'s draw order.
pub(super) fn write_primitive<P: Primitive>( pub(super) fn write_primitive<P: Primitive>(
@@ -446,6 +452,7 @@ impl UiRenderState {
region: mut old_region, 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 child_move_slot,
mut own_mask, mut own_mask,
primitives: mut recycle, primitives: mut recycle,
} = retained; } = retained;
@@ -498,6 +505,7 @@ impl UiRenderState {
old_region = Some(active.region); 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);
child_move_slot = active.child_move_slot;
own_mask = active.own_mask; own_mask = active.own_mask;
recycle = active.primitives; recycle = active.primitives;
} else if self.active.contains_key(&id) { } else if self.active.contains_key(&id) {
@@ -512,6 +520,7 @@ impl UiRenderState {
old_region = Some(active.region); 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);
child_move_slot = active.child_move_slot;
own_mask = active.own_mask; own_mask = active.own_mask;
recycle = active.primitives; recycle = active.primitives;
} }
@@ -533,6 +542,7 @@ impl UiRenderState {
region, region,
mask, mask,
move_slot, move_slot,
child_move_slot,
own_mask, own_mask,
layer, layer,
id, id,
@@ -577,6 +587,7 @@ impl UiRenderState {
region, region,
mask: _, mask: _,
move_slot, move_slot,
child_move_slot,
own_mask, own_mask,
textures, textures,
primitives, primitives,
@@ -608,6 +619,7 @@ impl UiRenderState {
layer, layer,
size, size,
move_slot, move_slot,
child_move_slot,
own_mask, own_mask,
move_applied: Vec2::ZERO, move_applied: Vec2::ZERO,
}; };
@@ -804,6 +816,18 @@ impl UiRenderState {
rsc.ui_mut().masks.remove(outer); rsc.ui_mut().masks.remove(outer);
} }
} }
// The self-ownership ref held by this widget's optional
// child-coordinate slot, plus that slot's link to this
// widget's ordinary move slot. Direct children may still
// hold the slot alive until `remove_rec` reaches them, the
// same lifetime shape as `move_slot` immediately below.
if let Some(slot) = active.child_move_slot {
let parent_slot = rsc.ui().move_offsets[slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(slot);
if parent_slot != MoveOffset::NONE_PARENT {
rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot));
}
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent; let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot); rsc.ui_mut().move_offsets.remove(active.move_slot);
if parent_slot != MoveOffset::NONE_PARENT { if parent_slot != MoveOffset::NONE_PARENT {
@@ -1213,6 +1237,7 @@ impl UiRenderState {
region: Some(active.region), region: Some(active.region),
children: active.children, children: active.children,
move_slot: Some(active.move_slot), move_slot: Some(active.move_slot),
child_move_slot: active.child_move_slot,
own_mask: active.own_mask, own_mask: active.own_mask,
primitives: active.primitives, primitives: active.primitives,
}, },
+31
View File
@@ -45,6 +45,26 @@ impl Dirty {
self.words[word] |= 1 << (i % 64); self.words[word] |= 1 << (i % 64);
} }
pub fn contains(&self, i: usize) -> bool {
self.all
|| self
.words
.get(i / 64)
.is_some_and(|word| word & (1 << (i % 64)) != 0)
}
/// Clear one entry that was restored to the value already on the GPU.
/// `all` has no per-entry representation and is used only when every
/// byte must be uploaded regardless of later writes, so it stays set.
pub fn unmark(&mut self, i: usize) {
if self.all {
return;
}
if let Some(word) = self.words.get_mut(i / 64) {
*word &= !(1 << (i % 64));
}
}
/// Everything must be written: the buffer was reallocated (its /// Everything must be written: the buffer was reallocated (its
/// contents are undefined), or the array was cleared. /// contents are undefined), or the array was cleared.
pub fn mark_all(&mut self) { pub fn mark_all(&mut self) {
@@ -114,6 +134,17 @@ mod tests {
assert_eq!(marked(&[3, 4, 5], 64, 0), vec![3..6]); assert_eq!(marked(&[3, 4, 5], 64, 0), vec![3..6]);
} }
#[test]
fn a_restored_entry_can_be_unmarked() {
let mut dirty = Dirty::default();
dirty.mark(3);
dirty.mark(5);
assert!(dirty.contains(3));
dirty.unmark(3);
assert!(!dirty.contains(3));
assert_eq!(dirty.ranges(8, 0), vec![5..6]);
}
#[test] #[test]
fn a_run_that_crosses_a_word_boundary_is_one_range() { fn a_run_that_crosses_a_word_boundary_is_one_range() {
assert_eq!(marked(&[62, 63, 64, 65], 128, 0), vec![62..66]); assert_eq!(marked(&[62, 63, 64, 65], 128, 0), vec![62..66]);
+61
View File
@@ -37,6 +37,67 @@ impl Widget for FixedRect {
} }
} }
struct ChildOffset {
child: StrongWidget,
offset: Vec2,
}
impl Widget for ChildOffset {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.set_child_offset(self.offset);
painter.widget(&self.child)
}
}
#[test]
fn a_child_coordinate_offset_moves_only_the_child_subtree() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let child = rsc.ui.widgets.add_strong(FixedRect(40.0));
let child_weak = child.weak();
let parent = rsc.ui.widgets.add_strong(ChildOffset {
child: child.any(),
offset: vec2(0.0, 15.0),
});
let parent_weak = parent.weak();
// Keep the coordinate-owning widget below the root: a nested widget is
// normally redrawn when its parent visits it, which takes a different
// retained-state path from `UiRenderState::redraw` on the root itself.
let outer = rsc.ui.widgets.add_strong(Sized {
inner: parent.any(),
x: None,
y: None,
});
let outer_weak = outer.weak();
let root = outer.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.take_counters();
let parent_before = render.window_region(&parent_weak, &rsc).unwrap();
let child_before = render.window_region(&child_weak, &rsc).unwrap();
assert!((parent_before.top_left.y - 0.0).abs() < 0.01);
assert!((child_before.top_left.y - 15.0).abs() < 0.01);
rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0;
// Force the ordinary ancestor-redraw path rather than letting the
// renderer visit only the dirty descendant directly.
rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None;
render.update(&root, &mut rsc);
let (draws, rewrites, moves, _shapes) = render.take_counters();
let parent_after = render.window_region(&parent_weak, &rsc).unwrap();
let child_after = render.window_region(&child_weak, &rsc).unwrap();
assert_eq!((draws, rewrites, moves), (2, 0, 1));
assert_eq!(
parent_after, parent_before,
"the container itself stays put"
);
assert!((child_after.top_left.y - 35.0).abs() < 0.01);
}
#[test] #[test]
fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() { fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
+206 -39
View File
@@ -141,6 +141,11 @@ pub struct LazySpan {
extents: HashMap<RowKey, RowExtent>, extents: HashMap<RowKey, RowExtent>,
/// Last reported height, pruned when its row is evicted. /// Last reported height, pruned when its row is evicted.
heights: HashMap<RowKey, f32>, heights: HashMap<RowKey, f32>,
/// Direction-relative translation applied to every row through one
/// retained child-coordinate slot. Row regions subtract this value, so
/// changing it moves the visible run without changing any retained row's
/// own geometry.
content_offset: 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
/// [`Self::overscroll_gap`] reads. `false` by default, matching /// [`Self::overscroll_gap`] reads. `false` by default, matching
@@ -190,6 +195,7 @@ impl LazySpan {
pending_tap: None, pending_tap: None,
extents: HashMap::default(), extents: HashMap::default(),
heights: HashMap::default(), heights: HashMap::default(),
content_offset: 0.0,
} }
} }
@@ -309,6 +315,7 @@ impl LazySpan {
self.ctl.set_pinned_to_end(true); self.ctl.set_pinned_to_end(true);
self.heights.clear(); self.heights.clear();
self.extents.clear(); self.extents.clear();
self.content_offset = 0.0;
} }
/// Move the anchor's edge by `amt` pixels, where positive brings /// Move the anchor's edge by `amt` pixels, where positive brings
@@ -725,7 +732,7 @@ impl LazySpan {
/// Called a second time in the same `draw` when the first pass lands /// Called a second time in the same `draw` when the first pass lands
/// off the end of the content -- see [`Self::overscroll_gap`] and /// off the end of the content -- see [`Self::overscroll_gap`] and
/// `draw`. /// `draw`.
fn lay_out(&mut self, painter: &mut Painter) -> (f32, f32) { fn lay_out(&mut self, painter: &mut Painter, stable_anchor_lead: Option<f32>) -> (f32, f32) {
let anchor = self let anchor = self
.anchor .anchor
.expect("lay_out with no anchor: `draw` returns before this without one"); .expect("lay_out with no anchor: `draw` returns before this without one");
@@ -733,14 +740,15 @@ impl LazySpan {
Edge::Leading => Placement::Leading(anchor.offset), Edge::Leading => Placement::Leading(anchor.offset),
Edge::Trailing => Placement::Trailing(anchor.offset), Edge::Trailing => Placement::Trailing(anchor.offset),
}; };
let (mut lead, mut trail) = self.place(painter, anchor.slot, placement); let (mut lead, mut trail) =
self.place(painter, anchor.slot, placement, stable_anchor_lead, true);
let mut idx_lead = anchor.slot; let mut idx_lead = anchor.slot;
while lead > 0.0 { while lead > 0.0 {
let Some(prev) = self.prev_slot(idx_lead) else { let Some(prev) = self.prev_slot(idx_lead) else {
break; break;
}; };
let (l, _) = self.place(painter, prev, Placement::Trailing(lead)); let (l, _) = self.place(painter, prev, Placement::Trailing(lead), None, false);
lead = l; lead = l;
idx_lead = prev; idx_lead = prev;
} }
@@ -750,7 +758,7 @@ impl LazySpan {
let Some(next) = self.next_slot(idx_trail) else { let Some(next) = self.next_slot(idx_trail) else {
break; break;
}; };
let (_, t) = self.place(painter, next, Placement::Leading(trail)); let (_, t) = self.place(painter, next, Placement::Leading(trail), None, false);
trail = t; trail = t;
idx_trail = next; idx_trail = next;
} }
@@ -837,8 +845,48 @@ impl LazySpan {
region region
} }
/// A row's stable local box. `lead`/`trail` are where it belongs on
/// screen; the child-coordinate move slot adds `content_offset` back in
/// the shader and in `resolved_region`.
fn row_region(&self, lead: f32, trail: f32) -> UiRegion {
Self::abs_region(
self.dir,
lead - self.content_offset,
trail - self.content_offset,
)
}
fn apply_content_offset(&self, painter: &mut Painter) {
let screen_offset = match self.dir.sign {
Sign::Pos => self.content_offset,
Sign::Neg => -self.content_offset,
};
painter.set_child_offset(Vec2::from_axis(self.dir.axis, screen_offset, 0.0));
}
fn rebase_content_offset(&mut self) {
if self.content_offset.abs() >= CONTENT_OFFSET_REBASE {
self.content_offset = 0.0;
}
}
/// Keep an anchor row's local leading edge unchanged while its desired
/// on-screen leading edge moves. Scrolling and end-pinned growth can then
/// move the visible run through one child-coordinate write.
fn stabilize_lead(&mut self, painter: &mut Painter, from: f32, to: f32) {
self.content_offset += to - from;
self.apply_content_offset(painter);
}
/// Place a row and remember its height for later walks. /// Place a row and remember its height for later walks.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { fn place(
&mut self,
painter: &mut Painter,
slot: isize,
placement: Placement,
stable_lead: Option<f32>,
is_anchor: bool,
) -> (f32, f32) {
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:?}"
@@ -856,37 +904,69 @@ 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 dirty descendant is redrawn before its changed size bubbles up
// to this list. By the time the anchored row is placed, its retained
// draw can therefore already report the new height without another
// provisional row draw. This is deliberately anchor-only: the walk
// has no old on-screen reference with which to stabilize another
// row's coordinate space.
let known_anchor_height = (is_anchor && stable_lead.is_some()).then(|| {
painter.known_len(self.slot_widget(slot), axis).map(|len| {
len.apply_rest(density)
.within_len(container_len)
.to_abs(output_len)
})
});
let known_anchor_height = known_anchor_height.flatten();
if let Some(h) = cached { if let Some(h) = cached {
let (lead, trail) = placement.edges(h); let (lead, trail) = placement.edges(known_anchor_height.unwrap_or(h));
if let Some(old_lead) = stable_lead {
self.stabilize_lead(painter, old_lead, lead);
}
if !self.intersects_viewport(lead, trail) { if !self.intersects_viewport(lead, trail) {
return (lead, trail); return (lead, trail);
} }
} }
let widget = self.slot_widget(slot); let height = match (cached, known_anchor_height) {
let height = match cached { (Some(_), Some(height)) => {
Some(h) => { let (lead, trail) = placement.edges(height);
let region = self.row_region(lead, trail);
painter.widget_within(self.slot_widget(slot), region);
height
}
(Some(h), None) => {
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 region = self.row_region(lead, trail);
let used = painter.widget_within(self.slot_widget(slot), region);
let height = resolve(used); let height = resolve(used);
if height != h { if height != h {
let (lead, trail) = placement.edges(height); let (new_lead, new_trail) = placement.edges(height);
painter.place(widget, Self::abs_region(dir, lead, trail)); if stable_lead.is_some() {
self.stabilize_lead(painter, lead, new_lead);
}
let region = self.row_region(new_lead, new_trail);
painter.place(self.slot_widget(slot), region);
} }
height height
} }
None => { (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.row_region(measure_from, measure_from + GENEROUS_PADDING);
let height = resolve(painter.widget_within(widget, first)); let height = resolve(painter.widget_within(self.slot_widget(slot), first));
let (lead, trail) = placement.edges(height); let (lead, trail) = placement.edges(height);
painter.place(widget, Self::abs_region(dir, lead, trail)); if is_anchor {
self.stabilize_lead(painter, measure_from, lead);
}
let region = self.row_region(lead, trail);
painter.place(self.slot_widget(slot), region);
height height
} }
(None, Some(_)) => unreachable!("an uncached row has no retained height"),
}; };
let (lead, trail) = placement.edges(height); let (lead, trail) = placement.edges(height);
if let Some(k) = key { if let Some(k) = key {
@@ -902,6 +982,11 @@ impl LazySpan {
/// Primary-axis room for a row whose extent is not cached yet. /// Primary-axis room for a row whose extent is not cached yet.
const GENEROUS_PADDING: f32 = 100_000.0; const GENEROUS_PADDING: f32 = 100_000.0;
/// Keep stable local row coordinates precise over an indefinitely long
/// scroll. Crossing this costs one rare O(visible) rebase of row move slots;
/// ordinary ticks remain one child-coordinate write.
const CONTENT_OFFSET_REBASE: f32 = 65_536.0;
impl LazySpan { impl LazySpan {
/// Make this span scrollable: the wheel and a finger drag, registered /// Make this span scrollable: the wheel and a finger drag, registered
/// on the span itself. /// on the span itself.
@@ -1022,9 +1107,17 @@ impl Widget for LazySpan {
if let Some(tap) = self.pending_tap.take() { if let Some(tap) = self.pending_tap.take() {
self.reanchor_at_tap(tap); self.reanchor_at_tap(tap);
} }
self.rebase_content_offset();
let stable_anchor_lead = self.anchor.and_then(|anchor| {
self.extents
.values()
.find(|extent| extent.slot == anchor.slot)
.map(|extent| extent.lead)
});
self.extents.clear(); self.extents.clear();
self.apply_content_offset(painter);
let (lead, trail) = self.lay_out(painter); let (lead, trail) = self.lay_out(painter, stable_anchor_lead);
// **The clamp is applied inside the frame that found it**, not // **The clamp is applied inside the frame that found it**, not
// marked for the next one: layout is a pure function of the state // marked for the next one: layout is a pure function of the state
@@ -1047,9 +1140,15 @@ impl Widget for LazySpan {
// box at a new offset, which `draw_inner` dispatches as an O(1) // box at a new offset, which `draw_inner` dispatches as an O(1)
// move. // move.
if let Some(gap) = self.overscroll_gap(lead, trail) { if let Some(gap) = self.overscroll_gap(lead, trail) {
let stable_anchor_lead = self.anchor.and_then(|anchor| {
self.extents
.values()
.find(|extent| extent.slot == anchor.slot)
.map(|extent| extent.lead)
});
self.move_anchor(gap); self.move_anchor(gap);
self.extents.clear(); self.extents.clear();
self.lay_out(painter); self.lay_out(painter, stable_anchor_lead);
} }
self.rehome_anchor(); self.rehome_anchor();
@@ -1200,13 +1299,12 @@ mod tests {
/// is `dir` -- and the newest row moves from the bottom of the screen /// is `dir` -- and the newest row moves from the bottom of the screen
/// to the top. /// to the top.
/// ///
/// Asserts on **where each row was actually drawn** /// Asserts on each row's **resolved window region**, not on `extents`:
/// (`UiRenderState::active`), not on `extents`: those are kept in the /// those are kept in the walk's own direction-relative space and
/// walk's own direction-relative space and converted on the way out, /// converted on the way out, so an `extent()`-only test passes even with
/// so an `extent()`-only test passes even with the flip in /// the flip in `abs_region` deleted. The resolved region also includes
/// `abs_region` deleted -- it would be checking the bookkeeping /// the child-coordinate offset that now keeps retained row geometry
/// against itself while every row painted at the mirror of where it /// stable.
/// belongs.
#[test] #[test]
fn a_dir_up_span_grows_upward_from_item_zero() { fn a_dir_up_span_grows_upward_from_item_zero() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
@@ -1221,7 +1319,9 @@ mod tests {
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let drawn = |render: &UiRenderState, row: &WeakWidget<Sized>| { let drawn = |render: &UiRenderState, row: &WeakWidget<Sized>| {
let px = render.active[&row.id()].region.to_px((100.0, 100.0).into()); let px = render
.window_region(row, &rsc)
.expect("every short-list row is drawn");
(px.top_left.y, px.bot_right.y) (px.top_left.y, px.bot_right.y)
}; };
assert_eq!( assert_eq!(
@@ -1297,16 +1397,16 @@ mod tests {
// Row 4 is on screen in both spans now, and stays drawn // Row 4 is on screen in both spans now, and stays drawn
// across a move this small whichever way it goes. // across a move this small whichever way it goes.
let top = |render: &UiRenderState| { let top = |render: &UiRenderState, rsc: &TestRsc| {
render.active[&rows[4].id()] render
.region .window_region(&rows[4], rsc)
.to_px((100.0, 100.0).into()) .expect("row 4 is still drawn")
.top_left .top_left
.y .y
}; };
let before = top(&render); let before = top(&render, &rsc);
push(&mut rsc, &mut render, -10.0); push(&mut rsc, &mut render, -10.0);
top(&render) - before top(&render, &rsc) - before
}; };
for dir in [Dir::DOWN, Dir::UP] { for dir in [Dir::DOWN, Dir::UP] {
@@ -1445,7 +1545,14 @@ mod tests {
rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height)); rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let px = render.active[&bg_id].region.to_px((100.0, 100.0).into()); let px = render
.active
.get(&bg_id)
.unwrap_or_else(|| {
panic!("row {key_to_change} resized to {new_height}px lost its background")
})
.region
.to_px((100.0, 100.0).into());
let drawn = px.size().y; let drawn = px.size().y;
assert!( assert!(
(drawn - new_height).abs() < 0.5, (drawn - new_height).abs() < 0.5,
@@ -1664,19 +1771,79 @@ mod tests {
// overscroll, and the clamp lays out a second time within the // overscroll, and the clamp lays out a second time within the
// frame to give it back -- a correct extra pass, but not the // frame to give it back -- a correct extra pass, but not the
// ordinary scroll tick whose cost this test is about. // ordinary scroll tick whose cost this test is about.
// Admit any row touching the viewport's leading edge first; its
// initial placement is real virtualization work, not movement of
// the already-retained run.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
render.take_counters();
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters(); let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The visible window is a fixed ~10 rows regardless of n; an // The visible window is a fixed ~10 rows regardless of n. The
// O(n) regression would show up as draws/moves scaling with // row walk still runs so virtualization can admit and retire
// list size instead of staying flat. // rows, but every retained row shares the list's one child
assert!(draws <= 12, "n={n}: expected O(visible), got {draws} draws"); // coordinate move.
assert!(moves >= 1, "n={n}: a scroll tick should move something"); assert_eq!(draws, 1, "n={n}: only the list should really draw");
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}"); assert_eq!(moves, 1, "n={n}: the whole retained run should move once");
} }
} }
#[test]
fn a_large_accumulated_offset_rebases_without_moving_the_content() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let keys: Vec<RowKey> = (0..100).collect();
let rows = push_rows(&mut rsc, &mut list, &keys, 1_000.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 200.0));
render.update(&root, &mut rsc);
// Accumulate enough shared translation to cross the precision
// threshold while staying away from either end of the content.
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.scroll(CONTENT_OFFSET_REBASE + 1.0);
render.update(&root, &mut rsc);
let anchor_key = {
let list = rsc.ui.widgets.get(&list_weak).unwrap();
let anchor = list.anchor.expect("the populated list has an anchor");
list.slot_key(anchor.slot)
.expect("the anchor is a real row away from the sentinels")
};
let anchor_row = &rows[anchor_key as usize];
let before = render
.window_region(anchor_row, &rsc)
.expect("the rehomed anchor is retained")
.top_left
.y;
// This draw first rebases the local coordinate space, then applies
// the requested pixel of movement. Neither operation may jump the
// rendered row.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0);
render.update(&root, &mut rsc);
let after = render
.window_region(anchor_row, &rsc)
.expect("the anchor remains retained after the rebase")
.top_left
.y;
assert!(
(after - before - 1.0).abs() < 0.5,
"the rebase must be invisible; expected +1px, got {}px",
after - before,
);
}
/// The streamed-reply case (RUST.md's "streaming still costs a full /// The streamed-reply case (RUST.md's "streaming still costs a full
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta /// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
/// swaps the last row's widget for a taller one, same key, same slot. /// swaps the last row's widget for a taller one, same key, same slot.