Move LazySpan rows through one retained offset

This commit is contained in:
iris committed 2026-09-09 16:15:09 -04:00
1 parent 992482414f
commit f95835593f
8 files changed
+453 -50

No files matched your search

+71 -2
View File
@@ -143,6 +143,13 @@ macro_rules! primitives {
/// compacted, so a `Mask` can hold a slot across frames.
pub struct Primitives {
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>,
/// Where each slot's [`PrimitiveHandle`] sits in its owner's
/// `ActiveData::primitives` -- the index that makes
@@ -179,6 +186,7 @@ impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
original_instances: Default::default(),
assoc: Default::default(),
handle_idx: Default::default(),
freed: Vec::new(),
@@ -251,11 +259,13 @@ impl Primitives {
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst;
self.original_instances[i] = None;
self.assoc[i] = id;
self.handle_idx[i] = Self::NO_HANDLE;
i
} else {
self.instances.push(inst);
self.original_instances.push(None);
self.assoc.push(id);
self.handle_idx.push(Self::NO_HANDLE);
self.instances.len() - 1
@@ -342,8 +352,18 @@ impl Primitives {
if bytemuck::bytes_of(&self.instances[slot]) == bytemuck::bytes_of(&inst) {
return;
}
if !self.dirty.contains(slot) {
self.original_instances[slot] = Some(self.instances[slot]);
}
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
@@ -392,6 +412,7 @@ impl Primitives {
pub fn clear(&mut self) {
self.dirty.mark_all();
self.instances.clear();
self.original_instances.clear();
self.assoc.clear();
self.handle_idx.clear();
self.freed.clear();
@@ -464,7 +485,13 @@ impl Primitives {
}
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
}
}
@@ -785,3 +812,45 @@ impl<T> Deref for PrimitiveVec<T> {
&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,
/// Retained so descendants' parent links stay valid across redraws.
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`.
pub move_applied: Vec2,
}
+6 -5
View File
@@ -18,11 +18,12 @@ pub struct UiData {
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, forming the parent-linked chain
/// `resolve_move` walks in both shader stages. Allocated once on a
/// widget's first draw and reused for every later redraw of the same
/// id (never reallocated), so a retained descendant's `parent` index
/// never goes stale -- see LAYOUT.md section 2.
/// One entry per widget ever drawn, plus optional child-coordinate
/// boundaries owned by containers. Together they form the parent-linked
/// chain `resolve_move` walks in both shader stages. A widget's ordinary
/// entry is allocated once on its first draw and reused for every later
/// 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>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
+49 -4
View File
@@ -1,6 +1,7 @@
use crate::{
Axis, Color, Len, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer,
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
Axis, Color, Len, MoveOffset, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs,
TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2,
WidgetId,
render::{
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
PrimitiveHandle, PrimitiveInst, RectPrimitive,
@@ -16,6 +17,7 @@ pub struct Painter<'a> {
pub(super) region: UiRegion,
pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
pub(super) child_move_slot: Option<MoveIdx>,
/// This widget's retained mask slot.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
@@ -217,6 +219,47 @@ impl<'a> Painter<'a> {
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> {
if let Some(len) = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis) {
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
// widget's own slot, already known, and always correct regardless
// 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.layer,
id.id(),
region,
Some(self.id),
self.move_slot.idx() as u32,
parent_move_slot.idx() as u32,
self.mask,
Retained::default(),
self.rsc,
@@ -261,12 +305,13 @@ impl<'a> Painter<'a> {
} else if let Some((layer, mask)) = retained {
self.children.push(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(
layer,
id.id(),
region,
Some(self.id),
self.move_slot.idx() as u32,
parent_move_slot.idx() as u32,
mask,
Retained::default(),
self.rsc,
+25
View File
@@ -99,6 +99,7 @@ pub(crate) struct Retained {
pub region: Option<UiRegion>,
pub children: Vec<WidgetId>,
pub move_slot: Option<MoveIdx>,
pub child_move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx,
pub primitives: Vec<PrimitiveHandle>,
}
@@ -109,6 +110,7 @@ impl Default for Retained {
region: None,
children: Vec::new(),
move_slot: None,
child_move_slot: None,
own_mask: MaskIdx::NONE,
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
/// [`Drawn::No`], into `layer`'s draw order.
pub(super) fn write_primitive<P: Primitive>(
@@ -446,6 +452,7 @@ impl UiRenderState {
region: mut old_region,
children: mut old_children,
move_slot: mut old_move_slot,
mut child_move_slot,
mut own_mask,
primitives: mut recycle,
} = retained;
@@ -498,6 +505,7 @@ impl UiRenderState {
old_region = Some(active.region);
old_children = active.children;
old_move_slot = Some(active.move_slot);
child_move_slot = active.child_move_slot;
own_mask = active.own_mask;
recycle = active.primitives;
} else if self.active.contains_key(&id) {
@@ -512,6 +520,7 @@ impl UiRenderState {
old_region = Some(active.region);
old_children = active.children;
old_move_slot = Some(active.move_slot);
child_move_slot = active.child_move_slot;
own_mask = active.own_mask;
recycle = active.primitives;
}
@@ -533,6 +542,7 @@ impl UiRenderState {
region,
mask,
move_slot,
child_move_slot,
own_mask,
layer,
id,
@@ -577,6 +587,7 @@ impl UiRenderState {
region,
mask: _,
move_slot,
child_move_slot,
own_mask,
textures,
primitives,
@@ -608,6 +619,7 @@ impl UiRenderState {
layer,
size,
move_slot,
child_move_slot,
own_mask,
move_applied: Vec2::ZERO,
};
@@ -804,6 +816,18 @@ impl UiRenderState {
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;
rsc.ui_mut().move_offsets.remove(active.move_slot);
if parent_slot != MoveOffset::NONE_PARENT {
@@ -1213,6 +1237,7 @@ impl UiRenderState {
region: Some(active.region),
children: active.children,
move_slot: Some(active.move_slot),
child_move_slot: active.child_move_slot,
own_mask: active.own_mask,
primitives: active.primitives,
},
+31
View File
@@ -45,6 +45,26 @@ impl Dirty {
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
/// contents are undefined), or the array was cleared.
pub fn mark_all(&mut self) {
@@ -114,6 +134,17 @@ mod tests {
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]
fn a_run_that_crosses_a_word_boundary_is_one_range() {
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]
fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
let mut rsc = TestRsc {
+206 -39
View File
@@ -141,6 +141,11 @@ pub struct LazySpan {
extents: HashMap<RowKey, RowExtent>,
/// Last reported height, pruned when its row is evicted.
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
/// edge *and* nothing left to give back there -- what
/// [`Self::overscroll_gap`] reads. `false` by default, matching
@@ -190,6 +195,7 @@ impl LazySpan {
pending_tap: None,
extents: HashMap::default(),
heights: HashMap::default(),
content_offset: 0.0,
}
}
@@ -309,6 +315,7 @@ impl LazySpan {
self.ctl.set_pinned_to_end(true);
self.heights.clear();
self.extents.clear();
self.content_offset = 0.0;
}
/// 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
/// off the end of the content -- see [`Self::overscroll_gap`] and
/// `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
.anchor
.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::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;
while lead > 0.0 {
let Some(prev) = self.prev_slot(idx_lead) else {
break;
};
let (l, _) = self.place(painter, prev, Placement::Trailing(lead));
let (l, _) = self.place(painter, prev, Placement::Trailing(lead), None, false);
lead = l;
idx_lead = prev;
}
@@ -750,7 +758,7 @@ impl LazySpan {
let Some(next) = self.next_slot(idx_trail) else {
break;
};
let (_, t) = self.place(painter, next, Placement::Leading(trail));
let (_, t) = self.place(painter, next, Placement::Leading(trail), None, false);
trail = t;
idx_trail = next;
}
@@ -837,8 +845,48 @@ impl LazySpan {
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.
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!(
self.slot_exists(slot),
"place() called with a slot that doesn't exist: {slot:?}"
@@ -856,37 +904,69 @@ impl LazySpan {
};
let key = self.slot_key(slot);
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 {
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) {
return (lead, trail);
}
}
let widget = self.slot_widget(slot);
let height = match cached {
Some(h) => {
let height = match (cached, known_anchor_height) {
(Some(_), Some(height)) => {
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 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);
if height != h {
let (lead, trail) = placement.edges(height);
painter.place(widget, Self::abs_region(dir, lead, trail));
let (new_lead, new_trail) = placement.edges(height);
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
}
None => {
(None, None) => {
let measure_from = match placement {
Placement::Leading(lead) => lead,
Placement::Trailing(_) => 0.0,
};
let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING);
let height = resolve(painter.widget_within(widget, first));
let first = self.row_region(measure_from, measure_from + GENEROUS_PADDING);
let height = resolve(painter.widget_within(self.slot_widget(slot), first));
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
}
(None, Some(_)) => unreachable!("an uncached row has no retained height"),
};
let (lead, trail) = placement.edges(height);
if let Some(k) = key {
@@ -902,6 +982,11 @@ impl LazySpan {
/// Primary-axis room for a row whose extent is not cached yet.
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 {
/// Make this span scrollable: the wheel and a finger drag, registered
/// on the span itself.
@@ -1022,9 +1107,17 @@ impl Widget for LazySpan {
if let Some(tap) = self.pending_tap.take() {
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.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
// 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)
// move.
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.extents.clear();
self.lay_out(painter);
self.lay_out(painter, stable_anchor_lead);
}
self.rehome_anchor();
@@ -1200,13 +1299,12 @@ mod tests {
/// is `dir` -- and the newest row moves from the bottom of the screen
/// to the top.
///
/// Asserts on **where each row was actually drawn**
/// (`UiRenderState::active`), not on `extents`: those are kept in the
/// walk's own direction-relative space and converted on the way out,
/// so an `extent()`-only test passes even with the flip in
/// `abs_region` deleted -- it would be checking the bookkeeping
/// against itself while every row painted at the mirror of where it
/// belongs.
/// Asserts on each row's **resolved window region**, not on `extents`:
/// those are kept in the walk's own direction-relative space and
/// converted on the way out, so an `extent()`-only test passes even with
/// the flip in `abs_region` deleted. The resolved region also includes
/// the child-coordinate offset that now keeps retained row geometry
/// stable.
#[test]
fn a_dir_up_span_grows_upward_from_item_zero() {
let mut rsc = TestRsc {
@@ -1221,7 +1319,9 @@ mod tests {
render.update(&root, &mut rsc);
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)
};
assert_eq!(
@@ -1297,16 +1397,16 @@ mod tests {
// Row 4 is on screen in both spans now, and stays drawn
// across a move this small whichever way it goes.
let top = |render: &UiRenderState| {
render.active[&rows[4].id()]
.region
.to_px((100.0, 100.0).into())
let top = |render: &UiRenderState, rsc: &TestRsc| {
render
.window_region(&rows[4], rsc)
.expect("row 4 is still drawn")
.top_left
.y
};
let before = top(&render);
let before = top(&render, &rsc);
push(&mut rsc, &mut render, -10.0);
top(&render) - before
top(&render, &rsc) - before
};
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));
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;
assert!(
(drawn - new_height).abs() < 0.5,
@@ -1664,19 +1771,79 @@ mod tests {
// overscroll, and the clamp lays out a second time within the
// frame to give it back -- a correct extra pass, but not the
// 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);
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();
// The visible window is a fixed ~10 rows regardless of n; an
// O(n) regression would show up as draws/moves scaling with
// list size instead of staying flat.
assert!(draws <= 12, "n={n}: expected O(visible), got {draws} draws");
assert!(moves >= 1, "n={n}: a scroll tick should move something");
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
// The visible window is a fixed ~10 rows regardless of n. The
// row walk still runs so virtualization can admit and retire
// rows, but every retained row shares the list's one child
// coordinate move.
assert_eq!(draws, 1, "n={n}: only the list should really draw");
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
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
/// swaps the last row's widget for a taller one, same key, same slot.