`MaxSize` on the app's pin narrows the box it asks its child in and cuts the
answer to the cap; nothing on this branch does either, so the capability is
missing rather than merely unported. This is that capability as a rule beside
the widget, the way `Exact` already is: `Min(Len)`, `Max(Len)` and
`Clamp { min, max }`, resolved against the rel base a declared length is a
fraction of, and never carrying `leftover` -- a cap containing a share admits
several self-sizing fixed points (`docs/LAYOUT.md`, failed hypotheses).
Where it stands: every hand-written test passes, including the capability the
app actually used -- `a_capped_scroll_takes_its_viewport_from_the_cap` puts
400 px of content under a 100 px cap and gets a 100 px viewport with 300 to
scroll, which is what `MaxSize` gave. The 400-seed depth-5 scan does not
pass, and the reason is a design question rather than a slip, so this sits on
its own branch instead of in #19.
What the scan finds: a bound is the first rule whose effect depends on the
box its parent gives it, and the retained machinery hands a widget a box by
paths that never ask it again -- `place_in` from a re-placing parent, and
`reposition` after a parent's box moved. A decision made when the box was one
length therefore survives into a box of another, so warm and cold disagree
about a tree they agree on structurally. Four readings were measured over 400
seeds at depth 5:
- deciding at every ask and keeping it: seeds 291, 1, 120, 178, 64 differ.
- the same, re-decided at `place_in` too: seeds 1, 362, 188, 254, 156 differ,
because that path's box is the one the answer chose rather than the one the
widget was asked in.
- skipping a place its parent decided outright, which is the rule the share
follows: worse -- the same widget then gets two decisions by two paths.
- the bound as an answer rule only, leaving the box alone: seeds 4 and 196,
and those are the closest to passing by a wide margin.
The share is the one existing rule of this kind and it is stable because
`place_at` re-asks a child whose rel base it narrows, and because its
decision is baked into the retained place as a `Sized` length. Neither
protection generalises: a bound that binds is a length of the rel base, and
`Sized` cannot say "this slot, narrowed" for a `Within` place.
Also here, because a bound needed them: `Len::longer_than` and
`Bound::outside` share one comparison with the span; a rule that is a
fraction now pins its rel base whether the fraction is a length or a bound,
which was a real gap for `Exact` too; `widget_trait!` passes attributes
through, so the methods it defines can carry doc comments (none could);
`From<N> for Len`, so a bound reads `max_width(300)`; and `random.rs` grows
all three variants, with `describe` printing them so a failure can be written
out by hand.
Format, clippy with and without layout-diagnostics, and the suite (142 + 19 +
13 + 4) are clean. The fast ten-seed oracle passes; the long scans do not.
Neutering the bounds in the generator while leaving its draws in place puts
the same shapes back to green, so the divergence is the bounds and not the
new trees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.7 KiB
Rust
187 lines
6.7 KiB
Rust
//! Scrolling moves content and stops at its ends.
|
|
|
|
use iris::harness::{Harness, assert_corners};
|
|
use iris::prelude::*;
|
|
|
|
#[test]
|
|
fn scrollable_enables_a_region_node_but_raw_scroll_does_not() {
|
|
let mut h = Harness::new((100, 100));
|
|
let default_child = ().add(&mut h.rsc);
|
|
let _default = default_child.scrollable().add(&mut h.rsc);
|
|
assert!(h.rsc.widgets().is_region_node(default_child));
|
|
h.rsc.widgets_mut().set_region_node(default_child, false);
|
|
assert!(!h.rsc.widgets().is_region_node(default_child));
|
|
|
|
let raw_child = ().add(&mut h.rsc);
|
|
let _raw = Scroll::new(raw_child.add_strong(&mut h.rsc), Axis::Y).add(&mut h.rsc);
|
|
assert!(!h.rsc.widgets().is_region_node(raw_child));
|
|
|
|
let explicit = ().region_node().add(&mut h.rsc);
|
|
assert!(h.rsc.widgets().is_region_node(explicit));
|
|
}
|
|
|
|
#[test]
|
|
fn a_scrollable_child_can_drop_its_region_node() {
|
|
let mut h = Harness::new((400, 200));
|
|
let top = rect(Color::RED).height(200).add(&mut h.rsc);
|
|
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
|
|
let content = (top, bottom).span(Dir::DOWN).add(&mut h.rsc);
|
|
h.set_root(content.scrollable());
|
|
h.rsc.widgets_mut().set_region_node(content, false);
|
|
h.frame();
|
|
|
|
h.move_to((200, 100));
|
|
h.scroll((0, 1));
|
|
h.frame();
|
|
|
|
assert!(!h.rsc.widgets().is_region_node(content));
|
|
assert_corners!(h, top, (0, -150), (400, 50));
|
|
}
|
|
|
|
#[test]
|
|
fn a_wheel_scrolls_the_content_and_stops_at_its_end() {
|
|
let mut h = Harness::new((400, 200));
|
|
// Twice the window's height, so there is 200 to scroll.
|
|
let top = rect(Color::RED).height(200).add(&mut h.rsc);
|
|
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
|
|
h.set_root((top, bottom).span(Dir::DOWN).scrollable());
|
|
h.move_to((200, 100));
|
|
|
|
// `Scroll` starts snapped to the end.
|
|
assert_corners!(h, top, (0, -200), (400, 0));
|
|
|
|
// The handler scales a wheel line by 50.
|
|
h.scroll((0, 1));
|
|
h.frame();
|
|
assert_corners!(h, top, (0, -150), (400, 50));
|
|
|
|
h.scroll((0, 10));
|
|
h.frame();
|
|
assert_corners!(h, top, (0, 0), (400, 200));
|
|
}
|
|
|
|
#[test]
|
|
fn fixed_content_and_a_share_fill_one_viewport() {
|
|
let mut h = Harness::new((900, 100));
|
|
let content = rect(Color::RED)
|
|
.width(LayoutLen {
|
|
px: Px::from_int(600),
|
|
rel: Rel::ZERO,
|
|
leftover: Weight::ONE,
|
|
})
|
|
.add(&mut h.rsc);
|
|
let scroll = Scroll::new(content.add_strong(&mut h.rsc), Axis::X);
|
|
h.set_root(scroll);
|
|
|
|
assert_corners!(h, content, (0, 0), (900, 100));
|
|
}
|
|
|
|
#[test]
|
|
fn fixed_content_wider_than_the_viewport_still_scrolls() {
|
|
let mut h = Harness::new((900, 100));
|
|
let content = rect(Color::RED).width(1200).add(&mut h.rsc);
|
|
let scroll = Scroll::new(content.add_strong(&mut h.rsc), Axis::X);
|
|
h.set_root(scroll);
|
|
|
|
assert_corners!(h, content, (-300, 0), (900, 100));
|
|
}
|
|
|
|
#[test]
|
|
fn a_lone_share_fills_without_scrolling() {
|
|
let mut h = Harness::new((900, 100));
|
|
let content = rect(Color::RED).width(LayoutLen::LEFTOVER).add(&mut h.rsc);
|
|
let scroll = Scroll::new(content.add_strong(&mut h.rsc), Axis::X);
|
|
h.set_root(scroll);
|
|
|
|
assert_corners!(h, content, (0, 0), (900, 100));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapping_content_beside_a_fixed_length_is_stable_warm_and_cold() {
|
|
fn plant(h: &mut Harness) -> (WidgetId, WidgetId) {
|
|
let fixed = rect(Color::RED).width(600).add(&mut h.rsc);
|
|
let text = wtext("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.")
|
|
.size(16)
|
|
.wrap(true)
|
|
.width(LayoutLen::LEFTOVER)
|
|
.add(&mut h.rsc);
|
|
let content = (fixed, text).span(Dir::RIGHT).add(&mut h.rsc);
|
|
let scroll = Scroll::new(content.add_strong(&mut h.rsc), Axis::X);
|
|
h.set_root(scroll);
|
|
(text.id(), content.id())
|
|
}
|
|
|
|
let mut warm = Harness::new((900, 300));
|
|
let (text, content) = plant(&mut warm);
|
|
warm.rsc.widgets_mut().mark_for_redraw(text);
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((900, 300));
|
|
let (cold_text, cold_content) = plant(&mut cold);
|
|
|
|
assert_eq!(warm.region(&text), cold.region(&cold_text));
|
|
assert_eq!(warm.region(&content), cold.region(&cold_content));
|
|
}
|
|
|
|
/// A widget that clips to its box may not report more than the box: its
|
|
/// parent would place the part it cut off, and the framework would put a
|
|
/// drawing longer than its box somewhere. `Masked` is the second of these
|
|
/// after `Scroll`, and the assertion in `draw_at` is what says so.
|
|
#[test]
|
|
#[should_panic = "clips to"]
|
|
fn a_clipping_widget_reporting_more_than_its_box_is_caught() {
|
|
struct Clipper(StrongWidget);
|
|
|
|
impl Widget for Clipper {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
painter.set_mask(UiRegion::FULL);
|
|
painter.widget(&self.0).size()
|
|
}
|
|
}
|
|
|
|
let mut h = Harness::new((100, 100));
|
|
let tall = rect(Color::RED).height(400).add_strong(&mut h.rsc);
|
|
let clipper = Clipper(tall).add(&mut h.rsc);
|
|
// `set_root` lays the tree out, so this is where it is caught.
|
|
h.set_root(clipper);
|
|
}
|
|
|
|
/// Content that fits sits in the viewport, not in a box of the window's
|
|
/// length anchored at the viewport's start. `Part::From` takes window
|
|
/// lengths, so a `rel(1.0)` span in one is the window, and only a scroll
|
|
/// filling the window would land right.
|
|
#[test]
|
|
fn content_that_fits_is_placed_in_the_viewport_and_not_in_the_window() {
|
|
let mut h = Harness::new((400, 400));
|
|
let head = rect(Color::RED).height(100).add(&mut h.rsc);
|
|
let inner = rect(Color::BLUE).height(50).add(&mut h.rsc);
|
|
let scroll = Scroll::new(inner.add_strong(&mut h.rsc), Axis::Y).add(&mut h.rsc);
|
|
h.set_root((head, scroll).span(Dir::DOWN));
|
|
|
|
assert_corners!(h, scroll, (0, 100), (400, 400));
|
|
assert_corners!(h, inner, (0, 225), (400, 275));
|
|
}
|
|
|
|
/// A cap narrows the box the widget is asked in, which is what a scroll
|
|
/// measures its viewport from: the content scrolls within the cap rather than
|
|
/// within the room the cap was cut from.
|
|
#[test]
|
|
fn a_capped_scroll_takes_its_viewport_from_the_cap() {
|
|
let mut h = Harness::new((400, 200));
|
|
let top = rect(Color::RED).height(200).add(&mut h.rsc);
|
|
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
|
|
let scroll = (top, bottom).span(Dir::DOWN).scrollable().add(&mut h.rsc);
|
|
h.rsc.widgets_mut().set_max_len(scroll, Axis::Y, 100.into());
|
|
h.set_root(scroll);
|
|
h.move_to((200, 50));
|
|
|
|
// 400 of content in a viewport of 100, so 300 to scroll and the end
|
|
// showing: the top is 300 above the box, which the window centres.
|
|
assert_eq!(h.region(&scroll).unwrap().size().y, Px::from_int(100));
|
|
assert_corners!(h, top, (0, -250), (400, -50));
|
|
|
|
h.scroll((0, 1));
|
|
h.frame();
|
|
assert_corners!(h, top, (0, -200), (400, 0));
|
|
}
|