Redesign span layout around retained placement
This commit is contained in:
1 parent
4b69f3cc6b
commit
992482414f
23 files changed
+426
-1005
No files matched your search
@@ -7,34 +7,11 @@
|
||||
use crate::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A scrolling view over a child that is a fixed lump: it is measured
|
||||
/// whole and then moved, which is what makes a scroll tick an O(1) move of
|
||||
/// one subtree rather than a redraw.
|
||||
///
|
||||
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
|
||||
/// a child that lays out lazily cannot be measured whole or moved as a
|
||||
/// lump, and virtualising it inside one of these would never update which
|
||||
/// rows it shows, since a scroll tick offers a same-size moved region and
|
||||
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
|
||||
/// owns a controller of its own instead of being wrapped in one of these.
|
||||
/// A scrolling view that moves one fixed child as a subtree.
|
||||
pub struct ScrollArea {
|
||||
inner: StrongWidget,
|
||||
/// The position, the gesture, the fling and the pin -- everything
|
||||
/// about scrolling that is not this widget's own layout, shared with
|
||||
/// `LazySpan` rather than reimplemented beside it.
|
||||
ctl: ScrollController,
|
||||
container_len: f32,
|
||||
/// How long the content is along the axis, as of the last draw --
|
||||
/// `None` until this widget has drawn once.
|
||||
///
|
||||
/// An `Option` rather than a `0.0` that stands in for both, because
|
||||
/// the two answers led somewhere different and the code could not tell
|
||||
/// them apart: on the first frame the clamp computed a scroll range of
|
||||
/// zero, concluded from `amt == range` that the area was sitting at
|
||||
/// its end, and pinned it -- so the next frame, now knowing the real
|
||||
/// length, jumped to it. A code fence therefore opened at the end of
|
||||
/// its longest line, mid-word (`iris/run-headless.sh phone`,
|
||||
/// 2026-09-08).
|
||||
content_len: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -49,73 +26,23 @@ impl Scrollable for ScrollArea {
|
||||
}
|
||||
|
||||
impl Widget for ScrollArea {
|
||||
/// A scroll area animates exactly one thing, its fling. The
|
||||
/// registration that makes this run is `UiData::animate`, which
|
||||
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
|
||||
/// release starts one.
|
||||
fn tick(&mut self, now: Instant) -> bool {
|
||||
self.tick_fling(now)
|
||||
}
|
||||
|
||||
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
|
||||
/// reason: nothing drawn may depend on a length measured last frame.
|
||||
///
|
||||
/// **The child is drawn twice, and only the second decides anything.**
|
||||
/// The first is handed last frame's length as a *hint*, and it exists
|
||||
/// only so that the usual case, where the content's length did not
|
||||
/// change, offers the same region twice: `draw_inner` then makes the
|
||||
/// first call an O(1) `mov` and returns at the first line of the
|
||||
/// second. A frame on which the content did grow or shrink pays one
|
||||
/// real extra draw, and that is a frame on which the content was being
|
||||
/// redrawn anyway.
|
||||
///
|
||||
/// The alternative -- place against the hint and let the next frame
|
||||
/// fix it -- is what Iris found on her phone (2026-09-08): every
|
||||
/// newline typed into the composer drew the field in a box one line
|
||||
/// short of its text, and since that text is centred in its box it
|
||||
/// hung half a line past each end. There was no next frame: nothing
|
||||
/// dirtied that subtree again, so the stale placement was the last one
|
||||
/// drawn, until the keyboard closed and its inset rewrite forced a
|
||||
/// redraw ("it fixes itself"). **Layout is a pure function of the
|
||||
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
|
||||
/// -- a correction that needs a second frame is a frame drawn wrong.
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
// Every length here is resolved against the box this widget was
|
||||
// **offered** (`px_size`), never `output_size`: a scroll area is
|
||||
// routinely smaller than the window -- the composer's field is
|
||||
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||
// the window instead would make the pan range, and so where the
|
||||
// content sits, a function of the screen rather than of the box.
|
||||
let axis = self.ctl.axis();
|
||||
let container_len = painter.px_size().axis(axis);
|
||||
self.container_len = container_len;
|
||||
// Learned from the frame rather than passed in: a fling's
|
||||
// deceleration is a physical quantity and needs the real display
|
||||
// density, and `draw` is where this widget meets the only thing
|
||||
// that knows it.
|
||||
self.ctl.set_density(painter.density());
|
||||
|
||||
// Where the delta asked for since the last frame puts the content.
|
||||
// Already inside the range the previous frame published, so it is
|
||||
// the position to *measure* against; the clamp below is what the
|
||||
// length just measured has to say about it.
|
||||
let delta = self.ctl.take_delta();
|
||||
let travelled = self.ctl.amt() - delta;
|
||||
self.ctl.set_amt(travelled);
|
||||
|
||||
// The container's own length stands in as the hint until anything
|
||||
// has been measured: a zero-length region on the first frame would
|
||||
// place the child's primitives against a box of no size.
|
||||
let hint = self.content_len.unwrap_or(container_len);
|
||||
// A **measurement**: this asks how long the content is, and the
|
||||
// box it asks about is built from a hint that the answer below is
|
||||
// about to correct. Drawing it here painted the whole content at a
|
||||
// provisional offset and then painted it again at the real one.
|
||||
let used = painter.measure(&self.inner, self.child_region(hint));
|
||||
let used = painter.widget_within(&self.inner, self.child_region(hint));
|
||||
|
||||
// A child reporting `rel` means "this fraction of what I was
|
||||
// offered", and what it was offered is this scroll area -- so the
|
||||
// container, again, is what that resolves against.
|
||||
let measured = used
|
||||
.axis(axis)
|
||||
.apply_rest(painter.density())
|
||||
@@ -123,18 +50,6 @@ impl Widget for ScrollArea {
|
||||
self.content_len = Some(measured);
|
||||
let range = (measured - container_len).max(0.0);
|
||||
|
||||
// The end-pin, and then the clamp, against the length just
|
||||
// measured. Deliberately not also run before the measuring draw
|
||||
// above -- clamping against the hint would let a stale length
|
||||
// reduce `amt` in a way this pass cannot undo, and then where the
|
||||
// content sits would depend on the previous frame after all.
|
||||
//
|
||||
// Only a frame with no delta of its own re-pins: the pin means
|
||||
// "stay flush with the end as the content grows", and a reader who
|
||||
// just scrolled away from that end has said otherwise. (A delta
|
||||
// cannot be moving *toward* the end here -- the travel published
|
||||
// below is zero that way while pinned, so `take_delta` has already
|
||||
// clipped it.)
|
||||
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
|
||||
range
|
||||
} else {
|
||||
@@ -147,15 +62,7 @@ impl Widget for ScrollArea {
|
||||
fwd: range - amt,
|
||||
});
|
||||
|
||||
// The **content's** size, not the container's. A parent that can
|
||||
// grow (the composer's bar) should hug the text until its own cap
|
||||
// stops it, and reporting the container instead would make this
|
||||
// widget's answer a function of the answer -- the bar is sized
|
||||
// from what is reported here, so it collapses to nothing and never
|
||||
// recovers. What keeps the content inside the offered box is the
|
||||
// mask a caller puts around it (`.scrollable(..).masked()`), not
|
||||
// this number.
|
||||
painter.widget_within(&self.inner, self.child_region(measured))
|
||||
painter.place(&self.inner, self.child_region(measured))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +83,7 @@ impl ScrollArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the child sits for a given content length: a box that long
|
||||
/// along the scroll axis, pulled back by `amt`. The length is taken as
|
||||
/// a parameter rather than read from `content_len`, because `draw`
|
||||
/// places twice -- once against last frame's length and once against
|
||||
/// the one it has just measured -- and the two must be the same
|
||||
/// arithmetic.
|
||||
/// A content-sized box offset by the current scroll amount.
|
||||
fn child_region(&self, content_len: f32) -> UiRegion {
|
||||
let axis = self.ctl.axis();
|
||||
let mut region = UiRegion::FULL;
|
||||
|
||||
Reference in new issue
Block a user