From 5b7800264d5eca65d0190a506a94f76a590e079e Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 23:00:15 -0400 Subject: [PATCH] Read a child's answer in the asker's frame, and drop the root move entry A widget reports a fraction of the box it was given. Span added that fraction straight into a cursor that counts fractions of the row, and Pad summed its padding onto it, both right only while the offer had the parent's whole extent -- which a span's does not after a relative child. DrawResult::size and known_len now compose the answer through the offer's length, so a container reads lengths of its own box. That exposed placed_box scaling a fractional answer against a box the parent had already chosen from it, halving a nested span twice. The near-edge alignment override becomes per-axis `decided` flags: a box the parent chose from the answer is the answer, and is not placed again. Span decides the row axis; Scroll and Stack's sizing child decide both. Alignment is always the widget's own property now. The window is no longer a move entry. Chains bottom out in MoveIdx::NONE and the window is applied where a fraction becomes pixels, in to_px on the CPU and by the uniform in the shader, which now snaps the summed coordinate since a floor does not distribute over a sum. A resize rewrites no entry. Verified: view, minimal, random, tabs and text render byte-identical at 1920x1200 against 5f16617, a live resize to 1280x800 is identical to a cold render, and the 100-seed oracle, all fifteen shrinker cases at 400 seeds of depth 5, and 1000 seeds of depth 6 pass. Co-Authored-By: Claude Fable 5.1 --- core/src/orientation/align.rs | 3 +- core/src/orientation/len.rs | 19 ++++++ core/src/orientation/pos.rs | 2 +- core/src/render/shader/prelude.wgsl | 12 ++-- core/src/ui/active.rs | 13 ++-- core/src/ui/painter.rs | 76 +++++++++++++++------ core/src/ui/render_state.rs | 101 +++++++++++----------------- src/widget/position/scroll.rs | 2 +- src/widget/position/span.rs | 5 +- src/widget/position/stack.rs | 2 +- tests/cases/layout.rs | 56 ++++++++++++--- 11 files changed, 182 insertions(+), 109 deletions(-) diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs index c611cde..1ce2dd6 100644 --- a/core/src/orientation/align.rs +++ b/core/src/orientation/align.rs @@ -84,8 +84,7 @@ pub struct RegionAlign { } impl RegionAlign { - /// Both axes at the near edge. What a container passes as an override for - /// a child it is going to position itself. + /// Both axes at the near edge: the start of a box in its own orientation. pub const NEAR: Self = Self { x: AxisAlign::NEG, y: AxisAlign::NEG, diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index fd58850..cd6059e 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -125,6 +125,13 @@ impl Size { Axis::Y => self.y, } } + + pub fn axis_mut(&mut self, axis: Axis) -> &mut LayoutLen { + match axis { + Axis::X => &mut self.x, + Axis::Y => &mut self.y, + } + } } impl LayoutLen { @@ -151,6 +158,18 @@ impl LayoutLen { Len::from_parts(self.rel.add(share), self.px) } + /// This length, given as a part of a box `len` long, as a part of the + /// box `len` is itself a part of. The share is untouched: it is a claim + /// on whoever divides the room, not a fraction of anything. + pub const fn within_len(self, len: Len) -> Self { + let part = Len::from_parts(self.rel, self.px).within_len(len); + Self { + px: part.px, + rel: part.rel, + leftover: self.leftover, + } + } + pub fn px(px: impl UiNum) -> Self { Self { px: Px::from_num(px), diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 6b7dd13..ca6be56 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -219,7 +219,7 @@ impl Len { } } - pub fn within_len(&self, len: Len) -> Self { + pub const fn within_len(&self, len: Len) -> Self { self.within(&UiSpan { start: Len::ZERO, end: len, diff --git a/core/src/render/shader/prelude.wgsl b/core/src/render/shader/prelude.wgsl index 30e80e0..0c51c5e 100644 --- a/core/src/render/shader/prelude.wgsl +++ b/core/src/render/shader/prelude.wgsl @@ -35,6 +35,10 @@ struct MoveOffset { // belongs to the pixel above it. Flooring the product instead drops a pixel // wherever a fraction divides a window exactly: a fifth of 1920 comes out of // `REL_STEP` as 383.99998, and five tabs each lose their last column. +// +// Taken over the whole coordinate, fraction and pixels summed, since a floor +// does not distribute over a sum: floored apart, a half of one and a half of +// the other lose the pixel the two together make. fn snap_floor(v: vec2) -> vec2 { return floor(v + PX_STEP * 0.5); } @@ -147,8 +151,8 @@ fn vs_main( let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel); let bot_right_px = vec2(r.x.end.px, r.y.end.px); - let top_left = snap_floor(top_left_rel * window.dim) + snap_floor(top_left_px); - let bot_right = snap_floor(bot_right_rel * window.dim) + snap_floor(bot_right_px); + let top_left = snap_floor(top_left_rel * window.dim + top_left_px); + let bot_right = snap_floor(bot_right_rel * window.dim + bot_right_px); let size = bot_right - top_left; let uv = vec2( @@ -179,8 +183,8 @@ fn masked(in: VertexOutput, color: vec4) -> vec4 { let br = vec2(m.x.end.rel, m.y.end.rel); let br_px = vec2(m.x.end.px, m.y.end.px); - let top_left = snap_floor(tl * window.dim) + snap_floor(tl_px); - let bot_right = snap_floor(br * window.dim) + snap_floor(br_px); + let top_left = snap_floor(tl * window.dim + tl_px); + let bot_right = snap_floor(br * window.dim + br_px); let pos = in.clip_position.xy; if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { return color * 0.0; diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 3f09791..0aec51f 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -41,14 +41,11 @@ pub struct ActiveData { /// A change to one moves a box this widget cannot fix by drawing again, /// and comparing them is what says so. pub declared: [Option; 2], - /// The alignment its parent asked it with. A local redraw repeats that - /// question, including an override chosen by a container. - pub align: RegionAlign, - /// Whether that alignment was the parent's override rather than the - /// widget's own property. - pub align_override: bool, - /// Its own alignment when it was last drawn. A change to the property is - /// found against this even when its parent overrode the alignment. + /// The axes along which its parent chose its box from its own answer, + /// so a local redraw asks the question its parent asked. + pub decided: [bool; 2], + /// Its alignment when it was last drawn, which a change to the property + /// is found against. pub own_align: RegionAlign, /// The movable region whose coordinates `region` uses. pub parent_move: MoveIdx, diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 4b8050e..a3ca7a9 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -135,30 +135,34 @@ impl<'a> Painter<'a> { id: &'s StrongWidget, region: UiRegion, ) -> DrawResult<'s, 'a, W> { - self.widget_at(id, region, None) + self.widget_at(id, region, [false; 2]) } - /// Draws a widget with an alignment chosen by its container rather than - /// the widget's property. Containers use this when the box they hand down - /// already expresses the size they report around the child. - pub fn widget_aligned<'s, W: ?Sized>( + /// Draws a widget in a box this widget chose from the widget's own + /// 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 + /// widget reported of its offer, taken of this box a second time, would + /// shrink it twice. A container uses this where it hands back exactly + /// 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. + pub fn widget_decided<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, region: UiRegion, - align: RegionAlign, + decided: [bool; 2], ) -> DrawResult<'s, 'a, W> { - self.widget_at(id, region, Some(align)) + self.widget_at(id, region, decided) } fn widget_at<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, region: UiRegion, - align_override: Option, + decided: [bool; 2], ) -> DrawResult<'s, 'a, W> { let region_node = self.rsc.widgets().is_region_node(id.id()); let declared = self.declared_lens(id); - let align = align_override.unwrap_or_else(|| self.rsc.widgets().alignment(id.id())); + let align = self.rsc.widgets().alignment(id.id()); // 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. let local = match declared.iter().any(Option::is_some) { @@ -200,7 +204,7 @@ impl<'a> Painter<'a> { offer, offered_px: self.px_within_offer(offer), slot_wide: self.slot_wide, - align: align_override, + decided, }, None, self.rsc, @@ -216,7 +220,7 @@ impl<'a> Painter<'a> { DrawResult { child: id, painter: self, - size, + size: in_parent_frame(size, local, declared), } } @@ -282,7 +286,7 @@ impl<'a> Painter<'a> { for (axis, under) in AXES.into_iter().zip(self.under.iter_mut()) { *under = under.and(holds[axis as usize].through(local.axis(axis).len())); } - Some(size.axis(axis)) + Some(in_parent_frame(size, local, declared).axis(axis)) } /// Whether this is the first box a child is asked about in during a draw @@ -393,7 +397,13 @@ impl<'a> Painter<'a> { /// 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 { - placed_box(UiRegion::FULL, size, RegionAlign::NEAR, [None; 2]) + placed_box( + UiRegion::FULL, + size, + RegionAlign::NEAR, + [None; 2], + [false; 2], + ) } /// This widget's box in pixels. Reading it makes the drawing one that @@ -517,6 +527,23 @@ impl PrimitiveLike for &TextureHandle { } } +/// A child's answer as lengths of the box it was asked from. A widget reports +/// a fraction of the box it was given, and the widget that gave it wants the +/// same length as a fraction of its own: one composition apart wherever the +/// offer was not the whole of the parent's extent, as a span's is after a +/// relative child. A declared axis is already the parent's: it resolved the +/// rule in its own box, and the rule is what the report says. +fn in_parent_frame(size: Size, local: UiRegion, declared: [Option; 2]) -> Size { + let mut size = size; + for (axis, declared) in AXES.into_iter().zip(declared) { + if declared.is_none() { + let len = local.axis(axis).len(); + *size.axis_mut(axis) = size.axis(axis).within_len(len); + } + } + size +} + /// What a widget declares a length of its box to be. `leftover` is not one: a /// share of what is left over is only a length to the widget dividing one, /// so it passes up in the size instead. @@ -537,12 +564,20 @@ pub(crate) fn declared_lens(widgets: &Widgets, id: WidgetId) -> [Option, decided: bool) -> bool { + reported.leftover != Weight::ZERO || declared.is_some() || decided +} + /// The box a drawing occupies: the size the widget reported, on the side of -/// the box it was asked in that its alignment says. An axis reported as a -/// share fills, because a share is a length only to whoever divides one, and -/// whoever did is the one that handed down this box. A declared axis is -/// left alone too: `declared_box` already placed it, in the parent's box, -/// and the rule's length is what the widget reports there. +/// the box it was asked in that its alignment says, on every axis that is +/// not simply filled. /// /// A reported fraction is a fraction of the box the widget drew in, where a /// declared one is a fraction of the box its parent handed down -- a span @@ -553,11 +588,12 @@ pub(crate) fn placed_box( size: Size, align: RegionAlign, declared: [Option; 2], + decided: [bool; 2], ) -> UiRegion { let mut placed = region; - for (axis, declared) in AXES.into_iter().zip(declared) { + for (axis, (declared, decided)) in AXES.into_iter().zip(declared.into_iter().zip(decided)) { let reported = size.axis(axis); - if reported.leftover != Weight::ZERO || declared.is_some() { + if fills(reported, declared, decided) { continue; } let span = placed.axis_mut(axis); diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index b968ab0..a7a63f2 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,10 +1,10 @@ #[cfg(feature = "layout-diagnostics")] use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind}; -use crate::ui::painter::{declared_box, declared_lens, placed_box}; +use crate::ui::painter::{declared_box, declared_lens, fills, placed_box}; use crate::{ ActiveData, Axis, DrawLayers, Holds, IdLike, LayoutLen, Len, MaskIdx, MoveIdx, Moves, Painter, - PixelRegion, Px, PxVec2, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiSpan, Weight, - WideRegion, WidgetId, Widgets, + PixelRegion, Px, PxVec2, Rel, Size, StrongWidget, UiRegion, UiRsc, UiSpan, Weight, WideRegion, + WidgetId, Widgets, util::{HashMap, Vec2}, }; @@ -27,9 +27,10 @@ pub(super) struct DrawInfo { /// The box `parent_move` composes to, on the fine grid, so a widget's own /// box is one step further and not a walk back up the chain. pub slot_wide: WideRegion, - /// A container's answer for where the widget sits. `None` uses the - /// widget's own property. - pub align: Option, + /// 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 + /// [`Painter::widget_decided`]. + pub decided: [bool; 2], } pub struct UiRenderState { @@ -38,8 +39,6 @@ pub struct UiRenderState { pub(super) output_size: PxVec2, old_root: Option, - /// The slot every chain bottoms out in, holding the output as a box. - root_move: MoveIdx, /// Whether the output has changed since the last update. A frame is /// owed for that whether or not anything has to be drawn again. resized: bool, @@ -67,35 +66,22 @@ impl UiRenderState { answer_invalid: Default::default(), replace_answers: false, moves: Default::default(), - root_move: MoveIdx::NONE, resized: false, } } - /// The window as a box, so a chain bottoms out in one rather than in a - /// multiplication applied after it. Composing through a box held in - /// pixels leaves everything below it in pixels, which is why nothing - /// downstream has to know the output's size to resolve a position. - fn write_root(&mut self) { - let region = UiRegion::new( - UiSpan::new(Len::ZERO, Len::from_parts(Rel::ZERO, self.output_size.x)), - UiSpan::new(Len::ZERO, Len::from_parts(Rel::ZERO, self.output_size.y)), - ); - match self.root_move == MoveIdx::NONE { - true => self.root_move = self.moves.push(MoveIdx::NONE, region), - false => self.moves.set(self.root_move, region), - } - } - /// The window, in whatever the platform measures it in, onto the grid - /// everything below it is decided on. + /// everything below it is decided on. No move entry holds it: a chain + /// bottoms out in `MoveIdx::NONE`, which is the window, and the window's + /// size is applied where a fraction becomes pixels -- here in `to_px`, + /// and in the shader by its uniform. A resize therefore rewrites no + /// retained entry at all. pub fn resize(&mut self, size: impl Into) { let size = PxVec2::from_f32(size.into()); if size == self.output_size { return; } self.output_size = size; - self.write_root(); self.resized = true; } @@ -104,13 +90,13 @@ impl UiRenderState { layer: 0, parent: None, depth: 1, - parent_move: self.root_move, + parent_move: MoveIdx::NONE, region_node: false, mask: MaskIdx::NONE, offer: UiRegion::FULL, offered_px: self.output_size, - slot_wide: self.moves.compose(self.root_move, UiRegion::FULL), - align: None, + slot_wide: WideRegion::of(UiRegion::FULL), + decided: [false; 2], } } @@ -167,8 +153,6 @@ impl UiRenderState { #[cfg(feature = "layout-diagnostics")] let _layout = diag::timer(TimerKind::FullLayout); self.clear(rsc); - // free all resources & cache - self.write_root(); if let Some(id) = root { let info = self.root_info(); let region = Self::root_region(id.id(), rsc.widgets()); @@ -203,8 +187,7 @@ impl UiRenderState { info.region_node, ); } - let own_align = rsc.widgets().alignment(id); - let align = info.align.unwrap_or(own_align); + let align = rsc.widgets().alignment(id); let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && (rsc.widgets().needs_redraw.contains(&id) @@ -219,19 +202,15 @@ impl UiRenderState { if old.is_none() { old = self.remove(id, false, rsc); } - self.draw_at(id, region, info, align, old.take(), rsc) + self.draw_at(id, region, info, old.take(), rsc) }); let declared = declared_lens(rsc.widgets(), id); - // A near-edge override means the caller already chose this box from - // the child's answer. Applying the answer again would compound the - // placement; it is also how the second, final ask terminates. - let placed = match info.align == Some(RegionAlign::NEAR) { - true => region, - false => placed_box(region, answer.0, align, declared), - }; + // The second, final ask is in a box chosen from the answer on both + // axes, which is also what makes it terminate. + let placed = placed_box(region, answer.0, align, declared, info.decided); let placed_info = DrawInfo { - align: Some(RegionAlign::NEAR), + decided: [true; 2], ..info }; // The symbolic box can be unchanged while its parent slot changed @@ -240,7 +219,7 @@ impl UiRenderState { #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::PlaceRedraws); let old = self.remove(id, false, rsc); - self.draw_at(id, placed, placed_info, RegionAlign::NEAR, old, rsc); + self.draw_at(id, placed, placed_info, old, rsc); } // The answer is only reusable while both parts of the operation are: @@ -251,11 +230,14 @@ impl UiRenderState { let mut settled = answer; for axis in AXES { let reported = answer.0.axis(axis); - let placed_len = - match reported.leftover != Weight::ZERO || declared[axis as usize].is_some() { - true => Len::FULL, - false => Len::from_parts(reported.rel, reported.px), - }; + let placed_len = match fills( + reported, + declared[axis as usize], + info.decided[axis as usize], + ) { + true => Len::FULL, + false => Len::from_parts(reported.rel, reported.px), + }; settled.1[axis as usize] = settled.1[axis as usize].and(drawing_holds[axis as usize].through(placed_len)); } @@ -263,9 +245,8 @@ impl UiRenderState { let active = self.active.get_mut(&id).unwrap(); active.offer = info.offer; active.answer = settled; - active.align = align; - active.align_override = info.align.is_some(); - active.own_align = own_align; + active.decided = info.decided; + active.own_align = align; active.depth = info.depth; settled } @@ -276,7 +257,6 @@ impl UiRenderState { id: WidgetId, region: UiRegion, info: DrawInfo, - align: RegionAlign, old: Option, rsc: &mut dyn UiRsc, ) -> (Size, [Holds; 2]) { @@ -417,7 +397,7 @@ impl UiRenderState { offer: UiRegion::FULL, offered_px: px, slot_wide, - align: None, + decided: [false; 2], }, rsc, ); @@ -441,8 +421,7 @@ impl UiRenderState { children, size_deps, declared: declared_lens(rsc.widgets(), id), - align, - align_override: info.align.is_some(), + decided: info.decided, own_align: rsc.widgets().alignment(id), move_idx, parent_move: info.parent_move, @@ -800,8 +779,7 @@ impl UiRenderState { size_deps: Vec::new(), move_idx: info.parent_move, declared: [None; 2], - align: RegionAlign::default(), - align_override: false, + decided: [false; 2], own_align: rsc.widgets().alignment(id), parent_move: info.parent_move, mask: info.mask, @@ -820,7 +798,6 @@ impl UiRenderState { self.answer_invalid.clear(); self.replace_answers = false; self.moves.clear(); - self.root_move = MoveIdx::NONE; self.layers.clear(); rsc.widgets_mut().needs_redraw.clear(); self.free(rsc); @@ -977,8 +954,8 @@ impl UiRenderState { self.px_region(active.parent_move, region), self.px_region(active.parent_move, asked_in), ); - let parent_must_place = - active.parent.is_some() && (!region_node || active.align_override) && !at_offer; + let decided = active.decided.contains(&true); + let parent_must_place = active.parent.is_some() && (!region_node || decided) && !at_offer; // An independently positioned region node can redraw at its offer // and move its slot to its own placement. Every other widget needs // its parent to reproduce a different final position. @@ -1000,7 +977,7 @@ impl UiRenderState { offer: active.offer, offered_px, slot_wide: self.moves.compose(active.parent_move, UiRegion::FULL), - align: active.align_override.then_some(active.align), + decided: active.decided, }; let (was_answer, was) = (active.answer, (active.size, active.holds)); #[cfg(feature = "layout-diagnostics")] @@ -1032,7 +1009,7 @@ impl UiRenderState { // the way it did for a region node under a `Stack` once the stack // stopped overriding every child's alignment. let placed_info = DrawInfo { - align: Some(RegionAlign::NEAR), + decided: [true; 2], ..info }; self.draw_inner(id, region, placed_info, None, rsc); diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 178fb2d..ff2baaa 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -63,7 +63,7 @@ impl Widget for Scroll { region = region.offset(offset); region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); } - painter.widget_aligned(&self.inner, region, RegionAlign::NEAR); + painter.widget_decided(&self.inner, region, [true; 2]); // 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 // more. The content's length is what it scrolls through, not what it diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 58d634a..0375470 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -119,7 +119,10 @@ impl Widget for Span { if self.dir.sign == Sign::Neg { region.flip(axis); } - let placed = painter.widget_within(child, region); + // 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 + // alignment says. + let placed = painter.widget_decided(child, region, [axis == Axis::X, axis == Axis::Y]); if shrinks { let used = placed.len(!axis); // Choosing between a fixed and a relative length from the diff --git a/src/widget/position/stack.rs b/src/widget/position/stack.rs index 8bc3a1c..723ebdc 100644 --- a/src/widget/position/stack.rs +++ b/src/widget/position/stack.rs @@ -35,7 +35,7 @@ impl Widget for Stack { // 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_aligned(child, region, RegionAlign::NEAR), + true => painter.widget_decided(child, region, [true; 2]), false => painter.widget_within(child, region), }; } diff --git a/tests/cases/layout.rs b/tests/cases/layout.rs index d5a579e..8af6d78 100644 --- a/tests/cases/layout.rs +++ b/tests/cases/layout.rs @@ -20,6 +20,45 @@ fn a_span_gives_each_child_the_width_it_asked_for() { assert_corners!(h, right, (100, 0), (400, 200)); } +/// A drawn child reports a fraction of the box it was given, and a span +/// offers each child what is left after the one before. So a nested span +/// that takes half of the half it was offered has taken a quarter of the row, +/// and what follows starts three quarters along -- not at the end, which is +/// where adding its report straight into the cursor put it. +#[test] +fn a_span_reads_a_child_report_as_a_fraction_of_what_it_offered() { + let mut h = Harness::new((400, 100)); + 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 nested = (inner,).span(Dir::RIGHT).add(&mut h.rsc); + let tail = rect(Color::BLUE).width(100).add(&mut h.rsc); + 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 + // once more; half of that final box is what its child takes, packed at + // the nested span's own start. + assert_corners!(h, nested, (200, 0), (300, 100)); + assert_corners!(h, inner, (200, 0), (250, 100)); + assert_corners!(h, tail, (300, 0), (400, 100)); +} + +/// The same reading through a pad: its inset is the whole box less the +/// padding, so half of the inset plus the padding is half the box plus one +/// padding, not two. +#[test] +fn a_pad_reports_a_fraction_of_its_inset_as_a_fraction_of_its_box() { + let mut h = Harness::new((400, 100)); + let inner = rect(Color::GREEN).width(rel(0.5)).add(&mut h.rsc); + let padded = (inner,).span(Dir::RIGHT).pad(10).add(&mut h.rsc); + let tail = rect(Color::BLUE).width(100).add(&mut h.rsc); + // Ruled to the window: a root reporting a fraction of it is otherwise + // placed inside it by its own alignment, which is not what is under test. + h.set_root((padded, tail).span(Dir::RIGHT).width(rel(1.0))); + + assert_corners!(h, padded, (0, 0), (210, 100)); + assert_corners!(h, tail, (210, 0), (310, 100)); +} + #[test] fn a_span_ruled_across_itself_does_not_measure_its_children_there() { let mut h = Harness::new((400, 200)); @@ -225,21 +264,21 @@ fn only_a_region_node_lengthens_the_chain_and_it_can_be_removed() { h.set_root((bar, buried).span(Dir::RIGHT)); let move_idx = h.render.active[&leaf.id()].parent_move; - assert_eq!(h.render.moves.depth(move_idx), 1, "only the root region"); + assert_eq!(h.render.moves.depth(move_idx), 0, "the window is no entry"); h.rsc.widgets_mut().set_region_node(buried, true); h.frame(); let move_idx = h.render.active[&leaf.id()].parent_move; assert_eq!( h.render.moves.depth(move_idx), - 2, - "the opted-in widget's region and the root region" + 1, + "the opted-in widget's region alone" ); h.rsc.widgets_mut().set_region_node(buried, false); h.frame(); let move_idx = h.render.active[&leaf.id()].parent_move; - assert_eq!(h.render.moves.depth(move_idx), 1); + assert_eq!(h.render.moves.depth(move_idx), 0); } /// A span that sizes from its children passes their `leftover` weight up @@ -341,16 +380,15 @@ fn a_row_of_equal_shares_fills_it_exactly() { } } -/// Where the shader puts an edge: the two parts of a scalar are floored -/// apart, so a fraction and a pixel offset snap independently, and each is -/// taken to the boundary it composes to within half a step of. Kept in step -/// with `snap_floor` in `prelude.wgsl`. +/// Where the shader puts an edge: the fraction resolved against the window +/// plus the pixel offset, taken to the boundary it composes to within half +/// a step of. Kept in step with `snap_floor` in `prelude.wgsl`. fn drawn_edges(h: &Harness, id: WidgetId, axis: Axis) -> (f32, f32) { let active = &h.render.active[&id]; let region = h.render.moves.resolve(active.parent_move, active.region); let dim = h.size().axis(axis); let snap = |v: f32| (v + Px::STEP.to_f32() * 0.5).floor(); - let edge = |s: Len| snap(s.rel.to_f32() * dim) + snap(s.px.to_f32()); + let edge = |s: Len| snap(s.rel.to_f32() * dim + s.px.to_f32()); let span = region.axis(axis); (edge(span.start), edge(span.end)) }