Compare commits

...
Author SHA1 Message Date
iris-ai a2cb4f05da WIP: a stack does not take its sizing child's fraction twice
Every child gets the whole of the stack's box rather than `box_of(size)`,
and `widget_at` does not resolve a rule into a box already chosen from it.
Fixes half a row becoming a quarter, which no oracle can see because warm
and cold shrink alike. Pinned by
`a_stack_sized_by_a_child_does_not_take_that_childs_fraction_twice`.

Not landed. Seed 1091 at depth 4, `shuffle-swap-for-three`, disagrees by
three steps of the grid -- warm 1053.9971 against cold 1054 -- where the
oracle tolerates two. Not a structural divergence: `box_of` was also making
a child's placement exact, by handing it a box of exactly the length it
asked for, and giving it the whole box instead puts a rounding back at each
nesting level. Two nested stacks is three steps. Widening AGREE_STEPS is
not the answer; finding the composition that went from exact to rounded is.

Everything else is green: suite, shrinker at 400 seeds of depth 5, oracle at
1000 seeds of depth 6, and the rest of 2000 seeds at depth 4.

Also carries examples/text.rs's top panel taking the full width.
2026-09-17 04:50:00 -04:00
iris-ai a92c6acdbf Settle a frame strictly bottom-up rather than escalating into a parent
The queue was already deepest-first, but a widget that could not settle
where it was called `redraw` on its parent from inside itself. That drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending, and a parent drawing over a subtree that has not settled reads
answers about to move: the one that settles does so inside the parent's
draw, where its mark comes off and nothing compares what it now answers.
Seed 564 was exactly that, and it is the second time this shape has been
found.

So a widget that cannot settle defers instead. It marks its parent, stays
marked itself, and waits in `deferred` until the walk down the depths
reaches the parent -- which cannot be before everything deeper has settled,
because the walk always takes the deepest widget that is not waiting. The
category stops being something to check for. (Bryan, 2026-09-17.)

`dirty_size_under` stays in `draw_inner` for now: `update` draws the root
for a resize before `redraw_updates` runs at all, so the ordering does not
cover that entry.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6, and 2000 seeds at depth 4 over all fifteen cases. Drawn
widgets, widget draws and primitive writes are unchanged on every rig phase;
`many` pays 51 queue pops for 27 and 1059 depth reads for 410, which is the
deferring and nothing else.
2026-09-17 04:29:41 -04:00
iris-ai c8beca5753 Give the text example's aligned labels the width to align in
All three sat in the middle of a box the width of the widest of them, so
left, centred and right were the same picture. `text_align` puts the glyphs
somewhere in the box the text is given, and a text that reports the width of
its own glyphs is given exactly that -- there is nowhere for it to sit.
Declaring `rel(1.0)` on each hands it the row instead. (Bryan, 2026-09-17.)
2026-09-17 03:21:17 -04:00
iris-ai 4bd8607968 Report the step at or above a text's longest line
A wrapping text reported the width it used rounded to the nearest step,
which is under the line it measured half the time. A parent that sizes
itself from that report then hands the text back a box its own longest line
does not fit in, and breaking there is a different break -- one line more.

Two tolerances were hiding it and both go. `TextBuffer::shape` answered a
width up to 0.05 px under the longest line from the break in hand, which is
a structural decision taken on a hair's breadth: it kept a warm tree
self-consistent while a cold tree at the same width broke differently, and
0.05 px is fifty steps of the grid. The `Holds` range the text declares
started at the nearest step to its longest line for the same reason, so it
admitted boxes the line does not fit in. Both are the line itself now,
exactly, because the report no longer lands under it.

Found by seeds 1121 and 1839 at depth 4, which fail on `ea6dbae` and every
commit before it: a defect older than anything on this branch, reached by
running 2000 seeds at a depth the long runs do not use. Shrunk to the eight
widgets `a_text_is_given_back_a_box_the_line_it_measured_fits_in` builds.
2000 seeds at depth 4 over all fifteen cases are clean now, as are the
three long runs.

`text` is the one reference render that moves: its lower paragraph shifts a
pixel, the box being a step wider and its left edge crossing a snap
boundary. Same words, same lines, same breaks; `tabs`, `view`, `minimal`
and `random` are byte-identical.
2026-09-17 03:18:53 -04:00
iris-ai ffd79f32d3 Read a child's report as a fraction of the containing widget
`rel(0.5)` is half the span whatever else is in it and wherever the child
sits among them (Bryan, 2026-09-17). It was half of what the span had left
at the point it asked, because a report came back composed through the box
it was offered and a span offers each child the room from its cursor -- so
a nested span taking half of what it was given took a quarter of a row
whose first half was already spoken for, where the same half written as a
rule on the child took half the row.

The offer stays the remainder: a text has to wrap at the width actually
there, and `a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row`
pins that. What separates from it is the base a report's fractions are of,
which the ask now carries. It is the box the child was given wherever that
box is the child's whole area -- a pad's inset, a stack child, a scroll's
content -- and a span passes its own extent along the row.

`widget_decided` becomes `widget_at`, which says both things about an ask
rather than one of them; `widget_within` is still the sugar for neither.

Two spans asking for half each now take the whole row between them and a
third overflows, which the rewritten
`a_span_reads_a_child_report_as_a_fraction_of_the_row` states outright.
The five reference renders are byte-identical at 1920x1200 and `random`
live-resized still matches a cold render, so nothing that exists reports a
fraction to a span today.
2026-09-17 02:56:51 -04:00
iris-ai 0e0d4af326 Refuse a retained answer while something the widget measured is dirty
`draw_inner` took an answer from `try_reuse`, which checks only whether the
widget itself is marked, where `retained_answer` beside it also refused one
while anything the widget read a size from was dirty. A widget whose drawing
happened to be reusable therefore handed back the answer it gave before that
descendant changed.

