Stop composer layout recursion on spaces

This commit is contained in:
iris committed 2026-09-09 19:54:53 -04:00
1 parent 5ece49b8d9
commit e5fee03da8
4 files changed
+86 -14

No files matched your search

+7
View File
@@ -112,6 +112,13 @@ where
// the transcript: measured at 58px of stray text for a 475px message // the transcript: measured at 58px of stray text for a 475px message
// in a 417px box. // in a 417px box.
let content = field let content = field
// A wrapping editor occupies the composer's width even when its
// current text is short. Without this inner constraint the vertical
// ScrollArea reports the text's narrow natural width to Pad; settling
// then offers that width back to the editor, where a trailing space
// wraps and doubles its height. The next parent pass restores the
// wide one-line answer, so the two layouts have no fixed point.
.width(rest(1))
// `scrollable_to_end`: what is being typed is at the end, so a // `scrollable_to_end`: what is being typed is at the end, so a
// message longer than the six lines shown holds that end. // message longer than the six lines shown holds that end.
.scrollable(Axis::Y, Pin::End) .scrollable(Axis::Y, Pin::End)
+34
View File
@@ -225,6 +225,40 @@ fn the_composer_sits_above_a_simulated_ime_inset() {
); );
} }
/// A trailing space is narrower than the composer's available width but can
/// wrap when the field is measured again in its own reported width. The
/// settling walk must not bounce forever between those one- and two-line
/// answers. On Android that recursion exhausted the native UI thread's stack
/// and ended in SIGSEGV, before Rust's panic hook could write anything.
#[test]
fn a_space_in_the_composer_finishes_layout() {
let (mut h, screen) = opened();
screen.composer.set_bottom_inset(&mut h.rsc, 1000.0);
h.frame(PHONE_FRAME_MS * 2);
h.state.set_focus(Some(screen.composer.field));
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
for text in ["h", "i", " "] {
screen.composer.field.edit(&mut h.rsc).insert(text);
h.frame(PHONE_FRAME_MS);
}
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.text(), "hi ");
let region = h
.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is drawn");
let size = region.bot_right - region.top_left;
assert!(
size.x > phone_size().x / 2.0,
"the field shrink-wrapped to the short message: {region:?}"
);
assert!(
size.y < 30.0 * PHONE_SCALE,
"the trailing space wrapped onto a second line: {region:?}"
);
}
/// A newline typed into the composer must leave the caret inside the /// A newline typed into the composer must leave the caret inside the
/// bar's own padding, not flush against its bottom edge. /// bar's own padding, not flush against its bottom edge.
/// ///
+8 -1
View File
@@ -289,7 +289,14 @@ those changed widgets from the outside in, after their parents have assigned
the final boxes. Otherwise a newly appended child can retain the provisional the final boxes. Otherwise a newly appended child can retain the provisional
(even inverted) region it was measured in until another update happens. The (even inverted) region it was measured in until another update happens. The
downward work is confined to the branch that changed; unchanged descendants downward work is confined to the branch that changed; unchanged descendants
still take `draw_inner`'s retained fast path. still take `draw_inner`'s retained fast path. Each downward visit is exactly
one redraw, not another upward propagation: a wrapping child can have no fixed
point when an ancestor shrink-wraps it (a trailing space alternated between one
line in the offered width and two lines in its reported natural width). Feeding
that answer back into the same branch recursively overflowed Android's native
UI-thread stack before Rust could report a panic. A container that intends a
wrapping child to occupy its width declares that constraint explicitly; the
message composer does so on both sides of its vertical `ScrollArea`.
### 4. Wrapped text, and "needs child height before choosing width" ### 4. Wrapped text, and "needs child height before choosing width"
+37 -13
View File
@@ -1217,18 +1217,50 @@ impl UiRenderState {
/// newly grown subtree can retain the provisional (even inverted) region /// newly grown subtree can retain the provisional (even inverted) region
/// it was measured in until an unrelated later update redraws it. /// it was measured in until an unrelated later update redraws it.
fn redraw_and_settle(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { fn redraw_and_settle(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
let Some((parent, changed)) = self.redraw_once(id, rsc) else {
return;
};
if changed {
if let Some(pid) = parent {
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. This is deliberately one
// redraw rather than another settling pass: feeding its size
// back into the same upward walk can alternate between the
// provisional and final regions forever (a text edit first did
// that when an Android IME committed a space), overflowing the
// native thread's stack before Rust can report a panic.
let settled_size = self.active.get(&id).map(|active| active.size);
let _ = self.redraw_once(id, rsc);
debug_assert_eq!(
self.active.get(&id).map(|active| active.size),
settled_size,
"a widget changed size after its parent settled its final region"
);
}
}
/// Redraw `id` exactly once, returning its parent and whether the size it
/// reports changed. [`Self::redraw_and_settle`] owns any propagation; in
/// particular, its final downward redraw must not start another upward
/// pass through the same branch.
fn redraw_once(
&mut self,
id: WidgetId,
rsc: &mut dyn UiRsc,
) -> Option<(Option<WidgetId>, bool)> {
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
// here would leave one of the two copies on screen with nothing // here would leave one of the two copies on screen with nothing
// owning it -- see `draw_started`'s own doc. // owning it -- see `draw_started`'s own doc.
if self.draw_started.contains(&id) { if self.draw_started.contains(&id) {
return; return None;
} }
let Some(active) = self.remove(id, false, true, rsc) else { let active = self.remove(id, false, true, rsc)?;
return;
};
let old_size = active.size; let old_size = active.size;
let parent = active.parent; let parent = active.parent;
// `old_move_slot` being `Some` below means the slot is reused in // `old_move_slot` being `Some` below means the slot is reused in
@@ -1259,15 +1291,7 @@ impl UiRenderState {
// 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).
let changed = self.active.get(&id).map(|a| a.size) != Some(old_size); let changed = self.active.get(&id).map(|a| a.size) != Some(old_size);
if changed { Some((parent, changed))
if let Some(pid) = parent {
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);
}
} }
} }