Settle growing layout branches in one frame
This commit is contained in:
1 parent
e212ed8d02
commit
5ece49b8d9
7 files changed
+139
-33
No files matched your search
@@ -462,8 +462,8 @@ Each exists because something was invisible without it.
|
|||||||
The remaining layout cost was then removed at the framework boundary:
|
The remaining layout cost was then removed at the framework boundary:
|
||||||
`Painter::set_child_offset` gives a container one retained coordinate slot
|
`Painter::set_child_offset` gives a container one retained coordinate slot
|
||||||
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
||||||
Pinned growth now uploads instances at **2.9% against a 2.9% floor**, from
|
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
|
||||||
71.9% against 71.8%; p50 instance upload is **1,728 bytes**, from 176,496.
|
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
|
||||||
`Primitives` also cancels dirty marks for provisional writes restored before
|
`Primitives` also cancels dirty marks for provisional writes restored before
|
||||||
upload, so CPU-only layout states never become GPU work.
|
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;
|
||||||
|
|||||||
@@ -258,17 +258,15 @@ fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
|||||||
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
||||||
h.frame(PHONE_FRAME_MS);
|
h.frame(PHONE_FRAME_MS);
|
||||||
}
|
}
|
||||||
|
let message = h
|
||||||
|
.render
|
||||||
|
.debug(h.rsc.widgets(), "Message")
|
||||||
|
.find(|a| !a.primitives.is_empty())
|
||||||
|
.expect("the composer field is drawn");
|
||||||
// The caret is the last primitive `TextEdit::draw` emits.
|
// The caret is the last primitive `TextEdit::draw` emits.
|
||||||
let caret = {
|
let caret = h
|
||||||
let slot = *h
|
.render
|
||||||
.render
|
.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc);
|
||||||
.debug(h.rsc.widgets(), "Message")
|
|
||||||
.flat_map(|a| a.primitives.iter().map(|p| p.slot))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.last()
|
|
||||||
.expect("the focused field draws a caret");
|
|
||||||
h.render.primitive_corners(slot, &h.rsc)
|
|
||||||
};
|
|
||||||
// The bar sits directly on the IME, so its inside edge is one
|
// The bar sits directly on the IME, so its inside edge is one
|
||||||
// `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than
|
// `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than
|
||||||
// read back from the composer, which is the thing under test.
|
// read back from the composer, which is the thing under test.
|
||||||
@@ -280,4 +278,22 @@ fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
|||||||
and its padding is {padding}px",
|
and its padding is {padding}px",
|
||||||
caret.bot_right.y,
|
caret.bot_right.y,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The old assertion only guarded the last line. A viewport one line
|
||||||
|
// shorter than the field still kept that caret above the bottom while
|
||||||
|
// moving the first line above the bar's mask, visibly slicing it off.
|
||||||
|
let mask = h.rsc.ui.masks[message.mask.idx()];
|
||||||
|
let bar = h.render.primitive_corners(mask.primitive, &h.rsc);
|
||||||
|
let visible_content_top = message
|
||||||
|
.primitives
|
||||||
|
.iter()
|
||||||
|
.map(|p| h.render.primitive_corners(p.slot, &h.rsc))
|
||||||
|
.filter(|r| r.bot_right.y > bar.top_left.y)
|
||||||
|
.map(|r| r.top_left.y)
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
assert!(
|
||||||
|
visible_content_top > bar.top_left.y,
|
||||||
|
"the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}",
|
||||||
|
bar.top_left.y,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+16
-11
@@ -281,12 +281,15 @@ always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
|||||||
`false` (the default) is redrawn in full whenever `available` changes,
|
`false` (the default) is redrawn in full whenever `available` changes,
|
||||||
which is correct always, just not free.
|
which is correct always, just not free.
|
||||||
|
|
||||||
**Ancestor propagation** (a resized child changing its own reported size,
|
**Size propagation goes both ways in the same frame.** A resized child first
|
||||||
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
walks upward through exactly the ancestors whose cached size changes. That
|
||||||
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
measurement pass gives each parent the new size but necessarily drew the
|
||||||
ancestors whose cached size differs from the new one and stops as soon as
|
branch in its old boxes. As the recursion returns, `redraw_and_settle` revisits
|
||||||
a size is unchanged (`:274-286`). That loop moves from consulting
|
those changed widgets from the outside in, after their parents have assigned
|
||||||
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
the final boxes. Otherwise a newly appended child can retain the provisional
|
||||||
|
(even inverted) region it was measured in until another update happens. The
|
||||||
|
downward work is confined to the branch that changed; unchanged descendants
|
||||||
|
still take `draw_inner`'s retained fast path.
|
||||||
|
|
||||||
### 4. Wrapped text, and "needs child height before choosing width"
|
### 4. Wrapped text, and "needs child height before choosing width"
|
||||||
|
|
||||||
@@ -667,9 +670,11 @@ A move alone cannot fix a changed size; `Painter::place` redraws in that
|
|||||||
case.
|
case.
|
||||||
|
|
||||||
The cost is bounded and worth stating, because it is what makes the rule
|
The cost is bounded and worth stating, because it is what makes the rule
|
||||||
safe to apply everywhere: the second draw happens only on the frame a
|
safe to apply everywhere: the settling draw happens only on the frame a
|
||||||
widget's own size actually changes, which is a frame that was already
|
widget's own size actually changes, which is a frame that was already
|
||||||
redrawing it. A widget whose reported size is a function of the box it
|
redrawing it. `Sized` also requires its final region before retaining its
|
||||||
was *offered* would disagree every frame and redraw every frame — which
|
children: its own reported size may be known exactly while a descendant was
|
||||||
is why `LazySpan` requires content-sized rows, and has since long before
|
drawn in the provisional box, so moving only the wrapper is insufficient. A
|
||||||
this.
|
widget whose reported size is a function of the box it was *offered* would
|
||||||
|
disagree every frame and redraw every frame — which is why `LazySpan` requires
|
||||||
|
content-sized rows, and has since long before this.
|
||||||
+5
-6
@@ -682,10 +682,9 @@ report an exact `Len`; a debug assertion compares every hint with the real
|
|||||||
draw result. If final allocation changes a child's size, `Painter::place`
|
draw result. If final allocation changes a child's size, `Painter::place`
|
||||||
redraws it in that box. Otherwise placement is one move-offset write.
|
redraws it in that box. Otherwise placement is one move-offset write.
|
||||||
|
|
||||||
Measured over the fixture's 401 streamed events: the busiest frame makes
|
Measured over the fixture's 401 streamed events, streamed-frame CPU p50 is
|
||||||
176 `Widget::draw` calls and the worst widget is called four times.
|
0.12ms, from 1.18ms before this layout change. Arena size and upload floors
|
||||||
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
|
are unchanged.
|
||||||
Arena size and upload floors are unchanged.
|
|
||||||
|
|
||||||
Pinned growth now uses the same subtree translation as scrolling. A
|
Pinned growth now uses the same subtree translation as scrolling. A
|
||||||
container can retain a child-coordinate move slot through
|
container can retain a child-coordinate move slot through
|
||||||
@@ -693,8 +692,8 @@ container can retain a child-coordinate move slot through
|
|||||||
boxes and changes that one slot when its anchor moves. It still walks the
|
boxes and changes that one slot when its anchor moves. It still walks the
|
||||||
visible run to virtualise it, but unchanged rows no longer acquire new
|
visible run to virtualise it, but unchanged rows no longer acquire new
|
||||||
absolute primitive regions. Over the fixture's 401 streamed events, instance
|
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
|
upload is **1.1% against a 1.1% floor**, from 71.9% against 71.8%; median
|
||||||
instance bytes per frame are **1,728**, from 176,496. This is framework
|
instance bytes per frame are **1,488**, from 176,496. This is framework
|
||||||
layout/rendering behaviour and the transcript screen contains no special
|
layout/rendering behaviour and the transcript screen contains no special
|
||||||
case for it.
|
case for it.
|
||||||
|
|
||||||
|
|||||||
@@ -1207,6 +1207,16 @@ impl UiRenderState {
|
|||||||
|
|
||||||
/// redraws a widget that's currently active (drawn)
|
/// redraws a widget that's currently active (drawn)
|
||||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||||
|
self.redraw_and_settle(id, rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure a changed branch toward the root, then revisit each widget
|
||||||
|
/// whose reported size changed on the way back down. The upward pass gives
|
||||||
|
/// every parent the new child size; the downward pass is what lets those
|
||||||
|
/// children draw inside the final boxes their parents chose. Without it a
|
||||||
|
/// newly grown subtree can retain the provisional (even inverted) region
|
||||||
|
/// it was measured in until an unrelated later update redraws it.
|
||||||
|
fn redraw_and_settle(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
// An ancestor is drawing this widget right now, and that draw is
|
// An ancestor is drawing this widget right now, and that draw is
|
||||||
// about to write fresh primitives for it. Drawing it a second time
|
// about to write fresh primitives for it. Drawing it a second time
|
||||||
@@ -1248,11 +1258,15 @@ impl UiRenderState {
|
|||||||
// relay out too. Checked after the real draw, not before it --
|
// relay out too. Checked after the real draw, not before it --
|
||||||
// there is no query left that answers "what size would this be"
|
// there is no query left that answers "what size would this be"
|
||||||
// without actually drawing (LAYOUT.md section 5).
|
// without actually drawing (LAYOUT.md section 5).
|
||||||
if let Some(pid) = parent {
|
let changed = self.active.get(&id).map(|a| a.size) != Some(old_size);
|
||||||
let new_size = self.active.get(&id).map(|a| a.size);
|
if changed {
|
||||||
if new_size != Some(old_size) {
|
if let Some(pid) = parent {
|
||||||
self.redraw(pid, rsc);
|
self.redraw_and_settle(pid, rsc);
|
||||||
}
|
}
|
||||||
|
// The parent pass above has now placed this widget in its final
|
||||||
|
// region. Draw it once more there; unchanged descendants still
|
||||||
|
// take draw_inner's retained fast path.
|
||||||
|
self.redraw_and_settle(id, rsc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1172,3 +1172,67 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Growing an already-drawn row first measures its new child against the
|
||||||
|
/// row's old height. That provisional box can end before it starts when the
|
||||||
|
/// old trailing edge is above the new child's cursor. The size must bubble to
|
||||||
|
/// `LazySpan` and the corrected allocation must travel back down before this
|
||||||
|
/// update is presented; a later stream event is not a layout pass.
|
||||||
|
#[test]
|
||||||
|
fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
|
||||||
|
const FIRST: f32 = 30.0;
|
||||||
|
const SECOND: f32 = 70.0;
|
||||||
|
const GAP: f32 = 8.0;
|
||||||
|
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let first = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let first_id = first.id();
|
||||||
|
let first = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: first.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(FIRST)),
|
||||||
|
});
|
||||||
|
let mut contents = Span::empty(Dir::DOWN).gap(Len::abs(GAP));
|
||||||
|
contents.push(first.any());
|
||||||
|
let contents = rsc.ui.widgets.add_strong(contents);
|
||||||
|
let contents_w = contents.weak();
|
||||||
|
let row = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: contents.any(),
|
||||||
|
x: Some(Len::rest(1.0)),
|
||||||
|
y: None,
|
||||||
|
});
|
||||||
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
|
list.push_back(LazyItem::new(0, row.any()));
|
||||||
|
let root = rsc.ui.widgets.add_strong(list).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((200.0, 200.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let second = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let second_id = second.id();
|
||||||
|
let second = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: second.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(SECOND)),
|
||||||
|
});
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&contents_w)
|
||||||
|
.unwrap()
|
||||||
|
.push(second.any());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let first = render.primitive_corners(render.first_primitive(first_id).unwrap(), &rsc);
|
||||||
|
let second = render.primitive_corners(render.first_primitive(second_id).unwrap(), &rsc);
|
||||||
|
assert!(
|
||||||
|
(second.top_left.y - (first.bot_right.y + GAP)).abs() < 0.01,
|
||||||
|
"the new child should start after the old child and its gap: first={first:?} second={second:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(second.bot_right.y - second.top_left.y - SECOND).abs() < 0.01,
|
||||||
|
"the new child retained its provisional box: {second:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,4 +31,12 @@ impl Widget for Sized {
|
|||||||
Axis::Y => self.y,
|
Axis::Y => self.y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_exact_region(&self) -> bool {
|
||||||
|
// `Sized` may be measured in a provisional box and then placed in
|
||||||
|
// the content-sized box it reported. Its own region can be corrected
|
||||||
|
// by a move, but its child consumed the original box and must see the
|
||||||
|
// final one too.
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user