Nothing puts that right afterwards. The comparison that tells a reader its
child's answer moved is in `redraw`, and a widget settled inside its parent's
own draw never goes through it -- the placing ask redraws the subtree, the
descendant's mark is cleared there, and the parent keeps a number the tree no
longer agrees with. So the check is not the optimization its comment claimed;
it is what makes the answer an answer, and both retained routes are answers,
so it is asked once in `draw_inner` rather than by one of them.

Found by the generated oracle at seed 564, depth 6, `shuffle-every-other`,
while reading a child's report as a fraction of the containing widget: that
reading lets a span overflow itself, which makes the two asks' boxes differ
far enough for the placing one to redraw.

Twenty-five rig work counters are unchanged on `cold`, `repaint`, `scroll`,
`resize` and `size`; `many` makes 18 fewer reuse attempts, 17 of which
already reported "dirty". Both long fuzzers green.
2026-09-17 02:46:25 -04:00
11 changed files with 355 additions and 130 deletions

No files matched your search

+37
View File
@@ -109,6 +109,18 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
}) })
} }
/// The first step at or above `v`, where [`Self::from_f32`] takes the
/// nearest one and is below it half the time. For a bound that has to
/// admit the value it came from: a measurement rounded down is a bound
/// that leaves out the thing it was measured from.
pub const fn ceil_from_f32(v: f32) -> Self {
let nearest = Self::from_f32(v);
match nearest.to_f32() < v {
true => nearest.next_up(),
false => nearest,
}
}
/// From a number as it is written in source -- `16`, `1.5` -- which is /// From a number as it is written in source -- `16`, `1.5` -- which is
/// the other place a value enters the grid. /// the other place a value enters the grid.
pub fn from_num(v: impl UiNum) -> Self { pub fn from_num(v: impl UiNum) -> Self {
@@ -372,6 +384,12 @@ impl<const SHIFT: u32> FixedVec2<SHIFT> {
Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y)) Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y))
} }
/// The first step at or above each part, for a measurement reported as a
/// box: what it occupies is not less than what was measured.
pub fn ceil_from_f32(v: Vec2) -> Self {
Self::new(Fixed::ceil_from_f32(v.x), Fixed::ceil_from_f32(v.y))
}
pub fn to_f32(self) -> Vec2 { pub fn to_f32(self) -> Vec2 {
Vec2::new(self.x.to_f32(), self.y.to_f32()) Vec2::new(self.x.to_f32(), self.y.to_f32())
} }
@@ -488,6 +506,25 @@ mod tests {
assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200)); assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200));
} }
/// The bound a greedy line break needs: the width it was measured at is
/// not on the grid, and the narrowest box the break still holds for is
/// the step at or above it, never the one below.
#[test]
fn a_ceiling_never_lands_below_the_number_it_came_from() {
let step = 1.0 / (1 << PX_SHIFT) as f32;
for n in 0..64 {
let v = 189.0 + n as f32 * step / 3.0;
let up = Px::ceil_from_f32(v);
assert!(up.to_f32() >= v, "{up:?} is below {v}");
assert!(
up.to_f32() - v < step,
"{up:?} is more than a step above {v}"
);
}
// An exact step is its own ceiling.
assert_eq!(Px::ceil_from_f32(189.5), Px::from_f32(189.5));
}
#[test] #[test]
fn a_number_from_outside_is_clamped_to_the_grid() { fn a_number_from_outside_is_clamped_to_the_grid() {
assert_eq!(Px::from_f32(1e12), Px::MAX); assert_eq!(Px::from_f32(1e12), Px::MAX);
+9 -12
View File
@@ -107,13 +107,6 @@ impl Default for TextAttrs {
} }
} }
/// How far below the longest line a width may fall and still be answered by
/// the break in hand. A parent that offers a child the length it reported
/// composes that length back through the box chain, so the two differ in the
/// last bits -- and at exactly the longest line, that decides whether a line
/// fits. Sub-pixel, so no break it admits is one a reader could see.
const BREAK_EPSILON_PX: f32 = 0.05;
/// Keeps text and its corresponding layout from getting out of sync. /// Keeps text and its corresponding layout from getting out of sync.
pub struct TextBuffer { pub struct TextBuffer {
text: String, text: String,
@@ -200,15 +193,19 @@ impl TextBuffer {
// A greedy break at one width is the same break at every width down // A greedy break at one width is the same break at every width down
// to the longest line it produced: each line still fits, and none can // to the longest line it produced: each line still fits, and none can
// take a word that would not fit in the wider box. So the layout in // take a word that would not fit in the wider box. So the layout in
// hand already answers, and re-breaking would only be a chance to // hand already answers, and re-breaking would only be work.
// disagree with itself -- which is what happens when a parent offers //
// a child the length that child just reported, and the two land // At the longest line exactly, with no margin below it. A narrower
// either side of a float. // width really does break differently, so answering one from the
// break in hand is how a warm tree keeps lines a cold tree would
// never produce. The margin was here because a text reports the
// width it used and a parent hands that back; the report is the step
// at or above its longest line now, so what comes back fits.
if let Some(key) = &self.layout_key if let Some(key) = &self.layout_key
&& key.attrs == *attrs && key.attrs == *attrs
&& let (Some(broke_at), Some(want)) = (key.max_width, width) && let (Some(broke_at), Some(want)) = (key.max_width, width)
&& want <= broke_at && want <= broke_at
&& want + BREAK_EPSILON_PX >= self.layout.width() && want >= self.layout.width()
{ {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits); diag::bump(Counter::TextShapeHits);
+44 -38
View File
@@ -139,38 +139,49 @@ impl<'a> Painter<'a> {
id: &'s StrongWidget<W>, id: &'s StrongWidget<W>,
region: UiRegion, region: UiRegion,
) -> DrawResult<'s, 'a, W> { ) -> DrawResult<'s, 'a, W> {
self.widget_at(id, region, [false; 2]) self.widget_at(id, region, region.size(), [false; 2])
} }
/// Draws a widget in a box this widget chose from the widget's own /// Draws a widget in `region`, saying what the answer means.
/// answer along the `decided` axes. On those the answer is not placed ///
/// inside the box again: it already is the box, and a fraction the /// `reports_of` is what a fraction the child reports is a fraction of, as
/// widget reported of its offer, taken of this box a second time, would /// lengths of this widget's own box. It is the box the child was given
/// shrink it twice. A container uses this where it hands back exactly /// wherever that box is the child's whole area -- a pad's inset, a stack
/// child, a scroll's content -- and a span passes its own extent along
/// the row instead: it offers each child the room left from its cursor,
/// because a text has to wrap at the width actually there, while
/// `rel(0.5)` still means half the span wherever the child sits in it.
///
/// A `decided` axis is one where this box was chosen from the widget's
/// own answer. On those the answer is not placed inside the box again: it
/// already is the box, and a fraction taken of it a second time would
/// shrink it twice. A container uses that where it hands back exactly
/// what a child asked for -- a span placing a child at the length it /// what a child asked for -- a span placing a child at the length it
/// reported, a scroll giving its content the content's own length. /// reported, a scroll giving its content the content's own length.
pub fn widget_decided<'s, W: ?Sized>( pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
decided: [bool; 2],
) -> DrawResult<'s, 'a, W> {
self.widget_at(id, region, decided)
}
fn widget_at<'s, W: ?Sized>(
&'s mut self, &'s mut self,
id: &'s StrongWidget<W>, id: &'s StrongWidget<W>,
region: UiRegion, region: UiRegion,
reports_of: UiVec2,
decided: [bool; 2], decided: [bool; 2],
) -> DrawResult<'s, 'a, W> { ) -> DrawResult<'s, 'a, W> {
let region_node = self.rsc.widgets().is_region_node(id.id()); let region_node = self.rsc.widgets().is_region_node(id.id());
let declared = self.declared_lens(id); let declared = self.declared_lens(id);
let align = self.rsc.widgets().alignment(id.id()); let align = self.rsc.widgets().alignment(id.id());
// A rule this box was already chosen from is not resolved into it a
// second time. The box is that rule's length already, so resolving
// it again takes the fraction twice -- a widget declaring half of a
// stack, in the stack its own answer made half a row, is a quarter
// of the row. Pixels survive it, being the same length wherever they
// are taken from, which is why only a share ever shrank.
let resolve = AXES.map(|axis| match decided[axis as usize] {
true => None,
false => declared[axis as usize],
});
// Composing `FULL` through a box is not quite the identity in f32, // Composing `FULL` through a box is not quite the identity in f32,
// so a child with nothing declared keeps the box it would have had. // so a child with nothing declared keeps the box it would have had.
let local = match declared.iter().any(Option::is_some) { let local = match resolve.iter().any(Option::is_some) {
true => declared_box(region, declared, align), true => declared_box(region, resolve, align),
false => region, false => region,
}; };
let within = match local == UiRegion::FULL { let within = match local == UiRegion::FULL {
@@ -234,7 +245,7 @@ impl<'a> Painter<'a> {
DrawResult { DrawResult {
child: id, child: id,
painter: self, painter: self,
size: in_parent_frame(size, local, declared), size: in_parent_frame(size, reports_of, declared),
} }
} }
@@ -268,12 +279,14 @@ impl<'a> Painter<'a> {
/// A child's length in the box it is about to be offered, if it can be /// A child's length in the box it is about to be offered, if it can be
/// had without drawing it: from its hint, or from a drawing it already /// had without drawing it: from its hint, or from a drawing it already
/// has that holds for that box. /// has that holds for that box. `reports_of` is what a fraction in the
/// answer is a fraction of, as it is for [`Self::widget_at`].
pub fn known_len<W: ?Sized>( pub fn known_len<W: ?Sized>(
&mut self, &mut self,
child: &StrongWidget<W>, child: &StrongWidget<W>,
axis: Axis, axis: Axis,
region: UiRegion, region: UiRegion,
reports_of: UiVec2,
) -> Option<LayoutLen> { ) -> Option<LayoutLen> {
let declared = self.declared_lens(child); let declared = self.declared_lens(child);
let align = self.rsc.widgets().alignment(child.id()); let align = self.rsc.widgets().alignment(child.id());
@@ -299,7 +312,7 @@ impl<'a> Painter<'a> {
for (axis, under) in AXES.into_iter().zip(self.under.iter_mut()) { for (axis, under) in AXES.into_iter().zip(self.under.iter_mut()) {
*under = under.and(holds[axis as usize].through(local.axis(axis).len())); *under = under.and(holds[axis as usize].through(local.axis(axis).len()));
} }
Some(in_parent_frame(size, local, declared).axis(axis)) Some(in_parent_frame(size, reports_of, declared).axis(axis))
} }
/// Whether this is the first box a child is asked about in during a draw /// Whether this is the first box a child is asked about in during a draw
@@ -390,14 +403,6 @@ impl<'a> Painter<'a> {
.is_some() .is_some()
} }
/// The part of this widget's box that something of `size` takes, at the
/// near edge. A container that reports one child's size gives every child
/// this, so what it draws is inside what it says it occupies.
pub fn box_of(&self, size: Size) -> UiRegion {
let lens = placed_lens(size, [None; 2], [false; 2]);
placed_box(UiRegion::FULL, lens, RegionAlign::NEAR)
}
/// This widget's box in pixels. Reading it makes the drawing one that /// This widget's box in pixels. Reading it makes the drawing one that
/// holds for this box only, until `holds` says how far it goes. /// holds for this box only, until `holds` says how far it goes.
pub fn px_size(&mut self) -> PxVec2 { pub fn px_size(&mut self) -> PxVec2 {
@@ -518,18 +523,19 @@ impl PrimitiveLike for &TextureHandle {
} }
} }
/// A child's answer as lengths of the box it was asked from. A widget reports /// A child's answer as lengths of the parent's own box. A widget reports a
/// a fraction of the box it was given, and the widget that gave it wants the /// fraction, and `reports_of` is the length that fraction is of: the box the
/// same length as a fraction of its own: one composition apart wherever the /// child was given wherever that is the child's whole area, and the parent's
/// offer was not the whole of the parent's extent, as a span's is after a /// own extent wherever the box is a positional remainder, as a span's is
/// relative child. A declared axis is already the parent's: it resolved the /// after an earlier child. Pixels come through untouched either way, being
/// rule in its own box, and the rule is what the report says. /// that many pixels wherever they end up. A declared axis is already the
fn in_parent_frame(size: Size, local: UiRegion, declared: [Option<LayoutLen>; 2]) -> Size { /// parent's: it resolved the rule in its own box, and the rule is what the
/// report says.
fn in_parent_frame(size: Size, reports_of: UiVec2, declared: [Option<LayoutLen>; 2]) -> Size {
let mut size = size; let mut size = size;
for (axis, declared) in AXES.into_iter().zip(declared) { for (axis, declared) in AXES.into_iter().zip(declared) {
if declared.is_none() { if declared.is_none() {
let len = local.axis(axis).len(); *size.axis_mut(axis) = size.axis(axis).within_len(reports_of.axis(axis));
*size.axis_mut(axis) = size.axis(axis).within_len(len);
} }
} }
size size
+63 -41
View File
@@ -32,7 +32,7 @@ pub(super) struct DrawInfo {
pub offered_px: PxVec2, pub offered_px: PxVec2,
/// The axes along which the parent chose this box from the widget's own /// The axes along which the parent chose this box from the widget's own
/// answer, so the answer is not placed inside it again. See /// answer, so the answer is not placed inside it again. See
/// [`Painter::widget_decided`]. /// [`Painter::widget_at`].
pub decided: [bool; 2], pub decided: [bool; 2],
} }
@@ -55,6 +55,9 @@ pub struct UiRenderState {
/// Whether this frame contains a declared-length change, so any dirty /// Whether this frame contains a declared-length change, so any dirty
/// dependent replaces its answer too. /// dependent replaces its answer too.
replace_answers: bool, replace_answers: bool,
/// Widgets waiting for an ancestor to draw them, so the walk down the
/// depths does not pick one up again at its own depth.
deferred: crate::util::HashSet<WidgetId>,
pub moves: Moves, pub moves: Moves,
} }
@@ -68,6 +71,7 @@ impl UiRenderState {
slots: Default::default(), slots: Default::default(),
answer_invalid: Default::default(), answer_invalid: Default::default(),
replace_answers: false, replace_answers: false,
deferred: Default::default(),
moves: Default::default(), moves: Default::default(),
resized: false, resized: false,
} }
@@ -192,14 +196,19 @@ impl UiRenderState {
diag::draw_request(id, info.parent, region, info.px, info.region_node); diag::draw_request(id, info.parent, region, info.px, info.region_node);
} }
let align = rsc.widgets().alignment(id); let align = rsc.widgets().alignment(id);
let replace_answer = self.answer_invalid.remove(&id) // Nothing this widget has is an answer while something it measured
|| (self.replace_answers // is dirty: settling that changes what it would report, and a widget
&& (rsc.widgets().needs_redraw.contains(&id) // settled inside its parent's draw tells nobody -- the comparison
|| self.dirty_size_under(id, rsc.widgets()))); // that marks a reader is in `redraw`, which is not what asked here.
let retained = match replace_answer { // Both retained routes are an answer, so the question is asked once
// rather than by each of them.
let stale =
rsc.widgets().needs_redraw.contains(&id) || self.dirty_size_under(id, rsc.widgets());
let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && stale);
let retained = match replace_answer || stale {
true => None, true => None,
false => self false => self
.retained_answer(id, info, rsc.widgets()) .retained_answer(id, info)
.or_else(|| self.try_reuse(id, region, info, rsc)), .or_else(|| self.try_reuse(id, region, info, rsc)),
}; };
let answer = retained.unwrap_or_else(|| { let answer = retained.unwrap_or_else(|| {
@@ -489,16 +498,9 @@ impl UiRenderState {
/// The answer to an ask can be retained independently of where its /// The answer to an ask can be retained independently of where its
/// drawing ended up. Alignment is exactly that case: the first box is the /// drawing ended up. Alignment is exactly that case: the first box is the
/// question and the smaller placed box holds the drawing. /// question and the smaller placed box holds the drawing. Whether the
fn retained_answer( /// answer is stale at all is its caller's question, asked once there.
&self, fn retained_answer(&self, id: WidgetId, info: DrawInfo) -> Option<(Size, [Holds; 2])> {
id: WidgetId,
info: DrawInfo,
widgets: &Widgets,
) -> Option<(Size, [Holds; 2])> {
if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) {
return None;
}
let active = self.active.get(&id)?; let active = self.active.get(&id)?;
let has_region_node = active.move_idx != active.parent_move; let has_region_node = active.move_idx != active.parent_move;
if !active.drawn if !active.drawn
@@ -512,9 +514,11 @@ impl UiRenderState {
} }
/// Whether anything whose size this widget's own size was read from is /// Whether anything whose size this widget's own size was read from is
/// dirty. Not needed for the answer to come right -- a changed size /// dirty, which makes what it would answer not yet known. It also keeps
/// reaches its reader in any order -- but a reader that asks first /// a reader that asks first from laying out twice, which is all it was
/// lays out once rather than twice. /// here for while a changed size was thought to reach its reader in any
/// order; it does not, where the change settles inside the reader's own
/// draw.
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool { fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| { self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| { active.size_deps.iter().any(|child| {
@@ -827,18 +831,33 @@ impl UiRenderState {
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) { pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::IncrementalLayout); let _layout = diag::timer(TimerKind::IncrementalLayout);
// Deepest first: a reader whose children have all settled asks each // Deepest first, and strictly: a widget that cannot settle where it
// once, where any other order has it lay out again for whatever // is defers to its parent rather than drawing the parent from
// settles under it afterwards. Equal-depth widgets are independent, // inside itself. It marks the parent, stays marked, and waits here
// so their order does not matter. // until the walk reaches its parent's depth.
while let Some(id) = { //
let dirty = rsc.widgets().needs_redraw.iter().copied(); // What that buys is that nothing shallower is ever drawn while
dirty.max_by_key(|&id| self.depth(id)) // anything deeper is still dirty. A parent drawing can therefore
} { // trust every answer it reads without descending to check whether
// something below is about to change it -- which is the whole class
// of defect where a widget settles inside its parent's draw, clears
// its mark there, and tells nobody its answer moved.
loop {
let next = rsc
.widgets()
.needs_redraw
.iter()
.copied()
.filter(|id| !self.deferred.contains(id))
.max_by_key(|&id| self.depth(id));
let Some(id) = next else { break };
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::QueuePops); diag::bump(Counter::QueuePops);
self.redraw(id, rsc); if !self.redraw(id, rsc) {
self.deferred.insert(id);
}
} }
self.deferred.clear();
} }
fn depth(&self, id: WidgetId) -> usize { fn depth(&self, id: WidgetId) -> usize {
@@ -923,11 +942,13 @@ impl UiRenderState {
} }
/// Settles a dirty widget: asks it again where its parent asked, and /// Settles a dirty widget: asks it again where its parent asked, and
/// tells the parent if the answer changed. /// tells the parent if the answer changed. `false` where the question is
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { /// its parent's rather than its own, which leaves it marked for the
/// parent to draw when the walk reaches that depth.
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> bool {
rsc.widgets_mut().needs_redraw.remove(&id); rsc.widgets_mut().needs_redraw.remove(&id);
let Some(active) = self.active.get(&id) else { let Some(active) = self.active.get(&id) else {
return; return true;
}; };
// Its parent resolved its declared lengths into its box and decided // Its parent resolved its declared lengths into its box and decided
// whether to draw it at all, so a change to either is the parent's // whether to draw it at all, so a change to either is the parent's
@@ -947,14 +968,15 @@ impl UiRenderState {
at = self.active[&next].parent; at = self.active[&next].parent;
} }
} }
// Both stay marked: the parent because it has this to draw, and
// this because the parent must draw it rather than keep what it
// has. The mark comes off in `draw_at`, where the parent draws.
rsc.widgets_mut().needs_redraw.insert(id); rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc); rsc.widgets_mut().needs_redraw.insert(parent);
// Whatever the parent did not draw again is nothing it holds now. return false;
rsc.widgets_mut().needs_redraw.remove(&id);
return;
} }
if !active.drawn { if !active.drawn {
return; return true;
} }
// Nothing above the root resolved its rules or its alignment, so its // Nothing above the root resolved its rules or its alignment, so its
// box is its own to work out again against the output. Every other // box is its own to work out again against the output. Every other
@@ -969,7 +991,7 @@ impl UiRenderState {
diag::bump(Counter::LocalRedraws); diag::bump(Counter::LocalRedraws);
let old = self.remove(id, false, rsc); let old = self.remove(id, false, rsc);
self.draw_inner(id, region, info, old, rsc); self.draw_inner(id, region, info, old, rsc);
return; return true;
}; };
let (given_px, offered_px) = self.asked_px(id); let (given_px, offered_px) = self.asked_px(id);
// Asked again in the box its parent gave it, which is the question // Asked again in the box its parent gave it, which is the question
@@ -979,9 +1001,8 @@ impl UiRenderState {
// on is its lengths, so the same lengths elsewhere is one question. // on is its lengths, so the same lengths elsewhere is one question.
if given_px != offered_px { if given_px != offered_px {
rsc.widgets_mut().needs_redraw.insert(id); rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc); rsc.widgets_mut().needs_redraw.insert(parent);
rsc.widgets_mut().needs_redraw.remove(&id); return false;
return;
} }
let info = DrawInfo { let info = DrawInfo {
layer: active.layer, layer: active.layer,
@@ -1014,6 +1035,7 @@ impl UiRenderState {
} }
rsc.widgets_mut().needs_redraw.insert(parent); rsc.widgets_mut().needs_redraw.insert(parent);
} }
true
} }
} }
+8 -3
View File
@@ -26,12 +26,17 @@ impl DefaultAppState for State {
.wrap(true) .wrap(true)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.pad(16) .pad(16)
.width(rel(1.0))
.background(panel()); .background(panel());
// Each one takes the whole width, because `text_align` puts the
// glyphs somewhere in the box the text is given and a text that
// reports the width of its own glyphs is given exactly that.
let label = |text: &str, align| wtext(text).size(24).text_align(align).width(rel(1.0));
let aligned = ( let aligned = (
wtext("left").size(24).text_align(Align::LEFT), label("left", Align::LEFT),
wtext("centred").size(24).text_align(Align::CENTER), label("centred", Align::H_CENTER),
wtext("right").size(24).text_align(Align::RIGHT), label("right", Align::RIGHT),
) )
.span(Dir::DOWN) .span(Dir::DOWN)
.gap(8) .gap(8)
+3 -2
View File
@@ -14,7 +14,8 @@ impl Widget for Scroll {
let container_len = painter.px_len(self.axis); let container_len = painter.px_len(self.axis);
// Draw in the whole container only when its scrolling-axis length is // Draw in the whole container only when its scrolling-axis length is
// not already known, then draw it at the scrolled offset. // not already known, then draw it at the scrolled offset.
let answer_len = match painter.known_len(&self.inner, self.axis, UiRegion::FULL) { let whole = UiRegion::FULL;
let answer_len = match painter.known_len(&self.inner, self.axis, whole, whole.size()) {
Some(len) => len, Some(len) => len,
None => painter.widget(&self.inner).size().axis(self.axis), None => painter.widget(&self.inner).size().axis(self.axis),
}; };
@@ -63,7 +64,7 @@ impl Widget for Scroll {
region = region.offset(offset); region = region.offset(offset);
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
} }
painter.widget_decided(&self.inner, region, [true; 2]); painter.widget_at(&self.inner, region, region.size(), [true; 2]);
// What it occupies is its box, on both axes: it clips its content to // What it occupies is its box, on both axes: it clips its content to
// that box, so it can neither take less of one nor honestly ask for // that box, so it can neither take less of one nor honestly ask for
// more. The content's length is what it scrolls through, not what it // more. The content's length is what it scrolls through, not what it
+14 -3
View File
@@ -20,9 +20,15 @@ impl Widget for Span {
span.flip(); span.flip();
} }
let region = UiRegion::from_axis(axis, span, UiSpan::FULL); let region = UiRegion::from_axis(axis, span, UiSpan::FULL);
let len = match painter.known_len(child, axis, region) { // Offered the room left from the cursor, because a text has to
// wrap at the width actually there, but reporting a fraction of
// the whole row: `rel(0.5)` is half the span whatever else is in
// it and wherever this child sits among them.
let len = match painter.known_len(child, axis, region, UiVec2::FULL_SIZE) {
Some(len) => len, Some(len) => len,
None => painter.widget_within(child, region).len(axis), None => painter
.widget_at(child, region, UiVec2::FULL_SIZE, [false; 2])
.len(axis),
}; };
cursor.px += len.px + self.gap; cursor.px += len.px + self.gap;
cursor.rel += len.rel; cursor.rel += len.rel;
@@ -122,7 +128,12 @@ impl Widget for Span {
// Along the row this box is the child's own answer, so the answer // Along the row this box is the child's own answer, so the answer
// is not placed in it again; across it the child sits where its // is not placed in it again; across it the child sits where its
// alignment says. // alignment says.
let placed = painter.widget_decided(child, region, [axis == Axis::X, axis == Axis::Y]); let placed = painter.widget_at(
child,
region,
UiVec2::FULL_SIZE,
[axis == Axis::X, axis == Axis::Y],
);
if shrinks { if shrinks {
let used = placed.len(!axis); let used = placed.len(!axis);
// Choosing between a fixed and a relative length from the // Choosing between a fixed and a relative length from the
+28 -17
View File
@@ -13,31 +13,42 @@ impl Widget for Stack {
StackSize::Default => None, StackSize::Default => None,
StackSize::Child(i) => Some(i), StackSize::Child(i) => Some(i),
}; };
// Whichever child sizes the stack decides the box every child gets. // Every child gets the whole of this stack's box, the sizing one
// The stack reports that size, so a child given a longer box would // included, and the stack is then handed a box of the length that
// draw outside what the stack says it occupies. // child asked for. Not the part of the box that length takes: the
// stack's own box becomes that length, and taking the fraction of it
// again is the fraction twice -- a child asking for half of a stack
// that is already half a row would have a quarter of the row.
//
// It cannot be told apart by asking whether this box is the answer
// yet, either. A drawing has to be a function of the box alone, since
// moving the stack into the box it asked for reuses the drawing by
// scaling it, and a drawing made a fraction of one box is right in
// any other. So: fractions of this box throughout, and the move is
// the whole of the difference.
let region = UiRegion::FULL;
// Whichever child sizes the stack is asked here and not again below,
// on the layer it ends up on: a retained drawing belongs to the layer
// it was made on, so measuring it anywhere else costs a second
// drawing of it. Its box is its own answer, so the answer is not
// placed inside it again.
let size = match sizing.and_then(|i| self.children.get(i).map(|c| (i, c))) { let size = match sizing.and_then(|i| self.children.get(i).map(|c| (i, c))) {
// On the layer that child ends up on, so the ask below is a reuse
// rather than a second drawing of it somewhere else: a retained
// drawing belongs to the layer it was made on.
Some((i, child)) => { Some((i, child)) => {
painter.child_layer_at(i); painter.child_layer_at(i);
painter.widget(child).size() painter
.widget_at(child, region, region.size(), [true; 2])
.size()
} }
None => Size::LEFTOVER, None => Size::LEFTOVER,
}; };
let region = painter.box_of(size);
for (i, child) in self.children.iter().enumerate() { for (i, child) in self.children.iter().enumerate() {
if sizing == Some(i) {
continue;
}
painter.child_layer_at(i); painter.child_layer_at(i);
// The sizing child placed its own content in the box its answer // A box that owes nothing to this child's own answer: where it
// decided, and this box was derived from that answer, so applying // sits in one bigger than itself is its own business.
// its alignment again here would place it twice. Every other painter.widget_within(child, region);
// child is handed a box that owes nothing to its own answer, and
// where it sits in one bigger than itself is its own business.
match sizing == Some(i) {
true => painter.widget_decided(child, region, [true; 2]),
false => painter.widget_within(child, region),
};
} }
size size
} }
+12 -2
View File
@@ -55,8 +55,13 @@ impl TextView {
// line up to the one it was made at: each line still fits, and none // line up to the one it was made at: each line still fits, and none
// could take a word that did not fit in the wider box. A line too // could take a word that did not fit in the wider box. A line too
// long to fit at all says nothing about narrower boxes. // long to fit at all says nothing about narrower boxes.
//
// The step at or above that longest line rather than the nearest
// one, since the shaper measures in floats: the nearest step is
// under the line half the time, and a range starting there admits a
// box the line does not fit in, where the break is not this one.
if let Some(width) = width { if let Some(width) = width {
painter.holds(Axis::X, Px::from_f32(text.size.x).min(width)..=width); painter.holds(Axis::X, Px::ceil_from_f32(text.size.x).min(width)..=width);
} }
text text
} }
@@ -78,7 +83,12 @@ impl TextView {
let tex = self.render(painter); let tex = self.render(painter);
let region = tex.size.align(align); let region = tex.size.align(align);
let size = Size::px(tex.size); // The step at or above what the shaper measured, so a parent that
// hands back the length this reports hands back a box the longest
// line fits in. Rounded to the nearest step it is half the time a
// hair under that line, and the break made in it is not the break a
// cold layout makes there.
let size = Size::from_px(PxVec2::ceil_from_f32(tex.size));
let within = region.within(&painter.region()); let within = region.within(&painter.region());
painter.glyphs(tex, within); painter.glyphs(tex, within);
(region, size) (region, size)
+76 -11
View File
@@ -20,13 +20,13 @@ fn a_span_gives_each_child_the_width_it_asked_for() {
assert_corners!(h, right, (100, 0), (400, 200)); assert_corners!(h, right, (100, 0), (400, 200));
} }
/// A drawn child reports a fraction of the box it was given, and a span /// A span offers each child the room left after the one before, because a
/// offers each child what is left after the one before. So a nested span /// text has to wrap at the width actually there, but reads what the child
/// that takes half of the half it was offered has taken a quarter of the row, /// reports as a fraction of the whole row. So two children asking for half
/// and what follows starts three quarters along -- not at the end, which is /// each take the whole row between them, however much of it was left when
/// where adding its report straight into the cursor put it. /// each was asked, and a third overflows.
#[test] #[test]
fn a_span_reads_a_child_report_as_a_fraction_of_what_it_offered() { fn a_span_reads_a_child_report_as_a_fraction_of_the_row() {
let mut h = Harness::new((400, 100)); let mut h = Harness::new((400, 100));
let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc); let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc);
let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut h.rsc); let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut h.rsc);
@@ -35,11 +35,76 @@ fn a_span_reads_a_child_report_as_a_fraction_of_what_it_offered() {
h.set_root((half, nested, tail).span(Dir::RIGHT).width(rel(1.0))); h.set_root((half, nested, tail).span(Dir::RIGHT).width(rel(1.0)));
// The nested span is placed at the length it reported and drawn there // The nested span is placed at the length it reported and drawn there
// once more; half of that final box is what its child takes, packed at // once more; half of that final box is what its own child takes.
// the nested span's own start. assert_corners!(h, nested, (200, 0), (400, 100));
assert_corners!(h, nested, (200, 0), (300, 100)); assert_corners!(h, inner, (200, 0), (300, 100));
assert_corners!(h, inner, (200, 0), (250, 100)); assert_corners!(h, tail, (400, 0), (500, 100));
assert_corners!(h, tail, (300, 0), (400, 100)); }
/// The same fraction either way round: after a 100 px child in a 400 px row,
/// `rel(0.5)` is 100 to 300 whether the child's own rule says so or the child
/// drew half of what it was offered and reported that. Half the row, not half
/// of the 300 px left of it.
#[test]
fn a_reported_fraction_is_of_the_row_like_a_declared_one() {
let mut declaring = Harness::new((400, 100));
let head = rect(Color::RED).width(100).add(&mut declaring.rsc);
let declared = rect(Color::GREEN).width(rel(0.5)).add(&mut declaring.rsc);
declaring.set_root((head, declared).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(declaring, declared, (100, 0), (300, 100));
let mut reporting = Harness::new((400, 100));
let head = rect(Color::RED).width(100).add(&mut reporting.rsc);
let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut reporting.rsc);
let reported = (inner,).span(Dir::RIGHT).add(&mut reporting.rsc);
reporting.set_root((head, reported).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(reporting, reported, (100, 0), (300, 100));
}
/// What the fraction a child reports is of and what box it is offered are
/// two different lengths, and only the first is the whole row: a text still
/// wraps at the room actually left after its neighbour, so the same
/// paragraph is taller where less of the row is left for it.
#[test]
fn a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row() {
let paragraph = "Wrapping shapes one source into as many lines as the box \
leaves room for, so a paragraph's height is an answer.";
let height_after = |head_width: i32| {
let mut h = Harness::new((400, 400));
let head = rect(Color::RED).width(head_width).add(&mut h.rsc);
let text = wtext(paragraph).size(16).wrap(true).add(&mut h.rsc);
h.set_root((head, text).span(Dir::RIGHT).width(rel(1.0)));
let region = h.region(&text).unwrap();
(region.bot_right.y - region.top_left.y).to_f32()
};
let (crowded, whole_row) = (height_after(300), height_after(0));
assert!(crowded > whole_row, "{crowded} against {whole_row}");
}
/// A stack takes its size from one child and gives every child that size, so
/// a child asking for half of it is asking for half of what it is itself the
/// size of. Once the stack has been placed at the length it reported that
/// length is the box, and taking the fraction of it again takes it twice:
/// half a row became a quarter, and a further stack around it a further half.
/// Nothing pinned it because a pixel is the same length wherever it is taken
/// from, so only a share ever shrank -- and warm and cold shrink alike, so no
/// oracle saw it either.
#[test]
fn a_stack_sized_by_a_child_does_not_take_that_childs_fraction_twice() {
let mut h = Harness::new((400, 200));
let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc);
let behind = rect(Color::BLUE).add(&mut h.rsc);
let stack = Stack {
children: vec![behind.add_strong(&mut h.rsc), half.add_strong(&mut h.rsc)],
size: StackSize::Child(1),
}
.add(&mut h.rsc);
h.set_root((stack,).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(h, stack, (0, 0), (200, 200));
assert_corners!(h, half, (0, 0), (200, 200));
assert_corners!(h, behind, (0, 0), (200, 200));
} }
/// The same reading through a pad: its inset is the whole box less the /// The same reading through a pad: its inset is the whole box less the
+61 -1
View File
@@ -7,7 +7,8 @@
//! neither: one box length, composed two ways, landing either side of the //! neither: one box length, composed two ways, landing either side of the
//! boundary that decided whether a child was drawn at all, and two boxes //! boundary that decided whether a child was drawn at all, and two boxes
//! reached through a region node's own entry rather than through the offer //! reached through a region node's own entry rather than through the offer
//! that node was given. //! that node was given. The last is a wrapping text handed back the width
//! it measured, rounded to a step below the line it measured there.
use iris::harness::Harness; use iris::harness::Harness;
use iris::prelude::*; use iris::prelude::*;
@@ -554,3 +555,62 @@ fn a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered() {
} }
assert!(wrong.is_empty(), "{}", wrong.join("\n")); assert!(wrong.is_empty(), "{}", wrong.join("\n"));
} }
const PARAGRAPH: &str = "Wrapping shapes one source into as many lines as the \
box leaves room for, so a paragraph's height is an answer and not a setting.";
/// Eight widgets, shrunk from a 118-widget tree (seed 1121, depth 4,
/// `shuffle-swap-for-three`). The stack takes its size from the span above,
/// the span takes its width from the longest line of the texts in it, and
/// the text below the span is then wrapped at that width -- so a width the
/// shaper measured comes back to it as the box to break in.
fn plant_a_measured_width(h: &mut Harness, swapped: bool) -> (WeakWidget<Span>, WidgetId) {
let first: StrongWidget = rect(Color::YELLOW).add_strong(&mut h.rsc);
let mut inner = Span::empty(Dir::UP);
inner.children = match swapped {
true => swapped_in(h),
false => vec![first],
};
let inner = inner.height(142).add(&mut h.rsc);
let text = wtext(PARAGRAPH).size(16).wrap(true).add(&mut h.rsc);
let stack = Stack {
children: vec![inner.add_strong(&mut h.rsc), text.add_strong(&mut h.rsc)],
size: StackSize::Child(0),
}
.add(&mut h.rsc);
h.set_root((stack,).span(Dir::DOWN).width(195));
(inner, text.id())
}
/// What the span holds once its children have been swapped, which is what
/// the warm tree is changed to and what the cold one is grown with.
fn swapped_in(h: &mut Harness) -> Vec<StrongWidget> {
let paragraph = |h: &mut Harness| -> StrongWidget {
wtext(PARAGRAPH).size(16).wrap(true).add_strong(&mut h.rsc)
};
vec![
paragraph(h),
rect(Color::YELLOW).add_strong(&mut h.rsc),
paragraph(h),
]
}
/// A text handed back the width it measured breaks there the way it broke
/// when it measured it. The width the shaper answers is not on the grid, and
/// a report rounded to the nearest step is under the longest line half the
/// time: a warm tree then keeps a break made in a wider box while a cold one
/// makes a narrower break in the same box, and the paragraph gains a line.
#[test]
fn a_text_is_given_back_a_box_the_line_it_measured_fits_in() {
let mut warm = Harness::new((900, 1200));
let (inner, text) = plant_a_measured_width(&mut warm, false);
warm.frame();
warm.rsc[inner].children = swapped_in(&mut warm);
warm.frame();
let mut cold = Harness::new((900, 1200));
let (_, cold_text) = plant_a_measured_width(&mut cold, true);
cold.frame();
assert_eq!(warm.region(&text), cold.region(&cold_text));
}