1175 lines
45 KiB
Rust
1175 lines
45 KiB
Rust
//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests
|
|
//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do
|
|
//! not touch a GPU or a window, so a tree can be built and driven directly.
|
|
//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the
|
|
//! CPU-side layout/move machinery LAYOUT.md is about.
|
|
|
|
use crate::prelude::*;
|
|
|
|
/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the
|
|
/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so
|
|
/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a
|
|
/// second copy of the same harness.
|
|
pub(crate) struct TestRsc {
|
|
pub(crate) ui: UiData,
|
|
}
|
|
|
|
impl UiRsc for TestRsc {
|
|
fn ui(&self) -> &UiData {
|
|
&self.ui
|
|
}
|
|
fn ui_mut(&mut self) -> &mut UiData {
|
|
&mut self.ui
|
|
}
|
|
}
|
|
|
|
struct FixedRect(f32);
|
|
|
|
impl Widget for FixedRect {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
let size = Size::from_axis(Axis::Y, Len::abs(self.0), Len::REST);
|
|
painter.primitive_within(
|
|
RectPrimitive::color(UiColor::WHITE),
|
|
size.to_uivec2(painter.density())
|
|
.align(RegionAlign::TOP_LEFT),
|
|
);
|
|
size
|
|
}
|
|
}
|
|
|
|
struct ChildOffset {
|
|
child: StrongWidget,
|
|
offset: Vec2,
|
|
}
|
|
|
|
impl Widget for ChildOffset {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
painter.set_child_offset(self.offset);
|
|
painter.widget(&self.child)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_coordinate_offset_moves_only_the_child_subtree() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let child = rsc.ui.widgets.add_strong(FixedRect(40.0));
|
|
let child_weak = child.weak();
|
|
let parent = rsc.ui.widgets.add_strong(ChildOffset {
|
|
child: child.any(),
|
|
offset: vec2(0.0, 15.0),
|
|
});
|
|
let parent_weak = parent.weak();
|
|
// Keep the coordinate-owning widget below the root: a nested widget is
|
|
// normally redrawn when its parent visits it, which takes a different
|
|
// retained-state path from `UiRenderState::redraw` on the root itself.
|
|
let outer = rsc.ui.widgets.add_strong(Sized {
|
|
inner: parent.any(),
|
|
x: None,
|
|
y: None,
|
|
});
|
|
let outer_weak = outer.weak();
|
|
let root = outer.any();
|
|
let mut render = UiRenderState::new();
|
|
render.resize((100.0, 100.0));
|
|
render.update(&root, &mut rsc);
|
|
render.take_counters();
|
|
|
|
let parent_before = render.window_region(&parent_weak, &rsc).unwrap();
|
|
let child_before = render.window_region(&child_weak, &rsc).unwrap();
|
|
assert!((parent_before.top_left.y - 0.0).abs() < 0.01);
|
|
assert!((child_before.top_left.y - 15.0).abs() < 0.01);
|
|
|
|
rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0;
|
|
// Force the ordinary ancestor-redraw path rather than letting the
|
|
// renderer visit only the dirty descendant directly.
|
|
rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None;
|
|
render.update(&root, &mut rsc);
|
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
|
let parent_after = render.window_region(&parent_weak, &rsc).unwrap();
|
|
let child_after = render.window_region(&child_weak, &rsc).unwrap();
|
|
|
|
assert_eq!((draws, rewrites, moves), (2, 0, 1));
|
|
assert_eq!(
|
|
parent_after, parent_before,
|
|
"the container itself stays put"
|
|
);
|
|
assert!((child_after.top_left.y - 35.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let first = rsc.ui.widgets.add_strong(FixedRect(40.0));
|
|
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let fill = rsc.ui.widgets.add_strong(Sized {
|
|
inner: fill.any(),
|
|
x: None,
|
|
y: Some(Len::REST),
|
|
});
|
|
let last = rsc.ui.widgets.add_strong(FixedRect(40.0));
|
|
let last_w = last.weak();
|
|
let mut span = Span::empty(Dir::DOWN);
|
|
span.push(first.any());
|
|
span.push(fill.any());
|
|
span.push(last.any());
|
|
let root = rsc.ui.widgets.add_strong(span).any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 300.0));
|
|
render.update(&root, &mut rsc);
|
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
|
|
|
assert_eq!(draws, 5);
|
|
assert_eq!(moves, 1);
|
|
let last = render.window_region(&last_w, &rsc).unwrap();
|
|
assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}");
|
|
}
|
|
|
|
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
|
|
/// enough that an O(N) regression in the move path would show up as a
|
|
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
|
|
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
|
|
/// stack into a plain unit test). Returns the scroll widget (weak, for
|
|
/// mutating it later), the erased root to draw, and the rows (weak, for
|
|
/// hit-testing one of them).
|
|
fn scrolled_rects(
|
|
rsc: &mut TestRsc,
|
|
n: usize,
|
|
) -> (WeakWidget<ScrollArea>, StrongWidget, Vec<WeakWidget<Rect>>) {
|
|
let mut span = Span::empty(Dir::DOWN);
|
|
let mut rects = Vec::with_capacity(n);
|
|
for _ in 0..n {
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
rects.push(rect.weak());
|
|
// Each row gets a fixed height so the span's total content is
|
|
// genuinely taller than the viewport -- rest-sized rows would just
|
|
// divide whatever space is offered and never need scrolling.
|
|
let row = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(10.0)),
|
|
});
|
|
span.push(row.any());
|
|
}
|
|
let span = rsc.ui.widgets.add_strong(span);
|
|
let scroll = rsc
|
|
.ui
|
|
.widgets
|
|
// Anchored at the *start*: every test below scrolls down from
|
|
// the top and states its sign convention against that. An
|
|
// end-anchored area now sits at its end from its first drawn
|
|
// frame (`Scroll::draw` measures and places in the same frame),
|
|
// so `Pin::End` here would mean scrolling down from a
|
|
// position that is already the bottom -- a clamped no-op, which
|
|
// reads as "the move path is broken" rather than as the test
|
|
// starting somewhere it did not mean to.
|
|
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start));
|
|
let weak = scroll.weak();
|
|
(weak, scroll.any(), rects)
|
|
}
|
|
|
|
#[test]
|
|
fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (_scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 20000.0));
|
|
|
|
render.update(&root, &mut rsc);
|
|
// Two, not one: the first offers `ScrollArea`'s content the container's
|
|
// own length as a placeholder (nothing has been measured yet) and
|
|
// `Scroll::draw` asks to be drawn again once it knows the real one,
|
|
// which the second update is. Only after that is the tree settled --
|
|
// see `scrolling_moves_in_o1_without_a_redraw`'s own note on the
|
|
// same first draw.
|
|
render.update(&root, &mut rsc);
|
|
render.take_counters(); // discard the first, real draws
|
|
|
|
render.update(&root, &mut rsc);
|
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
|
assert_eq!((draws, rewrites, moves), (0, 0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_moves_in_o1_without_a_redraw() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
|
|
// The first draw offers `ScrollArea`'s content a zero-height region
|
|
// (nothing has been measured yet) and learns the real content length
|
|
// from what comes back; `update()` only redraws widgets actually
|
|
// marked dirty, so that corrected length is not reflected in the
|
|
// content's own *active* region until something -- here a no-op
|
|
// scroll tick -- actually asks `ScrollArea` to redraw again. Only after
|
|
// that warm-up does the content's offered size stop changing between
|
|
// draws, which is what makes a further, real scroll tick a same-size
|
|
// move instead of a resize. See scroll.rs.
|
|
render.update(&root, &mut rsc);
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
|
render.update(&root, &mut rsc);
|
|
render.take_counters();
|
|
|
|
// Negative: `scroll`'s sign convention subtracts from `amt`, and
|
|
// `amt` starts at (and is clamped to) 0 at the top of the content, so
|
|
// a *positive* argument here would be scrolling further up (a no-op,
|
|
// already clamped) rather than actually moving anything.
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
|
render.update(&root, &mut rsc);
|
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
|
|
|
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
|
|
// 1 move_offsets write, independent of how many rects are in the
|
|
// scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is
|
|
// marked dirty by `scroll()` and its own body is cheap arithmetic with
|
|
// no primitives of its own, so it is the one real `Widget::draw` this
|
|
// counts -- the 500 rects underneath move via the O(1) chain and are
|
|
// never revisited.
|
|
assert_eq!(draws, 1, "only Scroll itself should redraw");
|
|
assert_eq!(moves, 1, "the scrolled subtree should move in one write");
|
|
}
|
|
|
|
#[test]
|
|
fn hit_testing_follows_a_scrolled_widget() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (scroll, root, rects) = scrolled_rects(&mut rsc, 500);
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
render.update(&root, &mut rsc);
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let target = &rects[2];
|
|
let before = render.resolved_region(target, &rsc).unwrap();
|
|
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let after = render.resolved_region(target, &rsc).unwrap();
|
|
let before_px = before.to_px((800.0, 600.0).into());
|
|
let after_px = after.to_px((800.0, 600.0).into());
|
|
|
|
// Scrolling by -37 moves `amt` from 0 to 37, sliding the content's
|
|
// top-left up by 37px -- `resolved_region` (the CPU twin of the vertex
|
|
// shader's chain walk) must reflect that immediately, not the
|
|
// pre-scroll position, or a tap routed through it would land on
|
|
// whatever is now at the old coordinates instead of this widget.
|
|
assert!(
|
|
(after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01,
|
|
"before={before_px:?} after={after_px:?}"
|
|
);
|
|
}
|
|
|
|
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
|
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
|
/// mask, so storing the set one hands a `Masked` its own mask the second
|
|
/// time round -- which `Painter::set_mask` asserts against, since a mask
|
|
/// that chains to itself is a clip loop. That was an abort the first time
|
|
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
|
/// redraw of a `Masked` is what any real screen does whenever anything
|
|
/// inside it changes.
|
|
#[test]
|
|
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
|
shape: None,
|
|
inner: inner_root,
|
|
});
|
|
let masked_id = masked.id();
|
|
let root = masked.any();
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
render.update(&root, &mut rsc);
|
|
|
|
render.redraw(masked_id, &mut rsc);
|
|
render.redraw(masked_id, &mut rsc);
|
|
|
|
assert_eq!(
|
|
render.active.get(&masked_id).unwrap().mask,
|
|
MaskIdx::NONE,
|
|
"a `Masked` at the root is drawn under no mask of its own"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
|
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
|
shape: None,
|
|
inner: inner_root,
|
|
});
|
|
let masked_id = masked.id();
|
|
let root = masked.any();
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
|
|
render.update(&root, &mut rsc);
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let masked_slot_before = render.active.get(&masked_id).unwrap().move_slot;
|
|
let mask_delta_before = rsc.ui.move_offsets[masked_slot_before.idx()].delta;
|
|
|
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
|
|
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
|
|
|
|
// `Masked` itself is never the target of a `mov`/`place` here --
|
|
// only its scrolled child is -- so the slot its own mask references
|
|
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
|
|
// still read zero after the scroll. The visible counterpart of this
|
|
// (the clipped edge follows the scroll while the viewport border does
|
|
// not) is `iris/run-headless.sh`'s job to catch in a real frame; this
|
|
// is the numeric half, on the same data the fragment shader's
|
|
// `resolve_move` reads. See LAYOUT.md section 2b.
|
|
assert_eq!(mask_delta_before, [0.0, 0.0]);
|
|
assert_eq!(mask_delta_after, [0.0, 0.0]);
|
|
}
|
|
|
|
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
|
|
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
|
|
/// `rest`-width `TextEdit`, itself the second child of an outer
|
|
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
|
|
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
|
|
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
|
|
/// is this crate's layout engine or something specific to the real
|
|
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
|
|
/// insert exercises the exact redraw path a keystroke does.
|
|
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
|
|
let field = wtext("")
|
|
.editable(EditMode::MultiLine)
|
|
.text_align(Align::LEFT)
|
|
.wrap(true)
|
|
.size(18)
|
|
.color(UiColor::WHITE)
|
|
.add(rsc);
|
|
let bar = (field.pad(dp(12)).width(rest(1)),)
|
|
.span(Dir::RIGHT)
|
|
.background(rect(UiColor::new(40, 40, 46, 255)))
|
|
.add(rsc);
|
|
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
|
|
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
|
|
(field, tree)
|
|
}
|
|
|
|
/// The reproduction itself. A window this tall stands in for the keyboard
|
|
/// closed; the second, shorter `resize` stands in for `adjustResize`
|
|
/// shrinking the surface when the IME opens -- exactly the sequence
|
|
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
|
|
/// happens both before and after, since Iris's report was specifically
|
|
/// that text typed *after* the keyboard was already up did not appear.
|
|
#[test]
|
|
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (field, root) = composer_like_tree(&mut rsc);
|
|
let mut render = UiRenderState::new();
|
|
|
|
render.resize((1080.0, 2298.0));
|
|
render.update(&root, &mut rsc);
|
|
// Focusing a field is what places its caret on a real tap
|
|
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
|
|
// with no caret is a routing bug rather than a state to simulate --
|
|
// `insert_str`'s own `debug_assert!` says so, and caught this test
|
|
// typing into an unfocused field when it was added.
|
|
field
|
|
.edit(&mut rsc)
|
|
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
|
|
field.edit(&mut rsc).insert("a");
|
|
render.update(&root, &mut rsc);
|
|
|
|
let before_px = render.window_region(&field, &rsc).unwrap();
|
|
// The field is one line plus 12dp of padding on a 2298-tall window --
|
|
// nowhere near the whole window's height, and anchored at the bottom.
|
|
assert!(
|
|
before_px.bot_right.y - before_px.top_left.y < 200.0,
|
|
"before a resize: {before_px:?}"
|
|
);
|
|
assert!(
|
|
before_px.top_left.y > 1800.0,
|
|
"expected the bar near the bottom before a resize: {before_px:?}"
|
|
);
|
|
|
|
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
|
|
// window, then a further keystroke -- the redraw that must land in the
|
|
// bar's new (also short) region, not whatever region a provisional
|
|
// provisional placement used along the way.
|
|
render.resize((1080.0, 1478.0));
|
|
render.update(&root, &mut rsc);
|
|
field.edit(&mut rsc).insert("b");
|
|
render.update(&root, &mut rsc);
|
|
|
|
let after_px = render.window_region(&field, &rsc).unwrap();
|
|
assert!(
|
|
after_px.bot_right.y - after_px.top_left.y < 200.0,
|
|
"after a resize + keystroke: {after_px:?}"
|
|
);
|
|
assert!(
|
|
after_px.top_left.y > 1200.0,
|
|
"expected the bar near the bottom of the shorter window: {after_px:?}"
|
|
);
|
|
}
|
|
|
|
/// `ScrollArea` used to be documented as resolving its own lengths against
|
|
/// `Painter::output_size` -- the window -- which read as if a scroll area
|
|
/// smaller than the screen could not work, and cost a session's
|
|
/// investigation before the composer was wired up (docs/RUST.md,
|
|
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
|
|
/// three numbers that follow from the offered box: what it reports
|
|
/// upward, what its capping parent reports, and how far it can pan.
|
|
#[test]
|
|
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(1000.0)),
|
|
});
|
|
let scroll = rsc
|
|
.ui
|
|
.widgets
|
|
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
|
// go -- see `scrolled_rects`' note on the same choice.
|
|
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
|
|
let scroll_w = scroll.weak();
|
|
let scroll_id = scroll.id();
|
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
|
inner: scroll.any(),
|
|
x: None,
|
|
y: Some(Len::abs(100.0)),
|
|
});
|
|
let capped_id = capped.id();
|
|
let root = capped.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
// Two passes: the first offers the content a zero-length region
|
|
// (nothing measured yet) and learns the real content length from what
|
|
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
|
|
// that warm-up is deliberate rather than a bug.
|
|
render.update(&root, &mut rsc);
|
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
// Reports the *content*, so the cap above it has something to cap;
|
|
// reporting the container instead would make the answer a function of
|
|
// itself, since the container is sized from this very number.
|
|
assert_eq!(
|
|
render.active.get(&scroll_id).unwrap().size.y,
|
|
Len::abs(1000.0)
|
|
);
|
|
assert_eq!(
|
|
render.active.get(&capped_id).unwrap().size.y,
|
|
Len::abs(100.0),
|
|
"the cap, not the content and not the window"
|
|
);
|
|
|
|
// Panning is bounded by content minus *container*: 900, not the 400
|
|
// a 600px window would give. The draw is what spends the delta -- a
|
|
// controller banks it until the layout that knows where the content
|
|
// ends (`ScrollController::take_delta`).
|
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
|
|
render.update(&root, &mut rsc);
|
|
assert!(
|
|
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
|
|
"amt={}",
|
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
|
|
);
|
|
}
|
|
|
|
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
|
|
/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own
|
|
/// `region` is stale and is corrected entirely by the move chain. The
|
|
/// moved widget itself had its `region` updated *and* the chain delta
|
|
/// added on top, so its hit box sat at twice the pan -- which is why a
|
|
/// finger pan of the composer left its field untappable. See
|
|
/// `ActiveData::move_applied`.
|
|
#[test]
|
|
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(1000.0)),
|
|
});
|
|
let tall_w = tall.weak();
|
|
let scroll = rsc
|
|
.ui
|
|
.widgets
|
|
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
|
// go -- see `scrolled_rects`' note on the same choice.
|
|
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
|
|
let scroll_w = scroll.weak();
|
|
let root = scroll.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
render.update(&root, &mut rsc);
|
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let before = render.window_region(&tall_w, &rsc).unwrap();
|
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
|
|
render.update(&root, &mut rsc);
|
|
let after = render.window_region(&tall_w, &rsc).unwrap();
|
|
|
|
assert!(
|
|
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
|
|
"the pan was applied twice: before={before:?} after={after:?}"
|
|
);
|
|
}
|
|
|
|
/// A `Masked` used to allocate a **new** mask slot on every draw, and
|
|
/// `draw_inner`'s unchanged-region fast path means its descendants are
|
|
/// mostly *not* redrawn with it -- so they went on referencing the slot
|
|
/// they were first drawn under, whose region had since stopped being the
|
|
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
|
|
/// entries, none of them the `Masked`'s current box, and the field it was
|
|
/// meant to clip drew nothing at all on the emulator. The slot is
|
|
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
|
|
/// this pins both halves: one entry, and that entry is the widget's own
|
|
/// region.
|
|
#[test]
|
|
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
|
shape: None,
|
|
inner: inner_root,
|
|
});
|
|
let masked_id = masked.id();
|
|
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
|
// which is what moves the bar away from the provisional slot it is
|
|
// first drawn at -- the move that left the stale mask behind.
|
|
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
|
let filler = rsc.ui.widgets.add_strong(Sized {
|
|
inner: filler.any(),
|
|
x: None,
|
|
y: Some(rest(1)),
|
|
});
|
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
|
inner: masked.any(),
|
|
x: None,
|
|
y: Some(Len::abs(60.0)),
|
|
});
|
|
let mut span = Span::empty(Dir::DOWN);
|
|
span.push(filler.any());
|
|
span.push(capped.any());
|
|
let root = rsc.ui.widgets.add_strong(span).any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
for _ in 0..3 {
|
|
render.update(&root, &mut rsc);
|
|
render.redraw(masked_id, &mut rsc);
|
|
}
|
|
|
|
assert_eq!(
|
|
rsc.ui.masks.iter().count(),
|
|
1,
|
|
"one `Masked` must own exactly one mask slot, however often it is redrawn"
|
|
);
|
|
let mask = *rsc.ui.masks.iter().next().unwrap();
|
|
assert_eq!(
|
|
render.primitives.instance(mask.primitive).region,
|
|
render.active.get(&masked_id).unwrap().region,
|
|
"the mask a descendant clips against must be this widget's current box"
|
|
);
|
|
}
|
|
|
|
/// A `dp` cap that has done its job must be reported in pixels. `Span`
|
|
/// places a child using the `abs`/`rel` of the length it reported, so a
|
|
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
|
|
/// bar a slot of **zero** the moment its content grew past six lines --
|
|
/// and the `ScrollArea` inside then measured its container at -63px (the
|
|
/// padding, subtracted from nothing) and panned the whole message out of
|
|
/// view. Measured on this checkout's emulator, 2026-09-06:
|
|
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
|
|
#[test]
|
|
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(1000.0)),
|
|
});
|
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
|
inner: tall.any(),
|
|
x: None,
|
|
y: Some(Len::dp(100.0)),
|
|
});
|
|
let capped_w = capped.weak();
|
|
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
|
let filler = rsc.ui.widgets.add_strong(Sized {
|
|
inner: filler.any(),
|
|
x: None,
|
|
y: Some(rest(1)),
|
|
});
|
|
let mut span = Span::empty(Dir::DOWN);
|
|
span.push(filler.any());
|
|
span.push(capped.any());
|
|
let root = rsc.ui.widgets.add_strong(span).any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
render.set_density(2.5);
|
|
render.update(&root, &mut rsc);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let box_px = render.window_region(&capped_w, &rsc).unwrap();
|
|
let height = box_px.bot_right.y - box_px.top_left.y;
|
|
assert!(
|
|
(height - 250.0).abs() < 0.01,
|
|
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
|
|
);
|
|
}
|
|
|
|
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
|
|
/// the branch that fix had no reason to touch: `draw_inner`'s
|
|
/// size-independent fast path rewrites a widget's primitives *in place*
|
|
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
|
|
/// for `region` to have absorbed. Counting one there anyway makes
|
|
/// `resolved_region` subtract a delta the chain never held, and the
|
|
/// widget's hit box lands short of where it is drawn by exactly the
|
|
/// distance it just moved -- with nothing on screen to say so, since the
|
|
/// primitives are in the right place.
|
|
#[test]
|
|
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let spacer = rsc.ui.widgets.add_strong(Sized {
|
|
inner: top.any(),
|
|
x: None,
|
|
y: Some(Len::abs(100.0)),
|
|
});
|
|
let spacer_w = spacer.weak();
|
|
// `Rect` is `is_size_independent`, so growing the spacer above it
|
|
// offers this one a region that changed *both* position and size --
|
|
// the one shape that reaches the branch under test.
|
|
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let below_w = below.weak();
|
|
let mut span = Span::empty(Dir::DOWN);
|
|
span.push(spacer.any());
|
|
span.push(below.any());
|
|
let root = rsc.ui.widgets.add_strong(span).any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 600.0));
|
|
render.update(&root, &mut rsc);
|
|
let first = render.window_region(&below_w, &rsc).unwrap();
|
|
assert!(
|
|
(first.top_left.y - 100.0).abs() < 0.01,
|
|
"hit box at {:?}, drawn at y=100",
|
|
first.top_left
|
|
);
|
|
|
|
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
|
|
render.update(&root, &mut rsc);
|
|
let after = render.window_region(&below_w, &rsc).unwrap();
|
|
assert!(
|
|
(after.top_left.y - 250.0).abs() < 0.01,
|
|
"hit box at {:?}, drawn at y=250",
|
|
after.top_left
|
|
);
|
|
}
|
|
|
|
/// A parent that both `mov`s a child (its own layout moved the box it
|
|
/// offers) and places it inside that box in the same frame -- what
|
|
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
|
|
/// stops matching what the row reports, which is reachable as soon as a
|
|
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
|
|
struct MoveThenPlace {
|
|
inner: StrongWidget,
|
|
/// Where the child is *offered* a (constant-size) box, moved between
|
|
/// frames by the test.
|
|
offer_top: f32,
|
|
/// Where the child is then placed within this widget's own region.
|
|
place_top: f32,
|
|
}
|
|
|
|
impl Widget for MoveThenPlace {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
let offer = UiRegion::new(
|
|
UiSpan::FULL,
|
|
UiSpan::new(
|
|
UiScalar::abs(self.offer_top),
|
|
UiScalar::abs(self.offer_top + 40.0),
|
|
),
|
|
);
|
|
painter.widget_within(&self.inner, offer);
|
|
let place = UiRegion::new(
|
|
UiSpan::FULL,
|
|
UiSpan::new(
|
|
UiScalar::abs(self.place_top),
|
|
UiScalar::abs(self.place_top + 40.0),
|
|
),
|
|
);
|
|
painter.place(&self.inner, place);
|
|
Size::default()
|
|
}
|
|
}
|
|
|
|
/// Placement must preserve a move already applied in the same frame.
|
|
#[test]
|
|
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let child = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(40.0)),
|
|
});
|
|
let child_w = child.weak();
|
|
let parent = rsc.ui.widgets.add_strong(MoveThenPlace {
|
|
inner: child.any(),
|
|
offer_top: 0.0,
|
|
place_top: 100.0,
|
|
});
|
|
let parent_w = parent.weak();
|
|
let root = parent.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 400.0));
|
|
render.update(&root, &mut rsc);
|
|
let before = render.window_region(&child_w, &rsc).unwrap();
|
|
assert!(
|
|
(before.top_left.y - 100.0).abs() < 0.01,
|
|
"the child should be drawn where it was placed, not where it was offered: {before:?}"
|
|
);
|
|
|
|
// Move the offered box without changing its size (the `mov` fast path)
|
|
// and place the child at the same spot as before. Marking the parent
|
|
// dirty is what a real container's own content change does; the child
|
|
// itself is untouched, which is the case `mov` exists for.
|
|
{
|
|
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
|
|
parent.offer_top = 200.0;
|
|
}
|
|
rsc.ui.widgets.needs_redraw.insert(parent_w.id());
|
|
render.update(&root, &mut rsc);
|
|
let after = render.window_region(&child_w, &rsc).unwrap();
|
|
assert!(
|
|
(after.top_left.y - 100.0).abs() < 0.01,
|
|
"the placement did not change, so neither should the child: before={before:?} \
|
|
after={after:?}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
|
|
//
|
|
// The shape a mask clips to is a *primitive already drawn*, never a copy
|
|
// of one, so "the child's clipped corner" and "the container's own corner"
|
|
// are the same arithmetic. These say so by evaluating both and demanding
|
|
// exact equality: an approximate assertion would also pass a second copy
|
|
// of the radius that merely happened to agree.
|
|
// ---------------------------------------------------------------------
|
|
|
|
const RADIUS: f32 = 20.0;
|
|
|
|
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
|
|
/// that fills it -- so the child's own corners are exactly the corners
|
|
/// being clipped away. Returns the drawn state, the mask, the child, and
|
|
/// the shape primitive the mask points at.
|
|
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
|
|
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let child_id = child.id();
|
|
let shape = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
|
let shape_id = shape.id();
|
|
let root = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Masked {
|
|
shape: Some(shape.any()),
|
|
inner: child.any(),
|
|
})
|
|
.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 100.0));
|
|
render.update(&root, rsc);
|
|
|
|
let mask = render
|
|
.active
|
|
.get(&child_id)
|
|
.expect("the child is drawn")
|
|
.mask;
|
|
assert_ne!(
|
|
mask,
|
|
MaskIdx::NONE,
|
|
"the child was drawn with no clip at all"
|
|
);
|
|
let slot = render
|
|
.first_primitive(shape_id)
|
|
.expect("the shape widget drew a rect");
|
|
(render, mask, child_id, slot)
|
|
}
|
|
|
|
/// The pass condition: the child's coverage at a corner pixel *equals*
|
|
/// the container's own coverage there. Exactly equal, because it is the
|
|
/// same primitive evaluated once -- LAYOUT.md's point 1.
|
|
#[test]
|
|
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (render, mask, _child, slot) = rounded_container(&mut rsc);
|
|
let corners = render.primitive_corners(slot, &rsc);
|
|
let radius = render
|
|
.primitives
|
|
.primitive_data::<RectPrimitive>(slot)
|
|
.expect("a mask's shape is a rect")
|
|
.radius;
|
|
|
|
// Across the whole corner arc, not one point on it: a single sample
|
|
// is satisfied by a mask that clips to the box and happens to agree
|
|
// where the two coincide. Swept from the arc's own centre -- the
|
|
// straight chord between the two ends of the arc lies *inside* the
|
|
// circle everywhere, so a walk along it never leaves the shape and
|
|
// the `outside` count below is what caught that.
|
|
let arc_center = corners.top_left + Vec2::new(radius, radius);
|
|
let (mut outside, mut inside) = (0, 0);
|
|
for i in 0..=20 {
|
|
let angle = std::f32::consts::FRAC_PI_2 * i as f32 / 20.0;
|
|
let dir = Vec2::new(-angle.cos(), -angle.sin());
|
|
for out in [-1.5f32, 0.0, 1.5] {
|
|
let pos = arc_center + dir * (radius + out);
|
|
let container = rounded_rect_coverage(pos, corners.top_left, corners.bot_right, radius);
|
|
assert_eq!(
|
|
render.mask_coverage(mask, pos, &rsc),
|
|
container,
|
|
"at {pos:?} the child's clip and the container's own edge disagree",
|
|
);
|
|
if container < 0.5 {
|
|
outside += 1;
|
|
} else {
|
|
inside += 1;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
outside > 0 && inside > 0,
|
|
"the sweep stayed on one side of the curve ({outside} out, {inside} in), so it proved \
|
|
nothing about the corner"
|
|
);
|
|
}
|
|
|
|
/// A hit test asks the same question the pixels do: the corner the
|
|
/// container rounded away is not there to be pressed, and a point just
|
|
/// inside the curve is. LAYOUT.md's point 4.
|
|
#[test]
|
|
fn a_mask_s_shape_decides_what_can_be_pressed() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (render, mask, _child, slot) = rounded_container(&mut rsc);
|
|
let corners = render.primitive_corners(slot, &rsc);
|
|
|
|
// The very corner of the box, which the radius cut off.
|
|
let cut = corners.top_left + Vec2::new(1.0, 1.0);
|
|
assert!(
|
|
!render.mask_admits(mask, cut, &rsc),
|
|
"the corner the container rounded away is still pressable",
|
|
);
|
|
// The same distance in along the diagonal, past the curve.
|
|
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
|
|
assert!(
|
|
render.mask_admits(mask, inside, &rsc),
|
|
"a point well inside the curve is not pressable",
|
|
);
|
|
// And the middle of an edge, which no radius touches -- the half the
|
|
// rounding had no reason to change.
|
|
let edge = Vec2::new(
|
|
(corners.top_left.x + corners.bot_right.x) / 2.0,
|
|
corners.top_left.y + 1.0,
|
|
);
|
|
assert!(
|
|
render.mask_admits(mask, edge, &rsc),
|
|
"a straight edge between two corners is not pressable",
|
|
);
|
|
}
|
|
|
|
/// Nested masks multiply, so a pixel inside two feathered corners is
|
|
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
|
|
/// decreased / multiplied" Iris asked for. Written as a product of the
|
|
/// two the shader would compute separately, which is what "multiply"
|
|
/// means and what an intersection test would get wrong.
|
|
#[test]
|
|
fn nested_masks_multiply_their_coverage() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
let child_id = child.id();
|
|
|
|
let inner_shape = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
|
let inner_shape_id = inner_shape.id();
|
|
let inner = rsc.ui.widgets.add_strong(Masked {
|
|
shape: Some(inner_shape.any()),
|
|
inner: child.any(),
|
|
});
|
|
let outer_shape = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
|
let outer_shape_id = outer_shape.id();
|
|
let root = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Masked {
|
|
shape: Some(outer_shape.any()),
|
|
inner: inner.any(),
|
|
})
|
|
.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 100.0));
|
|
render.update(&root, &mut rsc);
|
|
|
|
let mask = render.active.get(&child_id).expect("drawn").mask;
|
|
let one = |render: &UiRenderState, rsc: &TestRsc, id, pos| {
|
|
let slot = render.first_primitive(id).expect("a shape rect");
|
|
let c = render.primitive_corners(slot, rsc);
|
|
let radius = render
|
|
.primitives
|
|
.primitive_data::<RectPrimitive>(slot)
|
|
.unwrap()
|
|
.radius;
|
|
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
|
|
};
|
|
|
|
// A point on the corner arc, where both feathers are partial -- the
|
|
// only place a product and a minimum differ.
|
|
let slot = render.first_primitive(inner_shape_id).unwrap();
|
|
let corners = render.primitive_corners(slot, &rsc);
|
|
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
|
|
let inner_cov = one(&render, &rsc, inner_shape_id, pos);
|
|
let outer_cov = one(&render, &rsc, outer_shape_id, pos);
|
|
assert!(
|
|
inner_cov > 0.0 && inner_cov < 1.0,
|
|
"the sample point is not inside a feather ({inner_cov}), so this proves nothing"
|
|
);
|
|
assert_eq!(
|
|
render.mask_coverage(mask, pos, &rsc),
|
|
inner_cov * outer_cov,
|
|
"two nested masks must multiply, not intersect",
|
|
);
|
|
}
|
|
|
|
/// A plain `.masked()` -- no shape given -- still clips to the widget's
|
|
/// own box with square corners, which is what every list and scroll area
|
|
/// relies on. The half the shape work had no reason to touch, and the one
|
|
/// that would silently round every existing clip if `set_mask` ever wrote
|
|
/// a radius of its own.
|
|
#[test]
|
|
fn a_plain_mask_still_clips_to_a_square_box() {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
|
let root = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Masked {
|
|
shape: None,
|
|
inner: inner_root,
|
|
})
|
|
.any();
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 100.0));
|
|
render.update(&root, &mut rsc);
|
|
|
|
let mask = *rsc.ui.masks.iter().next().expect("one mask");
|
|
let corners = render.primitive_corners(mask.primitive, &rsc);
|
|
let mask_idx = MaskIdx::preset(0);
|
|
assert!(
|
|
render.mask_admits(mask_idx, corners.top_left + Vec2::new(0.5, 0.5), &rsc),
|
|
"a square clip must admit its own corner pixel",
|
|
);
|
|
assert!(
|
|
!render.mask_admits(mask_idx, corners.top_left - Vec2::new(2.0, 2.0), &rsc),
|
|
"a square clip must reject a point outside it",
|
|
);
|
|
}
|
|
|
|
/// A scroll area created to be *read* opens at the beginning of its
|
|
/// content, however many frames it takes to learn how long that content
|
|
/// is.
|
|
///
|
|
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
|
|
/// for "not drawn yet", so the first frame's clamp found a range of zero,
|
|
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
|
|
/// the frame after, now knowing the real length, jumped to it. On screen
|
|
/// that was a code fence opening at the end of its longest line, in the
|
|
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
|
|
#[test]
|
|
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
|
|
for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] {
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
|
inner: fill,
|
|
x: None,
|
|
y: Some(Len::abs(5000.0)),
|
|
});
|
|
let scroll = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(ScrollArea::new(tall.any(), Axis::Y, pin));
|
|
let weak = scroll.weak();
|
|
let root = scroll.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((800.0, 100.0));
|
|
// Twice: the first draw is the one that measures the content, and
|
|
// the defect only showed on the second. The touch in between is
|
|
// what asks for that second draw -- an unchanged frame draws
|
|
// nothing at all, which is the point of the frame before it.
|
|
render.update(&root, &mut rsc);
|
|
let _ = rsc.ui.widgets.get_mut(&weak);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let amt = rsc.ui.widgets.get(&weak).unwrap().amt();
|
|
assert!(
|
|
(amt - want).abs() < 0.01,
|
|
"an area to be {name} should have opened at {want}, got {amt}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
|
|
/// `Span` places those children a slot out of step", worked around in
|
|
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
|
|
/// which costs a tool group the inset its cards should sit inside.
|
|
///
|
|
/// The shape is the smallest one that reproduced it there: an outer
|
|
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
|
|
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
|
|
/// be *drawn* where its own box is -- `primitive_corners` rather than
|
|
/// `window_region`, since the report is about what is on screen and the
|
|
/// two resolve the move chain differently.
|
|
#[test]
|
|
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
|
|
const PAD: f32 = 4.0;
|
|
const ROW: f32 = 20.0;
|
|
const HEADER: f32 = 30.0;
|
|
let mut rsc = TestRsc {
|
|
ui: UiData::default(),
|
|
};
|
|
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
|
|
let header_id = header_fill.id();
|
|
let header = rsc.ui.widgets.add_strong(Sized {
|
|
inner: header_fill,
|
|
x: None,
|
|
y: Some(Len::abs(HEADER)),
|
|
});
|
|
let mut inner = Span::empty(Dir::DOWN);
|
|
let mut rects = Vec::new();
|
|
for _ in 0..3 {
|
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
|
rects.push(rect.weak());
|
|
let sized = rsc.ui.widgets.add_strong(Sized {
|
|
inner: rect.any(),
|
|
x: None,
|
|
y: Some(Len::abs(ROW)),
|
|
});
|
|
let padded = rsc.ui.widgets.add_strong(Pad {
|
|
padding: Padding::uniform(PAD),
|
|
inner: sized.any(),
|
|
exact_region: false,
|
|
});
|
|
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
|
|
let card = rsc.ui.widgets.add_strong(Stack {
|
|
children: vec![fill, padded.any()],
|
|
size: StackSize::Child(1),
|
|
});
|
|
let wide = rsc.ui.widgets.add_strong(Sized {
|
|
inner: card.any(),
|
|
x: Some(Len::rest(1.0)),
|
|
y: None,
|
|
});
|
|
inner.push(wide.any());
|
|
}
|
|
let inner = rsc.ui.widgets.add_strong(inner);
|
|
let outer = rsc.ui.widgets.add_strong(Span {
|
|
children: vec![header.any(), inner.any()],
|
|
dir: Dir::DOWN,
|
|
gap: Len::ZERO,
|
|
});
|
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
|
list.push_back(LazyItem::new(0, outer.any()));
|
|
let list = rsc.ui.widgets.add_strong(list);
|
|
let root = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Masked {
|
|
shape: None,
|
|
inner: list.any(),
|
|
})
|
|
.any();
|
|
|
|
let mut render = UiRenderState::new();
|
|
render.resize((200.0, 400.0));
|
|
render.update(&root, &mut rsc);
|
|
render.update(&root, &mut rsc);
|
|
|
|
let head_slot = render
|
|
.first_primitive(header_id)
|
|
.expect("the header drew a primitive");
|
|
let head_top = render.primitive_corners(head_slot, &rsc).top_left.y;
|
|
for (i, rect) in rects.iter().enumerate() {
|
|
let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD;
|
|
let slot = render
|
|
.first_primitive(rect.id())
|
|
.expect("each rect drew a primitive");
|
|
let drawn = render.primitive_corners(slot, &rsc);
|
|
assert!(
|
|
(drawn.top_left.y - want).abs() < 0.01,
|
|
"row {i} should be drawn at y={want}, got {drawn:?}"
|
|
);
|
|
}
|
|
}
|