Move LazySpan rows through one retained offset
This commit is contained in:
1 parent
992482414f
commit
f95835593f
8 files changed
+453
-50
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user