use rustc_hash::FxHashSet;
use super::*;
use crate::animation::{Animate, Keyframes, Repeat, SpringConfig, TimingFunction, Transition};
use crate::backdrop::BackdropSources;
use crate::jobs::{self, JobType};
use crate::layout::{Constraints, Flex, at_least, at_most, fill, fraction};
use crate::reactive::{Signal, create_signal};
use crate::renderer::{DrawCommand, RenderNode};
use crate::widgets::CornerRadii;
use crate::widgets::widget::{Event, EventResponse, MouseButton};
struct H {
tree: Tree,
root: WidgetId,
last: Option<(
std::rc::Rc<RenderNode>,
Vec<crate::renderer::FlattenedCommand>,
crate::renderer::FlattenScratch,
)>,
}
impl H {
fn new(widget: impl Widget + 'static) -> Self {
let mut tree = Tree::new();
let root = tree.register(Box::new(widget));
tree.with_widget_mut(root, |w, id, t| w.register_children(t, id));
Self {
tree,
root,
last: None,
}
}
fn layout(&mut self, constraints: Constraints) -> Size {
let root = self.root;
self.tree
.layout_widget(root, constraints)
.expect("root is registered")
}
fn measure(&mut self, constraints: Constraints) -> Size {
let root = self.root;
self.tree
.measure_widget(root, constraints)
.expect("root is registered")
}
fn fit(&mut self, w: f32, h: f32) -> Size {
self.layout(Constraints::new(0.0, 0.0, w, h))
}
fn advance_at(&mut self, now: std::time::Instant) -> bool {
self.tree.set_frame_instant(Some(now));
let root = self.root;
let moving = self
.tree
.with_widget_mut(root, |w, id, t| w.advance_animations(t, id))
.expect("root is registered");
self.tree.set_frame_instant(None);
moving
}
fn paint(&mut self) -> RenderNode {
let root = self.root;
let mut node = RenderNode::new(root.as_u64());
self.tree.paint_widget(root, &mut node, 1.0);
node
}
fn frame(&mut self, w: f32, h: f32) -> Frame {
pump(self);
self.fit(w, h);
let root = self.root;
if !self.tree.needs_paint(root)
&& let Some((node, commands, scratch)) = &self.last
{
let blur = crate::blur::regions_from_commands(commands, scratch.clip_chains());
let input = crate::region::input_ops_from_commands(commands, scratch.clip_chains());
return Frame {
node: std::rc::Rc::clone(node),
blur,
input,
};
}
let mut node = RenderNode::new(root.as_u64());
self.tree.paint_widget(root, &mut node, 1.0);
let node = std::rc::Rc::new(node);
let mut commands = Vec::new();
let mut layers = Vec::new();
let mut scratch = crate::renderer::FlattenScratch::default();
let _ = crate::renderer::flatten_root_into(&node, &mut commands, &mut layers, &mut scratch);
let blur = crate::blur::regions_from_commands(&commands, scratch.clip_chains());
let input = crate::region::input_ops_from_commands(&commands, scratch.clip_chains());
for child in &node.children {
crate::cache_paint_results(&mut self.tree, child);
}
self.tree.clear_needs_paint(root);
self.last = Some((std::rc::Rc::clone(&node), commands, scratch));
Frame { node, blur, input }
}
fn send(&mut self, event: Event) -> EventResponse {
let root = self.root;
crate::reactive::diagnostics::snapshot_zone(|| {
self.tree
.dispatch(root, &event)
.expect("root is registered")
})
}
fn children(&self) -> Vec<WidgetId> {
self.tree.get_children(self.root).to_vec()
}
fn roots(&self) -> FxHashSet<WidgetId> {
[self.root].into_iter().collect()
}
fn drain_jobs(&mut self) {
let roots = self.roots();
jobs::distribute_jobs(&self.tree, &roots);
jobs::recycle_job_buffer(jobs::drain_surface_jobs(self.root));
jobs::recycle_job_buffer(jobs::drain_orphan_jobs());
}
fn jobs_from(&mut self, write: impl FnOnce()) -> Vec<JobType> {
self.drain_jobs();
write();
let roots = self.roots();
jobs::distribute_jobs(&self.tree, &roots);
let drained = jobs::drain_surface_jobs(self.root);
let mut types: Vec<JobType> = drained
.iter()
.filter(|j| j.widget_id == self.root)
.map(|j| j.job_type)
.collect();
types.sort_by_key(|t| format!("{t:?}"));
types.dedup();
jobs::recycle_job_buffer(drained);
types
}
}
struct Frame {
node: std::rc::Rc<RenderNode>,
blur: Vec<crate::region::RegionRect>,
input: Vec<crate::region::InputRegionOp>,
}
fn box_of(w: f32, h: f32) -> Container {
container().width(w).height(h)
}
fn rects(node: &RenderNode) -> Vec<(Rect, Color)> {
node.commands
.iter()
.filter_map(|c| match &**c {
DrawCommand::RoundedRect { rect, color, .. } => Some((*rect, *color)),
_ => None,
})
.collect()
}
fn borders(node: &RenderNode) -> Vec<(f32, Color)> {
node.commands
.iter()
.filter_map(|c| match &**c {
DrawCommand::RoundedRect {
border: Some(border),
..
} => Some((border.width, border.color)),
_ => None,
})
.collect()
}
#[test]
fn a_scaling_state_layer_declares_only_a_scale() {
let c = container()
.when_hovered(|s| s.lighter(0.1))
.when_pressed(|s| s.scale(0.98));
let declared = c
.interaction
.as_ref()
.map(|ix| ix.declares_transform)
.unwrap_or_default();
assert!(declared.scale, "the pressed layer names a scale");
assert!(!declared.translate, "and nothing names a translate");
assert!(!declared.rotate, "or a rotate");
}
#[test]
fn a_translating_or_rotating_layer_declares_only_its_own_component() {
let declares = |c: &Container| {
c.interaction
.as_ref()
.map(|ix| ix.declares_transform)
.unwrap_or_default()
};
let translating =
declares(&container().when_hovered(|s| s.translate(Translate::new(0.0, -2.0))));
assert!(translating.translate, "the hovered layer names a translate");
assert!(
!translating.rotate && !translating.scale,
"and nothing else"
);
let rotating = declares(&container().when_pressed(|s| s.rotate(2.0)));
assert!(rotating.rotate, "the pressed layer names a rotate");
assert!(!rotating.translate && !rotating.scale, "and nothing else");
}
#[test]
fn content_sizing_is_child_plus_padding() {
let mut h = H::new(container().padding(8.0).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0), Size::new(56.0, 36.0));
}
#[test]
fn exact_length_wins_over_content() {
let mut h = H::new(container().width(60.0).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 60.0);
}
#[test]
fn fill_takes_the_whole_offered_axis() {
let mut h = H::new(container().width(fill()).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 500.0);
}
#[test]
fn a_hidden_sibling_takes_no_spacing_from_a_filling_one() {
use crate::layout::flex_layout::MIN_VISIBLE_SIZE;
let laid_out = |column: bool, children: Vec<Container>| {
let flex = if column { Flex::column() } else { Flex::row() };
let mut h = H::new(
container()
.width(300.0)
.height(300.0)
.layout(flex.spacing(8.0))
.children(children),
);
h.fit(500.0, 500.0);
h.children()
.iter()
.map(|&child| {
let r = h.tree.get_bounds(child).unwrap();
if column {
(r.y, r.height)
} else {
(r.x, r.width)
}
})
.collect::<Vec<_>>()
};
let filling = |column: bool| {
if column {
container().height(fill()).width(20.0)
} else {
container().width(fill()).height(20.0)
}
};
let badge = |shown: bool, long: f32| container().visible(shown).width(long).height(long);
for column in [false, true] {
assert_eq!(
laid_out(column, vec![filling(column), badge(false, 40.0)])[0],
(0.0, 300.0),
"column: {column}, badge hidden"
);
assert_eq!(
laid_out(column, vec![filling(column), badge(true, 40.0)]),
[(0.0, 252.0), (260.0, 40.0)],
"column: {column}, badge shown"
);
}
assert_eq!(
laid_out(
false,
vec![filling(false), badge(false, 40.0), filling(false)]
),
[(0.0, 146.0), (146.0, 0.0), (154.0, 146.0)]
);
assert_eq!(
laid_out(false, vec![filling(false), badge(true, MIN_VISIBLE_SIZE)])[0],
(0.0, 300.0 - MIN_VISIBLE_SIZE)
);
}
#[test]
fn fraction_resolves_against_the_incoming_max() {
let mut h = H::new(container().width(fraction(0.5)).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 250.0);
}
#[test]
fn at_least_raises_a_smaller_content() {
let mut h = H::new(container().width(at_least(100.0)).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 100.0);
}
#[test]
fn at_least_grows_to_a_larger_content() {
let mut h = H::new(container().width(at_least(55.0)).child(box_of(100.0, 10.0)));
assert_eq!(h.fit(400.0, 400.0).width, 100.0);
let inner = h.children()[0];
assert_eq!(h.tree.get_bounds(inner).unwrap().width, 100.0);
}
#[test]
fn at_least_stretches_a_smaller_content_across_it() {
let mut h = H::new(
container()
.width(at_least(55.0))
.child(container().height(10.0)),
);
assert_eq!(h.fit(400.0, 400.0).width, 55.0);
let inner = h.children()[0];
assert_eq!(h.tree.get_bounds(inner).unwrap().width, 55.0);
}
#[test]
fn at_most_caps_a_larger_content() {
let mut h = H::new(container().width(at_most(30.0)).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 30.0);
}
#[test]
fn explicit_width_ignores_the_parent_minimum() {
let mut h = H::new(container().width(60.0).child(box_of(40.0, 20.0)));
assert_eq!(
h.layout(Constraints::new(200.0, 0.0, 500.0, 500.0)).width,
60.0
);
}
#[test]
fn content_sizing_obeys_the_parent_minimum() {
let mut h = H::new(container().child(box_of(40.0, 20.0)));
assert_eq!(
h.layout(Constraints::new(200.0, 0.0, 500.0, 500.0)).width,
200.0
);
}
#[test]
fn the_default_layout_stacks_children_in_a_column() {
let mut h = H::new(
container()
.child(box_of(40.0, 20.0))
.child(box_of(10.0, 30.0)),
);
assert_eq!(h.fit(500.0, 500.0), Size::new(40.0, 50.0));
}
#[test]
fn a_row_sums_along_the_main_axis() {
let mut h = H::new(
container()
.layout(Flex::row().spacing(4.0))
.child(box_of(40.0, 20.0))
.child(box_of(10.0, 30.0)),
);
assert_eq!(h.fit(500.0, 500.0), Size::new(54.0, 30.0));
}
#[test]
fn padding_shrinks_what_children_are_offered() {
let mut h = H::new(
container()
.width(100.0)
.padding(10.0)
.child(container().width(fill()).height(5.0)),
);
h.fit(500.0, 500.0);
let child = h.children()[0];
assert_eq!(h.tree.get_bounds(child).unwrap().width, 80.0);
}
#[test]
fn padding_past_its_own_maximum_grows_a_visible_box_and_not_a_hidden_one() {
let padded = |overflow: Overflow| {
container()
.width(at_most(100.0))
.padding(70.0)
.overflow(overflow)
};
let mut visible = H::new(padded(Overflow::Visible));
assert_eq!(
visible.fit(500.0, 500.0).width,
140.0,
"a box that may not shrink grows back to its content"
);
let mut hidden = H::new(padded(Overflow::Hidden));
assert_eq!(
hidden.fit(500.0, 500.0).width,
100.0,
"a box that may shrink keeps to its own maximum"
);
}
#[test]
fn overflowing_content_grows_a_visible_container() {
let mut h = H::new(container().child(box_of(400.0, 10.0)));
assert_eq!(h.fit(100.0, 500.0).width, 100.0, "still clamped to the max");
let mut hidden = H::new(
container()
.overflow(Overflow::Hidden)
.child(box_of(400.0, 10.0)),
);
assert_eq!(hidden.fit(100.0, 500.0).width, 100.0);
}
#[test]
fn at_most_bounds_children_with_or_without_a_size_animation() {
use crate::animation::{TimingFunction, Transition};
for animated in [false, true] {
let inner = container().width(fill()).height(5.0);
let outer = if animated {
container()
.width(at_most(60.0).transition(Transition::new(200, TimingFunction::EaseOut)))
} else {
container().width(at_most(60.0))
};
let mut h = H::new(outer.child(inner));
h.fit(500.0, 500.0);
let child = h.children()[0];
assert_eq!(
h.tree.get_bounds(child).unwrap().width,
60.0,
"child must be bounded by the cap (animated: {animated})"
);
assert_eq!(h.tree.cached_size(h.root).unwrap().width, 60.0);
}
}
#[test]
fn a_size_animation_does_not_change_how_content_wraps() {
use crate::animation::{TimingFunction, Transition};
use crate::widgets::text::text;
let sentence = "una frase abbastanza lunga da dover andare a capo piu volte";
let mut plain = H::new(container().width(at_most(100.0)).child(text(sentence)));
let plain_size = plain.fit(500.0, 500.0);
let mut animated = H::new(
container()
.width(at_most(100.0).transition(Transition::new(200, TimingFunction::EaseOut)))
.child(text(sentence)),
);
let animated_size = animated.fit(500.0, 500.0);
assert_eq!(
plain_size, animated_size,
"an attached animation must not change how the content wraps"
);
}
fn centred_box(subject: Container, constraints: Constraints) -> (Size, (f32, f32)) {
let mut h = H::new(
subject
.layout(Flex::row().center())
.child(box_of(20.0, 20.0)),
);
let size = h.layout(constraints);
let child = h.tree.get_bounds(h.children()[0]).unwrap();
(size, (child.x, child.y))
}
#[test]
fn an_exact_length_that_is_cut_centres_its_children_in_what_is_left() {
let loose = |side: f32| Constraints::new(0.0, 0.0, side, side);
let cases = [
(
"under the parent's maximum",
container().width(200.0).height(200.0),
container().width(100.0).height(100.0),
loose(100.0),
),
(
"padded, under the parent's maximum",
container().width(200.0).height(200.0).padding(10.0),
container().width(100.0).height(100.0).padding(10.0),
loose(100.0),
),
(
"under its own maximum",
container()
.width(Length::exact(200.0).at_most(100.0))
.height(Length::exact(200.0).at_most(100.0)),
container().width(100.0).height(100.0),
loose(500.0),
),
(
"over its own minimum",
container()
.width(Length::exact(50.0).at_least(100.0))
.height(Length::exact(50.0).at_least(100.0)),
container().width(100.0).height(100.0),
loose(500.0),
),
];
for (case, subject, control, constraints) in cases {
let (size, origin) = centred_box(subject, constraints);
let (control_size, control_origin) = centred_box(control, constraints);
assert_eq!(size, Size::new(100.0, 100.0), "{case}: the box is cut");
assert_eq!(size, control_size, "{case}: as big as the control");
assert_eq!(
origin,
(40.0, 40.0),
"{case}: the child is centred in the box on both axes"
);
assert_eq!(origin, control_origin, "{case}: where the control puts it");
}
}
#[test]
fn an_exact_length_under_a_larger_parent_minimum_centres_in_its_own_size() {
let (size, origin) = centred_box(
container().width(60.0).height(60.0),
Constraints::new(150.0, 150.0, 500.0, 500.0),
);
assert_eq!(size, Size::new(60.0, 60.0));
assert_eq!(origin, (20.0, 20.0), "centred in 60, not in 150");
}
fn linear_100ms() -> Transition {
Transition::new(100.0, TimingFunction::Linear)
}
fn halfway_to_300(h: &mut H, target: RwSignal<f32>, parent: f32) {
h.fit(parent, parent);
let t0 = std::time::Instant::now();
target.set(300.0);
frame_at(h, t0, parent, parent);
frame_at(h, t0 + std::time::Duration::from_millis(50), parent, parent);
}
fn centred_box_in_flight(parent: f32) -> (Size, (f32, f32)) {
let target = create_signal(100.0f32);
let mut h = H::new(
container()
.width(target.transition(linear_100ms()))
.height(target.transition(linear_100ms()))
.layout(Flex::row().center())
.child(box_of(20.0, 20.0)),
);
halfway_to_300(&mut h, target, parent);
let child = h.tree.get_bounds(h.children()[0]).unwrap();
(h.tree.cached_size(h.root).unwrap(), (child.x, child.y))
}
#[test]
fn an_exact_length_in_flight_past_the_parent_maximum_centres_in_what_is_left() {
assert_eq!(
centred_box_in_flight(100.0),
(Size::new(100.0, 100.0), (40.0, 40.0)),
"the box is cut, and the child is centred in it on both axes"
);
}
#[test]
fn an_exact_length_in_flight_under_a_larger_parent_centres_in_its_own_size() {
assert_eq!(
centred_box_in_flight(500.0),
(Size::new(200.0, 200.0), (90.0, 90.0))
);
}
#[test]
fn an_unplaced_size_animation_offers_the_declared_length() {
let side = create_signal(100.0f32);
let mut h = H::new(
container()
.width(side.transition(linear_100ms()))
.height(20.0)
.layout(Flex::row().center())
.child(box_of(20.0, 20.0)),
);
side.set(200.0);
h.fit(500.0, 500.0);
let child = h.tree.get_bounds(h.children()[0]).unwrap();
assert_eq!(h.tree.cached_size(h.root).unwrap().width, 200.0);
assert_eq!(child.x, 90.0, "centred in the declared 200");
}
#[test]
fn an_invisible_container_measures_zero() {
let mut h = H::new(container().visible(false).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0), Size::zero());
}
#[test]
fn a_clean_relayout_with_equal_constraints_is_skipped() {
let mut h = H::new(container().padding(8.0).child(box_of(40.0, 20.0)));
let first = h.fit(500.0, 500.0);
let child = h.children()[0];
h.tree.set_origin(child, 999.0, 999.0);
let second = h.fit(500.0, 500.0);
assert_eq!(second, first);
assert_eq!(
h.tree.get_bounds(child).unwrap().x,
999.0,
"the skipped layout must not have repositioned children"
);
}
#[test]
fn changed_constraints_force_a_relayout() {
let mut h = H::new(container().width(fill()).child(box_of(40.0, 20.0)));
assert_eq!(h.fit(500.0, 500.0).width, 500.0);
assert_eq!(h.fit(300.0, 500.0).width, 300.0);
}
#[test]
fn fixed_size_makes_a_relayout_boundary() {
let mut h = H::new(
container()
.width(100.0)
.height(50.0)
.child(box_of(40.0, 20.0)),
);
h.fit(500.0, 500.0);
assert!(h.tree.is_relayout_boundary(h.root));
let mut loose = H::new(container().child(box_of(40.0, 20.0)));
loose.fit(500.0, 500.0);
assert!(!loose.tree.is_relayout_boundary(loose.root));
}
#[test]
fn a_moving_colour_does_not_stop_a_fixed_box_being_a_boundary() {
let colour = create_signal(Color::RED);
let mut h = H::new(
container()
.width(100.0)
.height(50.0)
.background(colour.transition(Transition::new(100.0, TimingFunction::Linear)))
.child(box_of(40.0, 20.0)),
);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 500.0, 500.0);
colour.set(Color::BLUE);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(20),
500.0,
500.0,
);
h.paint();
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(40),
500.0,
500.0,
);
assert!(
h.tree.is_relayout_boundary(h.root),
"a background halfway through its transition moves nothing, so the \
parent has nothing to re-measure"
);
}
fn painted_rects_with_style(node: &RenderNode) -> Vec<(Color, CornerRadii)> {
fn walk(node: &RenderNode, out: &mut Vec<(Color, CornerRadii)>) {
for cmd in &node.commands {
if let DrawCommand::RoundedRect { color, radius, .. } = &**cmd {
out.push((*color, *radius));
}
}
for child in &node.children {
walk(child, out);
}
}
let mut out = Vec::new();
walk(node, &mut out);
out
}
fn scroller(scroll: Scroll) -> H {
H::new(
container()
.height(100.0)
.scroll(scroll)
.child(box_of(40.0, 900.0)),
)
}
#[test]
fn a_handle_colour_declared_with_a_signal_follows_it() {
let hot = create_signal(Color::rgb(1.0, 0.0, 0.0));
let mut h = scroller(Scroll::vertical().handle(move |c: Container| c.background(hot)));
h.fit(500.0, 500.0);
let before = painted_rects_with_style(&h.paint());
assert!(
before.iter().any(|(c, _)| c.r > 0.9 && c.g < 0.1),
"the declared handle colour has to reach the paint: {before:?}"
);
hot.set(Color::rgb(0.0, 0.0, 1.0));
pump(&mut h);
h.fit(500.0, 500.0);
let after = painted_rects_with_style(&h.paint());
assert!(
after.iter().any(|(c, _)| c.b > 0.9 && c.r < 0.1),
"the handle kept the colour it was built with: {after:?}"
);
}
#[test]
fn a_handle_takes_corners_the_old_pair_could_not_spell() {
let mut h = scroller(Scroll::vertical().handle(|c: Container| {
c.background(Color::rgb(1.0, 0.0, 0.0))
.corners([8.0, 0.0, 8.0, 0.0])
}));
h.fit(500.0, 500.0);
let (_, radii) = painted_rects_with_style(&h.paint())
.into_iter()
.find(|(c, _)| c.r > 0.9 && c.g < 0.1)
.expect("the handle, found by its declared colour");
assert!(
radii.top_left > 7.9 && radii.top_right < 0.1,
"a per-corner radius has to survive to paint, got {radii:?}"
);
}
#[test]
fn visibility_answers_to_a_signal() {
let shown = create_signal(ScrollbarVisibility::Hidden);
let mut h = scroller(Scroll::vertical().visibility(shown));
h.fit(500.0, 500.0);
let hidden_children = h.paint().children.len();
shown.set(ScrollbarVisibility::Always);
pump(&mut h);
h.fit(500.0, 500.0);
let shown_children = h.paint().children.len();
assert!(
shown_children > hidden_children,
"the scrollbar never came back: {hidden_children} children hidden, \
{shown_children} shown"
);
shown.set(ScrollbarVisibility::Hidden);
pump(&mut h);
h.fit(500.0, 500.0);
assert_eq!(
h.paint().children.len(),
hidden_children,
"and it goes away again"
);
}
#[test]
fn a_reserved_gutter_is_the_bar_and_a_margin_on_each_side() {
fn content_width(width: f32, margin: f32) -> f32 {
let mut h = H::new(
container()
.width(200.0)
.height(100.0)
.scroll(Scroll::vertical().width(width).margin(margin))
.child(container().width(fill()).height(900.0)),
);
h.fit(500.0, 500.0);
h.paint().children[0].bounds.width
}
let wide_margin = content_width(6.0, 2.0);
let no_margin = content_width(6.0, 0.0);
assert_eq!(
no_margin - wide_margin,
4.0,
"two margins of 2 is 4px of content, so the margin is counted on both \
sides: {no_margin} against {wide_margin}"
);
}
#[test]
fn a_one_axis_scroller_builds_one_axis_worth_of_parts() {
let mut vertical = scroller(Scroll::vertical());
vertical.fit(500.0, 500.0);
let one_axis = vertical.tree.get_children(vertical.root).len();
let mut both = H::new(
container()
.width(100.0)
.height(100.0)
.scroll(Scroll::both())
.child(box_of(900.0, 900.0)),
);
both.fit(500.0, 500.0);
let two_axes = both.tree.get_children(both.root).len();
assert_eq!(
two_axes - one_axis,
2,
"one axis is a track and a handle, so two axes is exactly two more: \
{one_axis} against {two_axes}"
);
}
#[test]
fn an_overflowing_scroller_paints_its_scrollbar() {
let mut h = scroller(Scroll::vertical());
h.fit(500.0, 500.0);
let node = h.paint();
assert!(
node.children.len() > 1,
"expected the content plus a scrollbar, got {} node(s)",
node.children.len()
);
}
#[test]
fn an_overflowing_horizontal_scroller_paints_its_scrollbar() {
let mut h = H::new(
container()
.width(100.0)
.scroll(Scroll::horizontal())
.child(box_of(900.0, 40.0)),
);
h.fit(500.0, 500.0);
assert!(h.paint().children.len() > 1);
}
#[test]
fn a_scroller_whose_content_fits_paints_no_scrollbar() {
let mut h = H::new(
container()
.height(100.0)
.scroll(Scroll::vertical())
.child(box_of(40.0, 20.0)),
);
h.fit(500.0, 500.0);
assert_eq!(
h.paint().children.len(),
1,
"only the content should be painted"
);
}
#[test]
fn a_vertical_scroller_offers_children_unbounded_height() {
let mut h = H::new(
container()
.height(100.0)
.scroll(Scroll::vertical())
.child(box_of(40.0, 900.0)),
);
assert_eq!(
h.fit(500.0, 500.0).height,
100.0,
"the viewport stays fixed"
);
let child = h.children()[0];
assert_eq!(
h.tree.get_bounds(child).unwrap().height,
900.0,
"the content keeps its natural height"
);
}
fn wheeled(subject: Container, scroll: Scroll, parent: f32) -> (bool, f32, f32) {
let mut h = H::new(
subject
.scroll(scroll)
.child(container().width(fill()).height(150.0)),
);
h.fit(parent, parent);
reach_of_a_notch(&mut h)
}
fn reach_of_a_notch(h: &mut H) -> (bool, f32, f32) {
let before = h.paint();
let scrollbar = before.children.len() > 1;
let content = &before.children[0];
let (top, width) = (content.local_transform.ty(), content.bounds.width);
h.send(Event::scroll(
20.0,
20.0,
0.0,
50.0,
crate::widgets::widget::ScrollSource::Wheel,
));
let after = h.paint().children[0].local_transform.ty();
(scrollbar, top - after, width)
}
#[test]
fn an_exact_length_that_is_cut_scrolls_what_it_cannot_show() {
let plain = Scroll::vertical;
let gutter = || Scroll::vertical().reserve_gutter(true);
let cases = [
(
"under the parent's maximum",
container().width(200.0).height(200.0),
container().width(100.0).height(100.0),
plain as fn() -> Scroll,
100.0,
),
(
"padded, under the parent's maximum",
container().width(220.0).height(220.0).padding(10.0),
container().width(120.0).height(120.0).padding(10.0),
plain,
120.0,
),
(
"with a reserved gutter, under the parent's maximum",
container().width(200.0).height(200.0),
container().width(100.0).height(100.0),
gutter,
100.0,
),
(
"under its own maximum",
container()
.width(Length::exact(200.0).at_most(100.0))
.height(Length::exact(200.0).at_most(100.0)),
container().width(100.0).height(100.0),
plain,
500.0,
),
];
for (case, subject, control, scroll, parent) in cases {
let (scrollbar, travel, width) = wheeled(subject, scroll(), parent);
let (_, control_travel, control_width) = wheeled(control, scroll(), parent);
assert!(scrollbar, "{case}: 150 in a 100 viewport needs a scrollbar");
assert_eq!(travel, 50.0, "{case}: the wheel reaches the last 50px");
assert_eq!(travel, control_travel, "{case}: as the control scrolls");
assert_eq!(width, control_width, "{case}: and is offered its width");
}
}
fn wheeled_in_flight(parent: f32) -> (bool, f32, f32) {
let target = create_signal(100.0f32);
let mut h = H::new(
container()
.width(100.0)
.height(target.transition(linear_100ms()))
.scroll(Scroll::vertical())
.child(container().width(fill()).height(150.0)),
);
halfway_to_300(&mut h, target, parent);
reach_of_a_notch(&mut h)
}
#[test]
fn an_exact_length_in_flight_past_the_parent_maximum_scrolls_what_it_cannot_show() {
let (scrollbar, travel, _) = wheeled_in_flight(100.0);
assert!(scrollbar, "150 in a 100 viewport needs a scrollbar");
assert_eq!(travel, 50.0, "the wheel reaches the last 50px");
}
#[test]
fn an_exact_length_in_flight_under_a_larger_parent_has_nothing_to_scroll() {
let (scrollbar, travel, _) = wheeled_in_flight(500.0);
assert!(!scrollbar, "150 fits in 200");
assert_eq!(travel, 0.0);
}
#[test]
fn a_background_emits_one_rounded_rect() {
let mut h = H::new(container().width(50.0).height(20.0).background(Color::RED));
h.fit(500.0, 500.0);
let node = h.paint();
let drawn = rects(&node);
assert_eq!(drawn.len(), 1);
assert_eq!(drawn[0].0, Rect::new(0.0, 0.0, 50.0, 20.0));
assert_eq!(drawn[0].1, Color::RED);
}
#[test]
fn a_fully_transparent_background_draws_nothing() {
let mut h = H::new(container().width(50.0).height(20.0));
h.fit(500.0, 500.0);
assert!(rects(&h.paint()).is_empty());
}
#[test]
fn a_border_is_its_own_command() {
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.background(Color::RED)
.border(2.0, Color::BLUE),
);
h.fit(500.0, 500.0);
assert_eq!(rects(&h.paint()).len(), 2, "background + border frame");
}
#[test]
fn an_invisible_container_paints_nothing() {
let mut h = H::new(
container()
.visible(false)
.width(50.0)
.height(20.0)
.background(Color::RED)
.child(box_of(10.0, 10.0)),
);
h.fit(500.0, 500.0);
let node = h.paint();
assert!(node.commands.is_empty());
assert!(node.children.is_empty());
}
#[test]
fn children_become_child_nodes_positioned_by_transform() {
let mut h = H::new(
container()
.padding(8.0)
.child(box_of(40.0, 20.0).background(Color::RED)),
);
h.fit(500.0, 500.0);
let node = h.paint();
assert_eq!(node.children.len(), 1);
let child = &node.children[0];
assert_eq!(child.bounds, Rect::new(0.0, 0.0, 40.0, 20.0));
assert_eq!(
(child.local_transform.tx(), child.local_transform.ty()),
(8.0, 8.0)
);
}
#[test]
fn hidden_overflow_sets_a_clip_region() {
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.overflow(Overflow::Hidden)
.child(box_of(400.0, 10.0)),
);
h.fit(500.0, 500.0);
assert!(h.paint().clip.is_some());
let mut visible = H::new(
container()
.width(50.0)
.height(20.0)
.child(box_of(400.0, 10.0)),
);
visible.fit(500.0, 500.0);
assert!(visible.paint().clip.is_none());
}
#[test]
fn a_zero_area_container_skips_its_children() {
let mut h = H::new(container().width(0.0).height(0.0).child(box_of(40.0, 20.0)));
h.fit(500.0, 500.0);
assert!(h.paint().children.is_empty());
}
fn click_at(x: f32, y: f32) -> [Event; 2] {
[
Event::mouse_down(x, y, MouseButton::Left),
Event::mouse_up(x, y, MouseButton::Left),
]
}
#[test]
fn a_click_inside_the_bounds_fires_on_click() {
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.on_click(move || counter.set(counter.get() + 1)),
);
h.fit(500.0, 500.0);
for e in click_at(25.0, 10.0) {
h.send(e);
}
assert_eq!(clicks.get(), 1);
for e in click_at(200.0, 10.0) {
h.send(e);
}
assert_eq!(clicks.get(), 1, "a click outside must not fire");
}
#[test]
fn the_right_and_middle_buttons_fire_the_handlers_that_name_them() {
let right = std::rc::Rc::new(std::cell::Cell::new(0));
let middle = std::rc::Rc::new(std::cell::Cell::new(0));
let (r, m) = (right.clone(), middle.clone());
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.on_right_click(move || r.set(r.get() + 1))
.on_middle_click(move || m.set(m.get() + 1)),
);
h.fit(500.0, 500.0);
h.send(Event::mouse_down(25.0, 10.0, MouseButton::Right));
assert_eq!((right.get(), middle.get()), (1, 0), "a right press");
h.send(Event::mouse_down(25.0, 10.0, MouseButton::Middle));
assert_eq!((right.get(), middle.get()), (1, 1), "a middle press");
h.send(Event::mouse_down(25.0, 10.0, MouseButton::Left));
assert_eq!(
(right.get(), middle.get()),
(1, 1),
"a left press belongs to on_click, and must not reach either"
);
h.send(Event::mouse_down(200.0, 10.0, MouseButton::Right));
assert_eq!(right.get(), 1, "a right press outside must not fire");
}
#[test]
fn a_corner_radius_excludes_the_corner() {
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.corners(20.0)
.on_click(move || counter.set(counter.get() + 1)),
);
h.fit(500.0, 500.0);
for e in click_at(1.0, 1.0) {
h.send(e);
}
assert_eq!(clicks.get(), 0, "the top-left corner is outside the circle");
for e in click_at(20.0, 20.0) {
h.send(e);
}
assert_eq!(clicks.get(), 1);
}
#[test]
fn hover_tracks_entering_and_leaving() {
let state = std::rc::Rc::new(std::cell::Cell::new(false));
let sink = state.clone();
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.on_hover(move |hovered| sink.set(hovered)),
);
h.fit(500.0, 500.0);
h.send(Event::mouse_move(25.0, 10.0));
assert!(state.get());
h.send(Event::mouse_move(300.0, 10.0));
assert!(!state.get());
}
#[test]
fn a_child_handles_before_its_parent() {
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let child_sink = order.clone();
let parent_sink = order.clone();
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.on_click(move || parent_sink.borrow_mut().push("parent"))
.child(
container()
.width(50.0)
.height(50.0)
.on_click(move || child_sink.borrow_mut().push("child")),
),
);
h.fit(500.0, 500.0);
for e in click_at(25.0, 25.0) {
h.send(e);
}
assert_eq!(*order.borrow(), vec!["child"]);
}
#[test]
fn scroll_reaches_the_callback_inside_the_bounds() {
let deltas = std::rc::Rc::new(std::cell::Cell::new(0.0f32));
let sink = deltas.clone();
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.on_scroll(move |_dx, dy, _src| sink.set(sink.get() + dy)),
);
h.fit(500.0, 500.0);
h.send(Event::scroll(
25.0,
10.0,
0.0,
12.0,
crate::widgets::widget::ScrollSource::Wheel,
));
assert_eq!(deltas.get(), 12.0);
}
#[cfg(debug_assertions)]
#[test]
fn advancing_animations_does_not_report_a_missing_scope() {
use crate::animation::{TimingFunction, Transition};
use crate::reactive::diagnostics::report_count;
let t = || Transition::new(200, TimingFunction::EaseOut);
let wide = create_signal(false);
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.background(Color::RED)
.border(
(move || if wide.get() { 6.0 } else { 2.0 }).transition(t()),
Color::BLUE,
)
.corners((move || if wide.get() { 20.0 } else { 4.0 }).transition(t())),
);
h.fit(200.0, 200.0);
h.paint();
let before = report_count();
let root = h.root;
h.tree.set_frame_instant(Some(std::time::Instant::now()));
h.tree.with_widget_mut(root, |w, id, tree| {
w.advance_animations(tree, id);
});
h.tree.set_frame_instant(None);
assert_eq!(
report_count() - before,
0,
"advancing animations must not be reported as a non-reactive read"
);
let queued = h.jobs_from(|| wide.set(true));
assert!(
queued.contains(&JobType::Animation),
"and the properties must genuinely be subscribed, got {queued:?}"
);
}
#[test]
fn a_border_width_animation_counts_as_movable_by_a_state_layer() {
let animated = container()
.border(
2.0.transition(Transition::spring(SpringConfig::BOUNCY)),
Color::BLUE,
)
.when_hovered(|s| s.border(14.0, Color::BLUE));
assert!(
animated.has_animated_state_properties(),
"hovering moves the width, so it needs an Animation job"
);
let plain = container()
.border(2.0, Color::BLUE)
.when_hovered(|s| s.border(14.0, Color::BLUE));
assert!(
!plain.has_animated_state_properties(),
"with nothing animated, a plain repaint is the whole job"
);
}
#[test]
fn a_state_layer_moving_an_animated_transform_counts_as_movable() {
let t = || Transition::new(80.0, TimingFunction::Linear);
assert!(
container()
.translate(Translate::NONE.transition(t()))
.when_hovered(|s| s.translate(Translate::new(0.0, -2.0)))
.has_animated_state_properties(),
"hovering moves the translate, so it needs an Animation job"
);
assert!(
container()
.rotate(0.0.transition(t()))
.when_hovered(|s| s.rotate(2.0))
.has_animated_state_properties(),
"and a rotate"
);
assert!(
container()
.scale(Scale::NONE.transition(t()))
.when_pressed(|s| s.scale(0.98))
.has_animated_state_properties(),
"and a scale — the layer on every button there is"
);
assert!(
!container()
.when_pressed(|s| s.scale(0.98))
.has_animated_state_properties(),
"and with nothing animated, a plain repaint is the whole job"
);
}
#[test]
fn a_declared_shadow_starts_at_its_value_rather_than_easing_up_to_it() {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(LIFTED.transition(Transition::new(60_000.0, TimingFunction::Linear))),
);
h.fit(100.0, 100.0);
assert_eq!(
drawn_shadow(&h.paint()),
Some(LIFTED),
"a declaration is where the motion starts, not where it is heading"
);
}
#[test]
fn a_shadow_written_before_the_first_paint_still_asks_for_its_frame() {
let declared = create_signal(Shadow::none());
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(
(move || declared.get()).transition(Transition::new(40.0, TimingFunction::Linear)),
),
);
h.fit(100.0, 100.0);
h.drain_jobs();
declared.set(LIFTED);
let queued = jobs::queued_job_types(h.root);
assert!(
queued.contains(&JobType::Animation),
"the write landed before any paint and nothing was listening: {queued:?}"
);
}
#[test]
fn a_background_written_before_the_first_paint_still_asks_for_its_frame() {
let declared = create_signal(Color::RED);
let mut h = H::new(container().width(40.0).height(40.0).background(
(move || declared.get()).transition(Transition::new(40.0, TimingFunction::Linear)),
));
h.fit(100.0, 100.0);
h.drain_jobs();
declared.set(Color::BLUE);
let queued = jobs::queued_job_types(h.root);
assert!(
queued.contains(&JobType::Animation),
"the write landed before any paint and nothing was listening: {queued:?}"
);
}
#[test]
fn corners_alone_are_an_animated_state_property() {
assert!(
container()
.corners(4.0.transition(Transition::new(80.0, TimingFunction::Linear)))
.when_hovered(|s| s.corners(12.0))
.has_animated_state_properties(),
"hovering moves the corners, so it needs an Animation job"
);
}
#[test]
fn a_settled_shadow_stops_asking_for_frames() {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(LIFTED.transition(Transition::new(40.0, TimingFunction::Linear))),
);
h.fit(100.0, 100.0);
settle(&mut h, 200);
h.drain_jobs();
h.paint();
let queued = jobs::queued_job_types(h.root);
assert!(
!queued.contains(&JobType::Animation),
"a shadow that has arrived asked for another frame: {queued:?}"
);
}
#[test]
fn a_shadow_alone_is_an_animated_property_on_both_lists() {
let t = || Transition::new(80.0, TimingFunction::Linear);
assert!(
container()
.shadow(Shadow::none().transition(t()))
.when_hovered(|s| s.shadow(LIFTED))
.has_animated_state_properties(),
"hovering moves the shadow, so it needs an Animation job"
);
assert!(
!container()
.shadow(LIFTED)
.when_hovered(|s| s.shadow(LOW))
.has_animated_state_properties(),
"and with no motion declared, a plain repaint is the whole job"
);
assert!(
container()
.shadow(Shadow::none().transition(t()))
.follows_a_signal_anywhere(),
"a shadow animation mirrors a signal, so it re-syncs at paint"
);
assert!(
!container()
.padding(Padding::all(0.0).transition(t()))
.when_hovered(|s| s.lighter(0.1))
.has_animated_state_properties(),
"padding is the property that answers differently to the two halves of \
this question — it does follow a signal, and it does move the box — so \
it is the one that says hovering has nothing here to animate"
);
}
#[test]
fn a_shadow_exactly_at_the_alpha_floor_is_not_drawn() {
let alpha = create_signal(style::SHADOW_ALPHA_FLOOR);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(move || {
Shadow::new(
(0.0, 2.0),
4.0,
0.0,
Color::rgba(0.0, 0.0, 0.0, alpha.get()),
)
}),
);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_none(),
"at the floor exactly, there is nothing to see"
);
alpha.set(style::SHADOW_ALPHA_FLOOR * 1.5);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_some(),
"and just above it there is"
);
}
#[test]
fn each_animated_transform_component_is_a_signal_animated_prop() {
let t = || Transition::new(80.0, TimingFunction::Linear);
assert!(
container()
.translate(Translate::NONE.transition(t()))
.follows_a_signal_anywhere(),
"a translate animation mirrors a signal, so it re-syncs at paint"
);
assert!(
container()
.rotate(0.0.transition(t()))
.follows_a_signal_anywhere(),
"and a rotate"
);
assert!(
container()
.scale(Scale::NONE.transition(t()))
.follows_a_signal_anywhere(),
"and a scale"
);
assert!(
!container()
.width(0.0.transition(t()))
.follows_a_signal_anywhere(),
"a width follows the content it measured, and is retargeted at layout"
);
}
#[test]
fn padding_invalidates_layout() {
let pad = create_signal(4.0f32);
let mut h = H::new(
container()
.padding(move || pad.get())
.child(box_of(10.0, 10.0)),
);
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| pad.set(12.0));
assert!(
queued.contains(&JobType::Layout),
"padding must invalidate layout, got {queued:?}"
);
}
#[test]
fn width_invalidates_layout() {
let w = create_signal(10.0f32);
let mut h = H::new(container().width(move || w.get()).child(box_of(10.0, 10.0)));
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| w.set(40.0));
assert!(queued.contains(&JobType::Layout), "got {queued:?}");
}
#[test]
fn background_invalidates_paint_only() {
let bg = create_signal(Color::RED);
let mut h = H::new(
container()
.width(10.0)
.height(10.0)
.background(move || bg.get()),
);
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| bg.set(Color::BLUE));
assert!(queued.contains(&JobType::Paint), "got {queued:?}");
assert!(
!queued.contains(&JobType::Layout),
"a colour change must not relayout, got {queued:?}"
);
}
#[test]
fn corner_radius_invalidates_paint_only() {
let r = create_signal(2.0f32);
let mut h = H::new(
container()
.width(10.0)
.height(10.0)
.background(Color::RED)
.corners(move || r.get()),
);
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| r.set(6.0));
assert!(queued.contains(&JobType::Paint), "got {queued:?}");
assert!(!queued.contains(&JobType::Layout), "got {queued:?}");
}
#[test]
fn a_transform_invalidates_paint_only() {
for (name, build) in [
(
"translate",
Box::new(|s: RwSignal<f32>| {
container()
.width(10.0)
.height(10.0)
.translate(move || Translate::new(s.get(), 0.0))
}) as Box<dyn Fn(RwSignal<f32>) -> Container>,
),
(
"rotate",
Box::new(|s: RwSignal<f32>| {
container().width(10.0).height(10.0).rotate(move || s.get())
}),
),
(
"scale",
Box::new(|s: RwSignal<f32>| {
container()
.width(10.0)
.height(10.0)
.scale(move || Scale::uniform(1.0 + s.get()))
}),
),
] {
let value = create_signal(0.0f32);
let mut h = H::new(build(value));
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| value.set(30.0));
assert!(
queued.contains(&JobType::Paint),
"{name}: a transform must repaint, got {queued:?}"
);
assert!(
!queued.contains(&JobType::Layout),
"{name}: a transform must not reflow anything, got {queued:?}"
);
}
}
#[test]
fn a_transform_does_not_reflow_the_parent_either() {
let dx = create_signal(0.0f32);
let mut h = H::new(
container().layout(Flex::column()).child(
container()
.width(10.0)
.height(10.0)
.translate(move || Translate::new(dx.get(), 0.0)),
),
);
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| dx.set(30.0));
assert!(
!queued.contains(&JobType::Layout),
"a child's transform relaid out its parent, got {queued:?}"
);
}
#[test]
fn visibility_invalidates_layout() {
let shown = create_signal(true);
let mut h = H::new(
container()
.visible(move || shown.get())
.child(box_of(10.0, 10.0)),
);
h.fit(500.0, 500.0);
h.paint();
let queued = h.jobs_from(|| shown.set(false));
assert!(queued.contains(&JobType::Layout), "got {queued:?}");
}
fn pump(h: &mut H) -> bool {
let roots = h.roots();
jobs::distribute_jobs(&h.tree, &roots);
let drained = jobs::drain_surface_jobs(h.root);
let animating = drained.iter().any(|job| job.job_type == JobType::Animation);
let mut layout_roots = Vec::new();
jobs::process_jobs(&drained, &mut h.tree, &mut layout_roots);
jobs::recycle_job_buffer(drained);
jobs::distribute_jobs(&h.tree, &roots);
let followup = jobs::drain_surface_non_animation_jobs(h.root);
jobs::process_jobs(&followup, &mut h.tree, &mut layout_roots);
jobs::recycle_job_buffer(followup);
jobs::recycle_job_buffer(jobs::drain_orphan_jobs());
animating
}
fn frame_at(h: &mut H, now: std::time::Instant, width: f32, height: f32) {
h.tree.set_frame_instant(Some(now));
pump(h);
h.fit(width, height);
h.tree.set_frame_instant(None);
}
#[test]
fn a_linear_animation_is_halfway_at_half_its_duration() {
let w = create_signal(0.0f32);
let mut h = H::new(
container()
.height(10.0)
.width(w.transition(Transition::new(100.0, TimingFunction::Linear))),
);
h.fit(400.0, 400.0);
let t0 = std::time::Instant::now();
w.set(100.0);
frame_at(&mut h, t0, 400.0, 400.0);
assert_eq!(
h.tree.cached_size(h.root).unwrap().width,
0.0,
"the segment begins where it was, not where it is going"
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
400.0,
);
assert_eq!(
h.tree.cached_size(h.root).unwrap().width,
50.0,
"half the duration of a linear curve is half the distance"
);
}
fn painted_ripple_radius(h: &mut H) -> Option<f32> {
h.paint()
.overlay_commands
.iter()
.find_map(|cmd| match &**cmd {
DrawCommand::Circle { radius, .. } => Some(*radius),
_ => None,
})
}
#[test]
fn a_ripple_and_a_transition_are_asked_about_the_same_moment() {
let w = create_signal(20.0f32);
let mut h = H::new(
container()
.height(100.0)
.width(w.transition(Transition::new(100.0, TimingFunction::Linear)))
.when_pressed(|s| s.ripple())
.on_click(|| {}),
);
h.fit(400.0, 400.0);
h.send(Event::mouse_down(10.0, 50.0, MouseButton::Left));
w.set(120.0);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let started = painted_ripple_radius(&mut h).expect("the press starts a ripple");
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
400.0,
);
assert_eq!(
h.tree.cached_size(h.root).unwrap().width,
70.0,
"the transition is halfway: 20 plus half of the hundred it travels"
);
let grown = painted_ripple_radius(&mut h).expect("and the ripple is still drawn");
assert!(
grown > started,
"the ripple grew over the same 50ms, from {started} to {grown}"
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
400.0,
);
assert_eq!(
painted_ripple_radius(&mut h),
Some(grown),
"asked about the same moment, a frame answers the same"
);
}
fn send_at(h: &mut H, at: std::time::Instant, event: Event) -> EventResponse {
h.tree.set_event_instant(Some(at));
let response = h.send(event);
h.tree.set_event_instant(None);
response
}
#[test]
fn a_press_held_for_a_named_time_has_grown_by_a_named_amount() {
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.when_pressed(|s| s.ripple())
.on_click(|| {}),
);
h.fit(400.0, 400.0);
let t0 = std::time::Instant::now() - std::time::Duration::from_secs(60);
send_at(&mut h, t0, Event::mouse_down(50.0, 50.0, MouseButton::Left));
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(8),
400.0,
400.0,
);
let started = painted_ripple_radius(&mut h).expect("the press starts a ripple");
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(120),
400.0,
400.0,
);
let held = painted_ripple_radius(&mut h).expect("and holding it keeps one");
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(120),
400.0,
400.0,
);
assert_eq!(
painted_ripple_radius(&mut h),
Some(held),
"the same instant twice is the same disc"
);
assert!(
held > started,
"a hundred and twenty milliseconds of press has to have grown it, \
from {started} to {held}"
);
}
fn gated_frame_at(h: &mut H, now: std::time::Instant, width: f32, height: f32) -> Frame {
h.tree.set_frame_instant(Some(now));
let frame = h.frame(width, height);
h.tree.set_frame_instant(None);
frame
}
fn ripple_color_of(frame: &Frame) -> Option<Color> {
frame
.node
.overlay_commands
.iter()
.find_map(|cmd| match &**cmd {
DrawCommand::Circle { color, .. } => Some(*color),
_ => None,
})
}
fn painted_ripple_color(h: &mut H) -> Option<Color> {
h.paint()
.overlay_commands
.iter()
.find_map(|cmd| match &**cmd {
DrawCommand::Circle { color, .. } => Some(*color),
_ => None,
})
}
#[test]
fn a_ripple_takes_the_colour_it_is_given_now() {
let hot = create_signal(Color::rgba(1.0, 0.0, 0.0, 0.4));
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.when_pressed(move |s: StateStyle| s.ripple_with_color(hot))
.on_click(|| {}),
);
h.fit(400.0, 400.0);
let t0 = std::time::Instant::now() - std::time::Duration::from_secs(60);
send_at(&mut h, t0, Event::mouse_down(50.0, 50.0, MouseButton::Left));
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(8),
400.0,
400.0,
);
let red = painted_ripple_color(&mut h).expect("the press starts a ripple");
assert!(
red.r > 0.9 && red.g < 0.1,
"the declared colour is what the disc is drawn with, got {red:?}"
);
hot.set(Color::rgba(0.0, 0.0, 1.0, 0.4));
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(16),
400.0,
400.0,
);
let blue = painted_ripple_color(&mut h).expect("the ripple is still running");
assert!(
blue.b > 0.9 && blue.r < 0.1,
"the ripple kept the colour it was built with, got {blue:?}"
);
let settled = t0 + std::time::Duration::from_secs(2);
gated_frame_at(&mut h, settled, 400.0, 400.0);
assert!(
!pump(&mut h),
"a ripple held past its growth has to stop asking for frames, or the \
case this is about does not arise"
);
hot.set(Color::rgba(0.0, 1.0, 0.0, 0.4));
eprintln!("needs_paint after write = {}", h.tree.needs_paint(h.root));
let frame = gated_frame_at(
&mut h,
settled + std::time::Duration::from_millis(8),
400.0,
400.0,
);
eprintln!("frame colour = {:?}", ripple_color_of(&frame));
let green = ripple_color_of(&frame).expect("the finger is still down");
assert!(
green.g > 0.9 && green.b < 0.1,
"the disc under a still finger never saw the write, got {green:?}"
);
assert!(
(green.a - 0.4).abs() < 0.01,
"the declared alpha is scaled by the disc's opacity, not combined with \
it some other way: got {green:?}"
);
}
#[test]
fn a_hover_ripple_does_not_stand_in_for_the_pressed_one() {
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.when_pressed(|s: StateStyle| s.ripple_with_color(Color::rgba(1.0, 0.0, 0.0, 0.4)))
.when_hovered(|s: StateStyle| s.ripple_with_color(Color::rgba(0.0, 0.0, 1.0, 0.4)))
.on_click(|| {}),
);
h.fit(400.0, 400.0);
let t0 = std::time::Instant::now() - std::time::Duration::from_secs(60);
send_at(&mut h, t0, Event::mouse_down(50.0, 50.0, MouseButton::Left));
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(8),
400.0,
400.0,
);
let drawn = painted_ripple_color(&mut h).expect("the press starts a ripple");
assert!(
drawn.r > 0.9 && drawn.b < 0.1,
"the hover layer's colour reached a press it does not describe: {drawn:?}"
);
}
fn painted_text_color(h: &mut H) -> Color {
pump(h);
h.fit(400.0, 400.0);
let node = h.paint();
fn find(node: &RenderNode) -> Option<Color> {
for cmd in &node.commands {
if let DrawCommand::Text { color, .. } = &**cmd {
return Some(*color);
}
}
node.children.iter().find_map(|child| find(child))
}
find(&node).expect("a text command")
}
fn set_hover(h: &mut H, inside: bool) {
let (x, y) = if inside { (5.0, 5.0) } else { (-50.0, -50.0) };
h.send(Event::mouse_move(x, y));
}
#[test]
fn a_hover_layer_reaches_the_text() {
let mut h = H::new(
container().width(100.0).height(40.0).control().child(
crate::widgets::text("Label")
.color(Color::rgb(0.5, 0.5, 0.5))
.when_hovered(|s| s.color(Color::WHITE)),
),
);
assert_eq!(painted_text_color(&mut h), Color::rgb(0.5, 0.5, 0.5));
set_hover(&mut h, true);
assert_eq!(painted_text_color(&mut h), Color::WHITE);
set_hover(&mut h, false);
assert_eq!(painted_text_color(&mut h), Color::rgb(0.5, 0.5, 0.5));
}
#[test]
fn a_transition_on_the_value_is_what_makes_that_value_ease() {
let color = create_signal(Color::BLACK);
let mut h = H::new(
container()
.layout(Flex::row())
.child(box_of(20.0, 20.0).background(color))
.child(
box_of(20.0, 20.0)
.background(color.transition(Transition::new(200.0, TimingFunction::Linear))),
),
);
h.fit(200.0, 200.0);
h.paint();
color.set(Color::WHITE);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(100),
200.0,
200.0,
);
let painted: Vec<Color> = h
.paint()
.children
.iter()
.map(|child| rects(child).first().expect("a background").1)
.collect();
assert_eq!(
painted[0],
Color::WHITE,
"a value declared with no motion is at its new value on the next paint"
);
assert!(
painted[1].r > 0.05 && painted[1].r < 0.95,
"and one declared with a transition is still on its way, got {:?}",
painted[1]
);
}
impl Container {
fn follows_a_signal_anywhere(&self) -> bool {
self.anims
.as_deref()
.is_some_and(|declared| declared.slots().any(AnimSlot::follows_a_signal))
}
}
#[test]
fn a_container_whose_declared_animation_is_running_asks_for_another_frame() {
let colour = create_signal(Color::RED);
let mut h = H::new(
container()
.width(100.0)
.height(50.0)
.background(colour.transition(Transition::new(100.0, TimingFunction::Linear))),
);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 500.0, 500.0);
h.paint();
colour.set(Color::BLUE);
let moving = h.advance_at(t0 + std::time::Duration::from_millis(20));
assert!(
moving,
"a transition halfway through needs the frame after it"
);
h.advance_at(t0 + std::time::Duration::from_millis(200));
let settled = h.advance_at(t0 + std::time::Duration::from_millis(220));
assert!(!settled, "and one that has arrived asks for nothing");
}
#[test]
fn declaring_no_motion_allocates_no_animation_box() {
assert!(
container().background(Color::RED).anims.is_none(),
"a plain declaration must not allocate the animation box"
);
assert!(
container()
.background(Color::RED.transition(200.0))
.anims
.is_some(),
"and one that declares a motion has somewhere to keep it"
);
assert!(
container()
.background(Color::RED.transition(200.0))
.background(Color::BLUE)
.anims
.as_deref()
.is_some_and(|a| a.background().is_none()),
"restating it plainly empties the slot rather than the box"
);
}
#[test]
fn a_container_carries_one_slot_per_declared_animation() {
let one = container().background(Color::RED.transition(200.0));
assert_eq!(
one.anims.as_deref().map(ContainerAnims::declared_count),
Some(1),
"one motion is one slot"
);
let two = container()
.background(Color::RED.transition(200.0))
.rotate(0.0.transition(200.0));
assert_eq!(
two.anims.as_deref().map(ContainerAnims::declared_count),
Some(2),
"and a second is a second"
);
assert!(
std::mem::size_of::<ContainerAnims>()
<= 2 * std::mem::size_of::<AnimSlot>() + 2 * std::mem::size_of::<usize>(),
"the store is sized by what a container declares, not by the twelve \
properties it could declare: {} bytes",
std::mem::size_of::<ContainerAnims>()
);
}
#[test]
fn restating_a_property_plainly_takes_its_motion_with_it() {
let color = create_signal(Color::BLACK);
let mut h = H::new(
container()
.layout(Flex::row())
.child(
box_of(20.0, 20.0)
.background(color.transition(Transition::new(200.0, TimingFunction::Linear))),
)
.child(
box_of(20.0, 20.0)
.background(color.transition(Transition::new(200.0, TimingFunction::Linear)))
.background(color),
),
);
h.fit(200.0, 200.0);
h.paint();
color.set(Color::WHITE);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(100),
200.0,
200.0,
);
let painted: Vec<Color> = h
.paint()
.children
.iter()
.map(|child| rects(child).first().expect("a background").1)
.collect();
assert!(
painted[0].r < 0.95,
"the transition it was declared with still holds, got {:?}",
painted[0]
);
assert_eq!(
painted[1],
Color::WHITE,
"and the one restated plainly has no transition left to hold it back"
);
}
#[test]
fn a_timeline_plays_on_a_padding() {
let plays = create_signal(0u32);
let rest = Padding::all(0.0);
let mut h = H::new(
container()
.padding(
rest.timeline(
Keyframes::new(200.0)
.at(0.0, rest)
.at(0.5, Padding::all(20.0))
.at(1.0, rest)
.played_by(plays),
),
)
.child(box_of(20.0, 20.0)),
);
let at_rest = h.fit(200.0, 200.0);
h.paint();
assert_eq!(at_rest.width, 20.0, "nothing plays until the trigger moves");
plays.set(1);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
h.tree
.set_frame_instant(Some(t0 + std::time::Duration::from_millis(100)));
pump(&mut h);
let played = h.fit(200.0, 200.0);
h.tree.set_frame_instant(None);
assert!(
played.width > at_rest.width + 5.0,
"the sequence has to reach the layout the padding sizes, got {} \
against {} at rest",
played.width,
at_rest.width
);
}
#[test]
fn a_timeline_plays_on_a_background() {
let plays = create_signal(0u32);
let rest = Color::BLACK;
let mut h = H::new(
box_of(50.0, 50.0).background(
rest.timeline(
Keyframes::new(200.0)
.at(0.0, rest)
.at(0.5, Color::RED)
.at(1.0, rest)
.played_by(plays),
),
),
);
h.fit(200.0, 200.0);
h.paint();
assert_eq!(
rects(&h.paint())[0].1,
rest,
"nothing plays until the trigger moves"
);
plays.set(1);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(100),
200.0,
200.0,
);
let flashed = rects(&h.paint())[0].1;
assert!(
flashed.r > 0.5,
"the sequence has to reach the pixels, got {flashed:?}"
);
}
#[test]
fn a_container_wakes_when_its_timeline_is_asked_to_play() {
let plays = create_signal(0u32);
let mut h = H::new(
box_of(50.0, 50.0).rotate(
0.0.timeline(
Keyframes::new(200.0)
.at(0.0, 0.0)
.at(0.5, 2.0)
.at(1.0, 0.0)
.played_by(plays),
),
),
);
h.fit(100.0, 100.0);
h.paint();
let queued = h.jobs_from(|| plays.set(1));
assert!(
queued.contains(&JobType::Animation),
"a play has to wake the container that would show it, got {queued:?}"
);
}
fn played_transform(h: &mut H) -> Transform {
let t0 = std::time::Instant::now();
frame_at(h, t0, 200.0, 200.0);
frame_at(h, t0 + std::time::Duration::from_millis(100), 200.0, 200.0);
h.paint().children[0].local_transform
}
#[test]
fn a_sequence_with_no_trigger_plays_because_it_exists() {
let mut h = H::new(spinning(Repeat::Times(1)));
frame_at(&mut h, std::time::Instant::now(), 200.0, 200.0);
assert!(
!played_transform(&mut h).is_identity(),
"nothing asked it to play, and that is the point: it plays because it is there"
);
}
#[test]
fn showing_the_widget_starts_the_sequence_and_hiding_it_stops_everything() {
let busy = create_signal(false);
let mut h = H::new(
container()
.layout(Flex::row())
.child(move || busy.get().then(|| spinning_child(Repeat::Forever))),
);
h.fit(200.0, 200.0);
h.paint();
assert!(
h.paint().children.is_empty(),
"nothing is waiting, so there is no spinner"
);
busy.set(true);
frame_at(&mut h, std::time::Instant::now(), 200.0, 200.0);
assert!(
!played_transform(&mut h).is_identity(),
"it appeared, so it plays — with nothing having asked it to"
);
busy.set(false);
let t0 = std::time::Instant::now();
for step in 0..6 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(step * 200),
200.0,
200.0,
);
}
assert!(
!pump(&mut h),
"an endless sequence has no stop call, so the widget going away has to \
be what lets the surface settle"
);
}
#[test]
fn an_endless_sequence_keeps_its_place_in_the_run_a_month_later() {
let mut h = H::new(spinning(Repeat::Forever));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(1),
200.0,
200.0,
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(100),
200.0,
200.0,
);
let first_run = h.paint().children[0].local_transform;
let month = std::time::Duration::from_secs(30 * 24 * 60 * 60);
frame_at(
&mut h,
t0 + month + std::time::Duration::from_millis(100),
200.0,
200.0,
);
assert_eq!(
h.paint().children[0].local_transform,
first_run,
"a whole number of runs later is the same point in the run"
);
frame_at(
&mut h,
t0 + month + std::time::Duration::from_millis(116),
200.0,
200.0,
);
assert!(
h.paint().children[0].local_transform != first_run,
"and the frame after it has moved on, rather than showing the same value"
);
}
#[test]
fn a_finished_sequence_stops_asking_to_be_played() {
let mut h = H::new(spinning(Repeat::Times(1)));
let t0 = std::time::Instant::now();
for step in 0..6 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(step * 100),
200.0,
200.0,
);
h.paint();
}
assert!(
!pump(&mut h),
"the sequence is over, so nothing is owed another frame"
);
}
#[test]
fn a_property_with_no_sequence_asks_for_no_play() {
let mut h = H::new(
container()
.layout(Flex::row())
.child(box_of(50.0, 50.0).rotate(0.0.transition(200.0))),
);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
h.paint();
assert!(
!pump(&mut h),
"an eased property with no sequence is owed no play, so the first \
layout has nothing to ask for"
);
}
fn spinning(repeat: Repeat) -> Container {
container()
.layout(Flex::row())
.child(spinning_child(repeat))
}
fn spinning_child(repeat: Repeat) -> Container {
box_of(50.0, 50.0).rotate(
0.0.timeline(
Keyframes::new(200.0)
.at(0.0, 0.0)
.at(0.5, 20.0)
.at(1.0, 0.0)
.repeat(repeat),
),
)
}
#[test]
fn a_sequence_that_repeats_forever_is_still_running_much_later() {
let mut endless = H::new(spinning(Repeat::Forever));
let mut counted = H::new(spinning(Repeat::Times(1)));
let t0 = std::time::Instant::now();
let at = t0 + std::time::Duration::from_millis(2100);
for h in [&mut endless, &mut counted] {
frame_at(h, t0, 200.0, 200.0);
frame_at(h, t0 + std::time::Duration::from_millis(1), 200.0, 200.0);
frame_at(h, at, 200.0, 200.0);
frame_at(h, at + std::time::Duration::from_millis(1), 200.0, 200.0);
}
assert!(
!endless.paint().children[0].local_transform.is_identity(),
"a sequence that repeats forever is still moving ten runs later"
);
assert!(
counted.paint().children[0].local_transform.is_identity(),
"and one that does not is back at rest, which is what says the first \
assertion is about the repeat and not about the clock"
);
}
#[test]
fn a_sequence_played_by_a_trigger_waits_for_it() {
let plays = create_signal(0u32);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0).rotate(
0.0.timeline(
Keyframes::new(200.0)
.at(0.0, 0.0)
.at(0.5, 20.0)
.at(1.0, 0.0)
.played_by(plays),
),
),
),
);
assert!(
played_transform(&mut h).is_identity(),
"nothing has asked it to play yet"
);
plays.set(1);
assert!(!played_transform(&mut h).is_identity(), "and now it has");
}
#[test]
fn a_played_sequence_actually_moves_the_transform() {
let plays = create_signal(0u32);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0).rotate(
0.0.timeline(
Keyframes::new(200.0)
.at(0.0, 0.0)
.at(0.5, 20.0)
.at(1.0, 0.0)
.played_by(plays),
),
),
),
);
h.fit(200.0, 200.0);
h.paint();
plays.set(1);
let played = played_transform(&mut h);
assert_ne!(
played,
Transform::IDENTITY,
"the sequence has to reach the paint, not only the job queue"
);
}
#[test]
fn a_relative_translate_is_its_own_width_at_any_width() {
let w = create_signal(50.0f32);
let mut h = H::new(
container()
.width(w)
.height(20.0)
.translate(Translate::relative(-1.0, 0.0)),
);
let tx = |h: &mut H| h.frame(200.0, 200.0).node.local_transform.tx();
assert_eq!(tx(&mut h), -50.0);
w.set(120.0);
assert_eq!(tx(&mut h), -120.0, "a new width is a new distance");
}
#[test]
fn a_relative_enter_slides_in_from_its_own_width_on_the_first_frame() {
let t0 = std::time::Instant::now();
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(80.0, 20.0).translate(
Translate::NONE
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(Translate::relative(-1.0, 0.0)),
),
),
);
let tx = |h: &mut H| h.paint().children[0].local_transform.tx();
frame_at(&mut h, t0, 200.0, 200.0);
assert_eq!(tx(&mut h), -80.0, "the first frame is one whole width out");
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
200.0,
200.0,
);
assert_eq!(tx(&mut h), -40.0, "half the fraction is half the width");
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(200),
200.0,
200.0,
);
assert_eq!(tx(&mut h), 0.0, "and it arrives");
}
#[test]
fn a_width_change_under_a_relative_translate_moves_at_once() {
let w = create_signal(50.0f32);
let mut h = H::new(container().width(w).height(20.0).translate(
Translate::relative(-1.0, 0.0).transition(Transition::new(100.0, TimingFunction::Linear)),
));
let tx = |h: &mut H| h.paint().local_transform.tx();
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
assert_eq!(tx(&mut h), -50.0);
w.set(100.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(10),
200.0,
200.0,
);
assert_eq!(tx(&mut h), -100.0, "at the new width at once");
assert!(!pump(&mut h), "and no animation was started to get there");
}
#[test]
fn an_animated_translate_reaches_the_paint() {
let offset = create_signal(Translate::NONE);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0)
.translate(offset.transition(Transition::new(600.0, TimingFunction::Linear))),
),
);
h.fit(200.0, 200.0);
h.paint();
assert_eq!(
h.paint().children[0].local_transform.tx(),
0.0,
"at rest it sits where it was laid out"
);
offset.set(Translate::new(40.0, 0.0));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 200.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(60),
200.0,
200.0,
);
let midway = h.paint().children[0].local_transform.tx();
assert_eq!(
midway, 4.0,
"it has to animate towards the target rather than snap to it"
);
settle(&mut h, 400);
let tx = h.paint().children[0].local_transform.tx();
assert!(
(tx - 40.0).abs() < 0.5,
"the animation has to arrive at what the signal named, got {tx}"
);
}
#[test]
fn a_translate_target_behind_an_untaken_branch_is_picked_up_by_the_resync() {
let armed = create_signal(false);
let offset = create_signal(0.0f32);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0).translate(
(move || {
if armed.get() {
Translate::new(offset.get(), 0.0)
} else {
Translate::NONE
}
})
.transition(Transition::new(80.0, TimingFunction::Linear)),
),
),
);
h.fit(200.0, 200.0);
h.paint();
armed.set(true);
settle(&mut h, 120);
offset.set(40.0);
settle(&mut h, 120);
let tx = h.paint().children[0].local_transform.tx();
assert!(
(tx - 40.0).abs() < 0.5,
"a target behind a branch nobody tracked still has to converge, got {tx}"
);
}
#[test]
fn a_padding_written_before_the_first_layout_arrives() {
let inset = create_signal(0.0f32);
let mut h = H::new(
container()
.width(200.0)
.height(200.0)
.padding(
(move || Padding::all(inset.get()))
.transition(Transition::new(80.0, TimingFunction::Linear)),
)
.child(box_of(20.0, 20.0)),
);
inset.set(24.0);
let x = h.frame(400.0, 400.0).node.children[0].local_transform.tx();
assert!(
(x - 24.0).abs() < 0.5,
"the write landed before anything was listening and has to be picked up \
at the seed, got {x}"
);
}
#[test]
fn a_size_settles_on_the_padding_the_layout_measured() {
let inset = create_signal(0.0f32);
let mut h = H::new(
container()
.padding(
(move || Padding::all(inset.get()))
.transition(Transition::new(80.0, TimingFunction::Linear)),
)
.width(at_most(200.0).transition(Transition::new(80.0, TimingFunction::Linear)))
.child(box_of(20.0, 20.0)),
);
inset.set(24.0);
settle(&mut h, 120);
let width = h.tree.cached_size(h.root).map(|s| s.width);
assert!(
width.is_some_and(|w| (w - 68.0).abs() < 0.5),
"the size follows the content plus the padding that was measured, \
20 + 24 + 24: got {width:?}"
);
}
#[test]
fn a_border_width_written_before_the_first_layout_arrives() {
let thickness = create_signal(0.0f32);
let mut h = H::new(container().width(80.0).height(80.0).border(
(move || thickness.get()).transition(Transition::new(80.0, TimingFunction::Linear)),
Color::WHITE,
));
thickness.set(5.0);
let frame = h.frame(200.0, 200.0);
let width = borders(&frame.node).first().map(|(width, _)| *width);
assert!(
width.is_some_and(|w| (w - 5.0).abs() < 0.5),
"the write landed before anything was listening and has to be picked up \
at the seed, got {width:?}"
);
}
#[test]
fn a_padding_target_behind_an_untaken_branch_is_picked_up_by_the_resync() {
let armed = create_signal(false);
let inset = create_signal(0.0f32);
let mut h = H::new(
container()
.width(200.0)
.height(200.0)
.padding(
(move || {
if armed.get() {
Padding::all(inset.get())
} else {
Padding::all(0.0)
}
})
.transition(Transition::new(80.0, TimingFunction::Linear)),
)
.child(box_of(20.0, 20.0)),
);
h.fit(400.0, 400.0);
h.paint();
armed.set(true);
settle(&mut h, 120);
inset.set(30.0);
settle(&mut h, 120);
let x = h.paint().children[0].local_transform.tx();
assert!(
(x - 30.0).abs() < 0.5,
"a padding behind a branch nobody tracked still has to converge, got {x}"
);
}
#[test]
fn a_border_width_target_behind_an_untaken_branch_is_picked_up_by_the_resync() {
let armed = create_signal(false);
let thickness = create_signal(0.0f32);
let mut h = H::new(
container().width(80.0).height(80.0).border(
(move || {
if armed.get() { thickness.get() } else { 0.0 }
})
.transition(Transition::new(80.0, TimingFunction::Linear)),
Color::WHITE,
),
);
h.fit(200.0, 200.0);
h.paint();
armed.set(true);
settle(&mut h, 120);
thickness.set(6.0);
settle(&mut h, 120);
let width = borders(&h.paint()).first().map(|(width, _)| *width);
assert!(
width.is_some_and(|w| (w - 6.0).abs() < 0.5),
"a border width behind a branch nobody tracked still has to converge, \
got {width:?}"
);
}
#[test]
fn a_shadow_target_behind_an_untaken_branch_is_picked_up_by_the_resync() {
let armed = create_signal(false);
let depth = create_signal(4.0f32);
let shadow_of = move |blur: f32| {
Shadow::new(
(0.0, blur * 0.5),
blur,
0.0,
Color::rgba(0.0, 0.0, 0.0, 0.5),
)
};
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.background(Color::RED)
.shadow(
(move || {
if armed.get() {
shadow_of(depth.get())
} else {
Shadow::none()
}
})
.transition(Transition::new(80.0, TimingFunction::Linear)),
),
);
h.fit(200.0, 200.0);
h.paint();
armed.set(true);
settle(&mut h, 120);
depth.set(20.0);
settle(&mut h, 120);
let painted = drawn_shadow(&h.paint()).expect("a shadow is drawn");
assert!(
(painted.blur - 20.0).abs() < 0.5,
"a target behind a branch nobody tracked still has to converge, got {}",
painted.blur
);
}
#[test]
fn a_translate_sequence_and_a_scale_sequence_move_the_transform() {
let plays = create_signal(0u32);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0).translate(
Translate::NONE.timeline(
Keyframes::new(200.0)
.at(0.0, Translate::new(10.0, 0.0))
.at(0.5, Translate::new(30.0, 0.0))
.at(1.0, Translate::new(10.0, 0.0))
.played_by(plays),
),
),
),
);
h.fit(200.0, 200.0);
h.paint();
plays.set(1);
let played = played_transform(&mut h).tx();
assert!(
played > 5.0,
"a displacement sequence has to reach the paint, not only the job \
queue, got {played}"
);
let plays = create_signal(0u32);
let mut h = H::new(
container().layout(Flex::row()).child(
box_of(50.0, 50.0).scale(
Scale::NONE.timeline(
Keyframes::new(200.0)
.at(0.0, Scale::uniform(1.1))
.at(0.5, Scale::uniform(1.4))
.at(1.0, Scale::uniform(1.1))
.played_by(plays),
),
),
),
);
h.fit(200.0, 200.0);
h.paint();
plays.set(1);
let played = played_transform(&mut h).a();
assert!(played > 1.05, "and so does a pulse, got {played}");
}
#[test]
fn unregistering_a_focused_widget_releases_the_focus() {
use crate::reactive::focus::focus_path;
use crate::reactive::request_focus;
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.child(box_of(10.0, 10.0)),
);
h.fit(100.0, 100.0);
let child = h.children()[0];
request_focus(&h.tree, child);
assert!(focus_path().contains(child));
assert!(
focus_path().contains(h.root),
"the ancestor is on the path, which is what a focused layer asks about"
);
h.tree.unregister(child);
assert!(
!focus_path().contains(h.root),
"an ancestor must stop claiming focus once the focused widget is gone"
);
}
#[test]
fn a_focused_layer_applies_while_a_descendant_holds_focus() {
use crate::reactive::focus::clear_focus;
use crate::reactive::request_focus;
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.background(Color::RED)
.when_focused(|s| s.background(Color::BLUE))
.child(box_of(10.0, 10.0)),
);
h.fit(100.0, 100.0);
let child = h.children()[0];
assert_eq!(rects(&h.paint())[0].1, Color::RED);
request_focus(&h.tree, child);
assert_eq!(rects(&h.paint())[0].1, Color::BLUE);
clear_focus();
assert_eq!(rects(&h.paint())[0].1, Color::RED);
}
#[test]
fn a_focused_layer_replaces_the_base_rather_than_ranking_against_it() {
use crate::reactive::focus::clear_focus;
use crate::reactive::request_focus;
use crate::widgets::text_input;
clear_focus();
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.border(1.5, Color::RED)
.when_focused(|s| s.border(1.5, Color::BLUE))
.child(text_input(create_signal(String::new()))),
);
h.fit(100.0, 100.0);
let input = h.children()[0];
assert_eq!(borders(&h.paint())[0].1, Color::RED);
request_focus(&h.tree, input);
assert_eq!(
borders(&h.paint())[0].1,
Color::BLUE,
"the layer wins, and the base has no way to say it should not"
);
}
#[test]
fn a_border_colour_that_asks_a_handle_follows_the_focus() {
use crate::reactive::focus::clear_focus;
use crate::reactive::request_focus;
use crate::widget_ref::create_widget_ref;
use crate::widgets::text_input;
clear_focus();
let handle = create_widget_ref();
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.border(2.0, move || {
if handle.is_focused() {
Color::BLUE
} else {
Color::RED
}
})
.child(text_input(create_signal(String::new())).widget_ref(handle)),
);
h.fit(100.0, 100.0);
let input = h.children()[0];
assert_eq!(borders(&h.paint())[0], (2.0, Color::RED));
request_focus(&h.tree, input);
assert_eq!(borders(&h.paint())[0], (2.0, Color::BLUE));
let queued = h.jobs_from(clear_focus);
assert!(
queued.contains(&JobType::Paint),
"asking about the focus has to subscribe to it, got {queued:?}"
);
assert_eq!(borders(&h.paint())[0], (2.0, Color::RED));
}
#[test]
fn a_state_override_follows_the_signal_it_was_given() {
let accent = create_signal(Color::RED);
let mut h = H::new(
box_of(50.0, 50.0)
.background(Color::BLACK)
.when_hovered(move |s: StateStyle| s.background(accent)),
);
h.fit(100.0, 100.0);
set_hover(&mut h, true);
assert_eq!(rects(&h.paint())[0].1, Color::RED);
let queued = h.jobs_from(|| accent.set(Color::BLUE));
assert!(
queued.contains(&JobType::Paint),
"the active override has to subscribe, got {queued:?}"
);
assert_eq!(rects(&h.paint())[0].1, Color::BLUE);
}
#[test]
fn a_state_layer_may_hang_on_a_condition_the_app_owns() {
let wrong = create_signal(false);
let mut h = H::new(
box_of(50.0, 50.0)
.background(Color::BLACK)
.state(wrong, |s| s.background(Color::RED)),
);
h.fit(100.0, 100.0);
assert_eq!(rects(&h.paint())[0].1, Color::BLACK);
let queued = h.jobs_from(|| wrong.set(true));
assert!(
queued.contains(&JobType::Paint),
"the condition has to subscribe, got {queued:?}"
);
assert_eq!(rects(&h.paint())[0].1, Color::RED);
}
#[test]
fn the_last_layer_declared_wins_over_the_ones_before_it() {
use crate::reactive::focus::clear_focus;
use crate::reactive::request_focus;
use crate::widgets::text_input;
clear_focus();
let wrong = create_signal(false);
let mut h = H::new(
container()
.width(50.0)
.height(50.0)
.border(1.5, Color::WHITE)
.when_focused(|s| s.border(1.5, Color::BLUE))
.state(wrong, |s| s.border(1.5, Color::RED))
.child(text_input(create_signal(String::new()))),
);
h.fit(100.0, 100.0);
let input = h.children()[0];
request_focus(&h.tree, input);
assert_eq!(borders(&h.paint())[0].1, Color::BLUE);
wrong.set(true);
assert_eq!(
borders(&h.paint())[0].1,
Color::RED,
"declared after the focused layer, so it outranks it"
);
wrong.set(false);
assert_eq!(borders(&h.paint())[0].1, Color::BLUE);
}
#[test]
fn a_layer_silent_on_a_property_does_not_shadow_the_one_below_it() {
let mut h = H::new(
box_of(50.0, 50.0)
.background(Color::BLACK)
.when_hovered(|s| s.background(Color::RED))
.when_pressed(|s| s.scale(0.5)),
);
h.fit(100.0, 100.0);
set_hover(&mut h, true);
h.send(Event::mouse_down(5.0, 5.0, MouseButton::Left));
assert_eq!(rects(&h.paint())[0].1, Color::RED);
}
#[test]
fn a_gradient_is_reactive_and_paint_only() {
let warm = create_signal(true);
let mut h = H::new(container().width(20.0).height(20.0).gradient(move || {
Some(if warm.get() {
LinearGradient::horizontal(Color::RED, Color::YELLOW)
} else {
LinearGradient::horizontal(Color::BLUE, Color::CYAN)
})
}));
h.fit(100.0, 100.0);
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::YELLOW)));
let queued = h.jobs_from(|| warm.set(false));
assert_eq!(queued, vec![JobType::Paint], "got {queued:?}");
assert_eq!(gradient_ends(&h.paint()), Some((Color::BLUE, Color::CYAN)));
}
#[test]
fn a_gradient_follows_the_signals_it_was_built_from() {
let end = create_signal(Color::YELLOW);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.gradient(move || LinearGradient::vertical(Color::RED, end.get())),
);
h.fit(100.0, 100.0);
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::YELLOW)));
end.set(Color::GREEN);
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::GREEN)));
}
#[test]
fn hovering_a_gradient_lightens_both_of_its_ends() {
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.gradient(LinearGradient::horizontal(Color::RED, Color::BLUE))
.when_hovered(|s| s.lighter(0.1).alpha(0.5)),
);
h.fit(500.0, 500.0);
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::BLUE)));
h.send(Event::mouse_move(25.0, 10.0));
assert_eq!(
gradient_ends(&h.paint()),
Some((
Color::RED.lighter(0.1).with_alpha(0.5),
Color::BLUE.lighter(0.1).with_alpha(0.5)
)),
"the hover resolved an override and the gradient drew its own colours"
);
h.send(Event::mouse_move(300.0, 10.0));
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::BLUE)));
}
#[test]
fn an_exact_background_on_hover_replaces_a_gradient() {
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.gradient(LinearGradient::horizontal(Color::RED, Color::BLUE))
.when_hovered(|s| s.background(Color::GREEN)),
);
h.fit(500.0, 500.0);
h.send(Event::mouse_move(25.0, 10.0));
let node = h.paint();
assert_eq!(gradient_ends(&node), None, "the gradient gives way");
assert!(
rects(&node)
.iter()
.any(|(_, colour)| *colour == Color::GREEN),
"to the colour the hover declared, got {:?}",
rects(&node)
);
}
#[test]
fn a_non_finite_exact_background_leaves_the_gradient() {
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.gradient(LinearGradient::horizontal(Color::RED, Color::BLUE))
.when_hovered(|s| s.background(Color::rgb(f32::NAN, 0.0, 0.0))),
);
h.fit(500.0, 500.0);
h.send(Event::mouse_move(25.0, 10.0));
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::BLUE)));
}
#[test]
fn overflow_is_reactive_and_invalidates_layout() {
let clipped = create_signal(false);
let mut h = H::new(
container()
.width(50.0)
.height(20.0)
.overflow(move || {
if clipped.get() {
Overflow::Hidden
} else {
Overflow::Visible
}
})
.child(box_of(400.0, 10.0)),
);
h.fit(500.0, 500.0);
assert!(h.paint().clip.is_none());
let queued = h.jobs_from(|| clipped.set(true));
assert!(
queued.contains(&JobType::Layout) && queued.contains(&JobType::Paint),
"got {queued:?}"
);
h.fit(500.0, 500.0);
assert!(h.paint().clip.is_some());
}
#[test]
fn a_backdrop_blur_is_reactive_and_zero_means_off() {
let radius = create_signal(0.0f32);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.backdrop_blur(move || radius.get()),
);
h.fit(100.0, 100.0);
assert_eq!(blur_radii(&h.paint()), Vec::<f32>::new());
let queued = h.jobs_from(|| radius.set(12.0));
assert_eq!(queued, vec![JobType::Paint], "got {queued:?}");
assert_eq!(blur_radii(&h.paint()), vec![12.0]);
radius.set(0.0);
assert_eq!(blur_radii(&h.paint()), Vec::<f32>::new());
}
#[test]
fn a_state_layer_replaces_the_whole_border() {
let danger = create_signal(false);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.border(1.0, Color::GRAY)
.state(danger, |s| s.border(2.0, Color::RED)),
);
h.fit(100.0, 100.0);
assert_eq!(borders(&h.paint()), vec![(1.0, Color::GRAY)]);
danger.set(true);
assert_eq!(borders(&h.paint()), vec![(2.0, Color::RED)]);
danger.set(false);
assert_eq!(borders(&h.paint()), vec![(1.0, Color::GRAY)], "and back");
}
#[test]
fn the_last_declared_border_layer_wins() {
let hovered = create_signal(true);
let failed = create_signal(true);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.border(1.0, Color::GRAY)
.state(hovered, |s| s.border(2.0, Color::WHITE))
.state(failed, |s| s.border(3.0, Color::RED)),
);
h.fit(100.0, 100.0);
assert_eq!(borders(&h.paint()), vec![(3.0, Color::RED)]);
failed.set(false);
assert_eq!(borders(&h.paint()), vec![(2.0, Color::WHITE)]);
}
#[test]
fn a_shadow_animates_towards_its_state_layer() {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(Shadow::none().transition(Transition::new(80.0, TimingFunction::Linear)))
.when_hovered(|s| s.shadow(LIFTED)),
);
h.fit(100.0, 100.0);
h.paint();
assert!(
drawn_shadow(&h.paint()).is_none(),
"flat on the surface at rest"
);
set_hover(&mut h, true);
for _ in 0..2 {
std::thread::sleep(std::time::Duration::from_millis(20));
pump(&mut h);
h.fit(100.0, 100.0);
h.paint();
}
assert!(
drawn_shadow(&h.paint()).is_some(),
"it casts a shadow while it rises"
);
settle(&mut h, 80);
set_hover(&mut h, false);
settle(&mut h, 80);
assert!(drawn_shadow(&h.paint()).is_none(), "and settles back down");
}
fn settle(h: &mut H, limit: usize) -> usize {
for frame in 0..limit {
std::thread::sleep(std::time::Duration::from_millis(4));
let animating = pump(h);
h.fit(400.0, 400.0);
h.paint();
if !animating {
return frame;
}
}
limit
}
#[test]
fn the_clip_carries_every_corner() {
let mut h = H::new(
container()
.width(100.0)
.height(40.0)
.corners([16.0, 0.0])
.overflow(Overflow::Hidden)
.child(container().width(100.0).height(40.0).background(Color::RED)),
);
let node = h.frame(200.0, 200.0).node;
let clip = node.children[0]
.clip
.as_ref()
.or(node.clip.as_ref())
.expect("a hidden overflow clips");
assert_eq!(clip.corner_radius.top_left, 16.0);
assert_eq!(clip.corner_radius.top_right, 16.0);
assert_eq!(clip.corner_radius.bottom_right, 0.0);
assert_eq!(clip.corner_radius.bottom_left, 0.0);
}
#[test]
fn a_state_override_replaces_the_whole_corner_shape() {
let c = container()
.corners(crate::widgets::Corners::squircle(12.0))
.when_hovered(|s| s.corners(20.0));
let hovered = c.interaction.as_ref().expect("a hover layer").states[0]
.1
.corners
.get_untracked()
.expect("which declares a shape");
assert_eq!(hovered.curvature, 1.0, "rounded, not squircle");
assert_eq!(hovered.radii.top_left, 20.0);
}
#[test]
fn a_background_with_an_enter_starts_somewhere_else_and_arrives() {
let entering = |from: Option<Color>| {
let value =
Color::rgb(1.0, 0.0, 0.0).transition(Transition::new(100.0, TimingFunction::Linear));
container().width(20.0).height(20.0).background(match from {
Some(from) => value.entering_from(from),
None => value,
})
};
let t0 = std::time::Instant::now();
let mut plain = H::new(entering(None));
frame_at(&mut plain, t0, 100.0, 100.0);
assert_eq!(
rects(&plain.paint()).first().map(|(_, c)| *c),
Some(Color::rgb(1.0, 0.0, 0.0)),
"with no enter declared, the first frame is the declared value"
);
let mut entered = H::new(entering(Some(Color::rgba(1.0, 0.0, 0.0, 0.0))));
frame_at(&mut entered, t0, 100.0, 100.0);
frame_at(
&mut entered,
t0 + std::time::Duration::from_millis(50),
100.0,
100.0,
);
let midway = rects(&entered.paint())
.first()
.map(|(_, c)| *c)
.expect("it paints");
assert!(
midway.a > 0.0 && midway.a < 1.0,
"the enter is under way rather than arrived, got {midway:?}"
);
frame_at(
&mut entered,
t0 + std::time::Duration::from_millis(200),
100.0,
100.0,
);
assert_eq!(
rects(&entered.paint()).first().map(|(_, c)| *c),
Some(Color::rgb(1.0, 0.0, 0.0)),
"and it arrives at the declared value"
);
}
#[test]
fn a_scale_with_an_enter_starts_collapsed() {
let t0 = std::time::Instant::now();
let mut h = H::new(
container().width(20.0).height(20.0).scale(
Scale::uniform(1.0)
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(Scale::uniform(0.0)),
),
);
frame_at(&mut h, t0, 100.0, 100.0);
let first = h.paint().local_transform;
assert!(
first.a() < 1.0,
"the box is still opening on the frame after its first layout, got {first:?}"
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(200),
100.0,
100.0,
);
assert_eq!(h.paint().local_transform.a(), 1.0, "and it finishes open");
}
#[test]
fn an_enter_is_consumed_by_the_first_layout_and_does_not_play_again() {
let t0 = std::time::Instant::now();
let mut h = H::new(
container().width(20.0).height(20.0).background(
Color::rgb(1.0, 0.0, 0.0)
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(Color::rgba(1.0, 0.0, 0.0, 0.0)),
),
);
frame_at(&mut h, t0, 100.0, 100.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(200),
100.0,
100.0,
);
assert_eq!(
rects(&h.paint()).first().map(|(_, c)| *c),
Some(Color::rgb(1.0, 0.0, 0.0)),
"the enter has finished"
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(300),
160.0,
160.0,
);
assert_eq!(
rects(&h.paint()).first().map(|(_, c)| *c),
Some(Color::rgb(1.0, 0.0, 0.0)),
"a relayout is not an appearance, so nothing plays a second time"
);
}
fn a_size_and_a_padding_entering() -> Container {
container()
.height(60.0)
.width(
120.0f32
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(0.0),
)
.padding(
Padding::all(20.0)
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(Padding::all(0.0)),
)
.child(container().width(10.0).height(10.0))
}
#[test]
fn a_measure_pass_leaves_the_appearance_for_the_first_real_layout() {
let t0 = std::time::Instant::now();
let mut h = H::new(a_size_and_a_padding_entering());
h.measure(Constraints::new(0.0, 0.0, 800.0, 800.0));
frame_at(&mut h, t0, 400.0, 400.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
400.0,
);
let midway = h.tree.cached_size(h.root).unwrap().width;
assert!(
midway > 0.0 && midway < 120.0,
"the width has to open from the value it enters from, as it does without \
the measure pass, got {midway}"
);
let child = h.tree.get_children(h.root)[0];
let inset = h.tree.get_origin(child).unwrap().0;
assert!(
inset > 0.0 && inset < 20.0,
"and so does the padding, got {inset}"
);
}
fn a_child_entering_at_50ms(measure_first: bool) -> f32 {
let t0 = std::time::Instant::now();
let mut h = H::new(
container().width(200.0).height(100.0).child(
container().height(20.0).width(
150.0f32
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(0.0),
),
),
);
if measure_first {
h.measure(Constraints::new(200.0, 0.0, 200.0, 300.0));
}
frame_at(&mut h, t0, 200.0, 100.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
200.0,
100.0,
);
let child = h.tree.get_children(h.root)[0];
h.tree.cached_size(child).unwrap().width
}
#[test]
fn a_child_whose_constraints_never_change_enters_halfway_at_half_its_duration() {
assert_eq!(a_child_entering_at_50ms(false), 75.0);
}
#[test]
fn a_child_a_measure_placed_is_laid_out_for_real_after_it() {
assert_eq!(
a_child_entering_at_50ms(true),
75.0,
"the child has to open from the value it enters from, as it does without \
the measure pass"
);
}
#[test]
fn a_measure_after_a_real_layout_reports_the_target_not_the_frame() {
let t0 = std::time::Instant::now();
let tall = create_signal(false);
let mut h = H::new(
container().layout(Flex::column()).child(
container().width(100.0).height(at_most(300.0)).child(
container().width(50.0).height(
(move || if tall.get() { 200.0 } else { 20.0 })
.transition(Transition::new(100.0, TimingFunction::Linear)),
),
),
),
);
let frame = |h: &mut H, ms: u64| {
h.tree
.set_frame_instant(Some(t0 + std::time::Duration::from_millis(ms)));
let roots = h.roots();
jobs::distribute_jobs(&h.tree, &roots);
let drained = jobs::drain_surface_jobs(h.root);
let mut layout_roots = Vec::new();
jobs::process_jobs(&drained, &mut h.tree, &mut layout_roots);
jobs::recycle_job_buffer(drained);
let fallback = Constraints::new(400.0, 0.0, 400.0, 1000.0);
for root in layout_roots {
let c = h.tree.last_layout_constraints(root).unwrap_or(fallback);
h.tree.layout_widget(root, c);
}
h.tree.set_frame_instant(None);
};
h.layout(Constraints::new(400.0, 0.0, 400.0, 1000.0));
tall.set(true);
for ms in [16, 32, 50] {
frame(&mut h, ms);
}
let child = h.tree.get_children(h.root)[0];
let in_flight = h.tree.cached_size(child).unwrap().height;
assert!(
in_flight > 20.0 && in_flight < 200.0,
"the control: mid-animation, got {in_flight}"
);
let measured = h.measure(Constraints::new(400.0, 0.0, 400.0, 800.0));
assert_eq!(measured.height, 200.0);
}
#[test]
fn a_settled_subtree_is_reused_by_a_measure_and_by_the_layout_after_it() {
let mut h = H::new(
container().width(200.0).height(100.0).child(
container()
.width(100.0)
.height(50.0)
.child(box_of(40.0, 20.0)),
),
);
h.fit(200.0, 100.0);
let grandchild = h.tree.get_children(h.children()[0])[0];
h.tree.set_origin(grandchild, 999.0, 999.0);
h.measure(Constraints::new(200.0, 0.0, 200.0, 300.0));
assert_eq!(
h.tree.get_origin(grandchild).unwrap().0,
999.0,
"the measure laid out a subtree the real layout had left settled"
);
h.fit(200.0, 100.0);
assert_eq!(
h.tree.get_origin(grandchild).unwrap().0,
999.0,
"the layout after the measure laid it out again"
);
}
#[test]
fn an_enter_reaches_a_size_and_a_padding_too() {
let t0 = std::time::Instant::now();
let mut h = H::new(a_size_and_a_padding_entering());
frame_at(&mut h, t0, 400.0, 400.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
400.0,
);
let midway = h.tree.cached_size(h.root).unwrap().width;
assert!(
midway > 0.0 && midway < 120.0,
"the width is opening rather than already open, got {midway}"
);
let child = h.tree.get_children(h.root)[0];
let inset = h.tree.get_origin(child).unwrap().0;
assert!(
inset > 0.0 && inset < 20.0,
"the padding is opening too, got {inset}"
);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(200),
400.0,
400.0,
);
assert_eq!(
h.tree.cached_size(h.root).unwrap().width,
120.0,
"and the width arrives"
);
assert_eq!(
h.tree.get_origin(h.tree.get_children(h.root)[0]).unwrap().0,
20.0,
"and so does the padding"
);
}
#[test]
fn an_enter_that_shrinks_still_runs_the_forward_transition() {
let closed = std::rc::Rc::new(std::cell::Cell::new(0u32));
let counted = std::rc::Rc::clone(&closed);
let t0 = std::time::Instant::now();
let mut h = H::new(
container().width(20.0).height(20.0).scale(
Scale::uniform(1.0)
.transition(
Transition::new(400.0, TimingFunction::Linear).reverse(
Transition::new(20.0, TimingFunction::Linear)
.on_complete(move || counted.set(counted.get() + 1)),
),
)
.entering_from(Scale::uniform(1.4)),
),
);
frame_at(&mut h, t0, 100.0, 100.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
100.0,
100.0,
);
assert_eq!(
closed.get(),
0,
"the reverse transition's completion belongs to the close, not the open"
);
let midway = h.paint().local_transform.a();
assert!(
midway > 1.0,
"and the forward curve is still running at 50ms of 400, got {midway}"
);
}
#[test]
fn an_enter_takes_what_the_property_takes() {
let _ = container()
.rotate(45.0f32.transition(200.0).entering_from(0i32))
.scale(Scale::uniform(1.0).transition(200.0).entering_from(0.0f64))
.width(120.0f32.transition(200.0).entering_from(0u16));
}
#[test]
#[should_panic(expected = "a timeline already says where it starts")]
fn an_enter_on_a_timeline_refuses() {
let plays = create_signal(0u32);
let _ = 0.0f32
.timeline(Keyframes::new(100.0).at(1.0, 1.0).played_by(plays))
.entering_from(0.0);
}
fn slide() -> Transition {
Transition::new(200.0, TimingFunction::Linear)
}
fn painted_x(node: &RenderNode, id: WidgetId) -> Option<f32> {
node.children
.iter()
.find(|child| child.id == id.as_u64())
.map(|child| child.local_transform.tx())
}
fn leaving_card() -> Container {
container().width(100.0).height(50.0).translate(
Translate::NONE
.transition(slide())
.exiting_to(Translate::new(-100.0, 0.0)),
)
}
fn after(t: std::time::Instant, ms: u64) -> std::time::Instant {
t + std::time::Duration::from_millis(ms)
}
#[test]
fn a_removed_child_with_an_exit_stays_until_the_exit_settles() {
let step = create_signal(0u32);
let mut h = H::new(container().child(move || {
step.get();
leaving_card()
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(
h.children().len(),
2,
"the leaving child and the new one are both there"
);
assert!(h.children().contains(&first));
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
let midway = painted_x(&h.paint(), first).expect("the leaving child still paints");
assert!(
(midway + 50.0).abs() < 0.5,
"half of a linear 200 ms from rest to -100 is -50, got {midway}"
);
frame_at(&mut h, after(t0, 210), 400.0, 400.0);
assert_eq!(h.children().len(), 1, "the exit has settled");
assert!(
!h.tree.contains(first),
"and the leaving child left the tree with it"
);
}
#[test]
fn a_removed_child_without_an_exit_is_gone_in_the_same_pass() {
let step = create_signal(0u32);
let mut h = H::new(container().child(move || {
step.get();
container()
.width(100.0)
.height(50.0)
.translate(Translate::NONE.transition(slide()))
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(h.children().len(), 1);
assert!(!h.tree.contains(first));
}
#[test]
fn a_spring_exit_that_begins_where_it_is_going_is_gone_in_the_same_pass() {
let step = create_signal(0u32);
let mut h = H::new(container().child(move || {
step.get();
container().width(100.0).height(50.0).translate(
Translate::NONE
.transition(SpringConfig::SNAPPY)
.exiting_to(Translate::NONE),
)
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(
h.children().len(),
1,
"nothing to play, so nothing to wait for"
);
assert!(!h.tree.contains(first));
}
#[test]
fn an_exit_is_read_when_the_child_is_removed() {
let step = create_signal(0u32);
let direction = create_signal(1.0f32);
let mut h = H::new(container().child(move || {
step.get();
container().width(100.0).height(50.0).translate(
Translate::NONE
.transition(slide())
.exiting_to(move || Translate::new(direction.get() * 100.0, 0.0)),
)
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
direction.set(-1.0);
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
let midway = painted_x(&h.paint(), first).expect("the leaving child still paints");
assert!(
(midway + 50.0).abs() < 0.5,
"the exit goes where the signal said at removal: got {midway}"
);
}
#[test]
fn a_leaving_child_takes_no_pointer_and_holds_no_focus() {
use crate::layout::ZStack;
use crate::reactive::focus::focus_path;
use crate::reactive::request_focus;
let step = create_signal(0u32);
let clicks = create_signal(Vec::<u32>::new());
let mut h = H::new(container().layout(ZStack::new()).child(move || {
let n = step.get();
leaving_card()
.background(Color::RED)
.when_hovered(|s| s.background(Color::BLUE))
.on_click(move || clicks.update(|c| c.push(n)))
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
request_focus(&h.tree, first);
assert!(focus_path().contains(first), "the setup needs it focused");
h.send(Event::mouse_move(20.0, 20.0));
let painted = |h: &mut H| {
let node = h.paint();
let leaving = node.children.iter().find(|c| c.id == first.as_u64());
leaving.and_then(|c| rects(c).first().map(|(_, color)| *color))
};
assert_eq!(
painted(&mut h),
Some(Color::BLUE),
"the setup needs it hovered"
);
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert!(h.tree.contains(first), "it is leaving, not gone");
assert!(
!focus_path().contains(first),
"the focus goes when the child is removed, not when it is disposed"
);
request_focus(&h.tree, first);
assert!(
!focus_path().contains(first),
"and a leaving child cannot take it back"
);
assert_eq!(
painted(&mut h),
Some(Color::RED),
"the pointer goes with the focus: it does not leave hovered"
);
for event in click_at(20.0, 20.0) {
h.send(event);
}
assert_eq!(
clicks.get_untracked(),
vec![1],
"the click belongs to the child that is arriving, not the one leaving"
);
}
#[test]
fn a_child_removed_under_the_pointer_hears_its_hover_go() {
let step = create_signal(0u32);
let heard = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let sink = heard.clone();
let mut h = H::new(container().child(move || {
let n = step.get();
let sink = sink.clone();
leaving_card().on_hover(move |hovered| sink.borrow_mut().push((n, hovered)))
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
h.send(Event::mouse_move(20.0, 20.0));
assert_eq!(*heard.borrow(), [(0, true)], "the setup needs it hovered");
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(*heard.borrow(), [(0, true), (0, false)]);
}
#[test]
fn a_signal_read_inside_a_leaving_child_queues_nothing_for_it() {
let step = create_signal(0u32);
let tint = create_signal(0.0f32);
let mut h = H::new(container().child(move || {
step.get();
leaving_card().background(move || Color::rgb(tint.get(), 0.0, 0.0))
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
h.paint();
let first = h.children()[0];
assert!(
jobs_for(&mut h, first, || tint.set(0.1)).contains(&JobType::Paint),
"the control: before removal the write repaints it"
);
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
frame_at(&mut h, after(t0, 50), 400.0, 400.0);
h.paint();
assert!(h.tree.contains(first), "it is leaving, and painting");
let queued = jobs_for(&mut h, first, || tint.set(0.5));
assert!(
queued.is_empty(),
"a leaving child must not be woken by what it read, got {queued:?}"
);
}
fn jobs_for(h: &mut H, id: WidgetId, write: impl FnOnce()) -> Vec<JobType> {
h.drain_jobs();
write();
jobs::queued_job_types(id)
}
#[test]
fn a_key_that_comes_back_mid_exit_reclaims_its_row() {
use std::cell::Cell;
let items = create_signal(vec![1u32, 2, 3]);
let builds = Rc::new(Cell::new(0u32));
let counted = builds.clone();
let mut h = H::new(container().children(crate::widgets::keyed(
move || items.get(),
|k| *k,
move |_| {
counted.set(counted.get() + 1);
leaving_card()
},
)));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let second = h.children()[1];
assert_eq!(builds.get(), 3);
items.set(vec![1, 3]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
let halfway = painted_x(&h.paint(), second).expect("leaving, and painting");
assert!((halfway + 50.0).abs() < 0.5, "half way out, got {halfway}");
items.set(vec![1, 2, 3]);
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
assert!(
h.children().contains(&second),
"the row that was leaving is the row that came back"
);
assert_eq!(h.children().len(), 3, "and nothing was built beside it");
assert_eq!(builds.get(), 3, "its builder did not run again");
frame_at(&mut h, after(t0, 210), 400.0, 400.0);
let returning = painted_x(&h.paint(), second).expect("back in the list");
assert!(
returning < -1.0 && returning > -49.0,
"on its way home from -50, neither snapped back nor restarted: got \
{returning}"
);
frame_at(&mut h, after(t0, 500), 400.0, 400.0);
frame_at(&mut h, after(t0, 510), 400.0, 400.0);
assert!(
h.tree.contains(second),
"and it is not disposed later either"
);
assert_eq!(painted_x(&h.paint(), second), Some(0.0), "home");
}
fn keyed_cards(
items: RwSignal<Vec<u32>>,
still: &'static [u32],
t0: std::time::Instant,
) -> (H, rustc_hash::FxHashMap<WidgetId, u32>) {
let mut h = H::new(container().children(crate::widgets::keyed(
move || items.get(),
|k| *k,
move |k| {
if still.contains(&k) {
container().width(100.0).height(50.0)
} else {
leaving_card()
}
},
)));
frame_at(&mut h, t0, 400.0, 400.0);
let ids = h
.children()
.into_iter()
.zip(items.get_untracked())
.collect();
(h, ids)
}
fn row_order(h: &H, ids: &rustc_hash::FxHashMap<WidgetId, u32>) -> Vec<(Option<u32>, f32)> {
let mut rows: Vec<_> = h
.children()
.into_iter()
.map(|child| {
let key = ids.get(&child).copied();
(key, h.tree.get_bounds(child).unwrap().y)
})
.collect();
rows.sort_by(|a, b| a.1.total_cmp(&b.1));
rows
}
#[test]
fn a_leaving_row_stays_after_the_rows_that_stood_before_it() {
let items = create_signal(vec![1u32, 2, 3]);
let t0 = std::time::Instant::now();
let (mut h, ids) = keyed_cards(items, &[], t0);
items.set(vec![9, 1, 2]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(
row_order(&h, &ids),
[
(None, 0.0),
(Some(1), 50.0),
(Some(2), 100.0),
(Some(3), 150.0)
],
"9, 1, 2, then 3 leaving"
);
}
#[test]
fn a_row_removed_without_an_exit_does_not_move_a_leaving_one_past_its_neighbour() {
let items = create_signal(vec![1u32, 2, 3]);
let t0 = std::time::Instant::now();
let (mut h, ids) = keyed_cards(items, &[1], t0);
items.set(vec![3, 4]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(
row_order(&h, &ids),
[(Some(2), 0.0), (Some(3), 50.0), (None, 100.0)],
"2 leaving, then 3, then 4"
);
}
#[test]
fn a_leaving_row_follows_its_nearest_predecessor_through_a_reorder() {
let items = create_signal(vec![1u32, 2, 3]);
let t0 = std::time::Instant::now();
let (mut h, ids) = keyed_cards(items, &[], t0);
items.set(vec![2, 1]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert_eq!(
row_order(&h, &ids),
[(Some(2), 0.0), (Some(3), 50.0), (Some(1), 100.0)],
"2, 3 leaving, then 1"
);
}
fn rows_with_an_effect(
items: crate::reactive::RwSignal<Vec<u32>>,
watched: crate::reactive::RwSignal<u32>,
) -> (H, Rc<std::cell::Cell<u32>>) {
let runs = Rc::new(std::cell::Cell::new(0u32));
let counted = runs.clone();
let h = H::new(container().children(crate::widgets::keyed(
move || items.get(),
|k| *k,
move |k| {
if k == 2 {
let counted = counted.clone();
crate::reactive::create_effect(move || {
watched.get();
counted.set(counted.get() + 1);
});
}
leaving_card()
},
)));
(h, runs)
}
#[test]
fn an_effect_inside_a_leaving_row_does_not_run() {
let items = create_signal(vec![1u32, 2, 3]);
let watched = create_signal(0u32);
let (mut h, runs) = rows_with_an_effect(items, watched);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
watched.set(1);
assert_eq!(runs.get(), 2, "the control: a live row's effect runs");
items.set(vec![1, 3]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
watched.set(2);
watched.set(3);
frame_at(&mut h, after(t0, 60), 400.0, 400.0);
assert_eq!(
runs.get(),
2,
"an effect in a row that is leaving must not run"
);
}
#[test]
fn a_reclaimed_row_runs_what_it_missed_once() {
let items = create_signal(vec![1u32, 2, 3]);
let watched = create_signal(0u32);
let (mut h, runs) = rows_with_an_effect(items, watched);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
assert_eq!(runs.get(), 1);
items.set(vec![1, 3]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
watched.set(1);
watched.set(2);
items.set(vec![1, 2, 3]);
frame_at(&mut h, after(t0, 60), 400.0, 400.0);
assert_eq!(runs.get(), 2, "the write it missed runs it once on reclaim");
watched.set(3);
assert_eq!(runs.get(), 3, "and it is live again afterwards");
}
#[test]
fn a_reclaimed_row_that_missed_nothing_does_not_run() {
let items = create_signal(vec![1u32, 2, 3]);
let watched = create_signal(0u32);
let (mut h, runs) = rows_with_an_effect(items, watched);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
items.set(vec![1, 3]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
items.set(vec![1, 2, 3]);
frame_at(&mut h, after(t0, 60), 400.0, 400.0);
assert_eq!(runs.get(), 1);
}
#[test]
fn an_effect_inside_a_leaving_row_is_disposed_when_the_exit_settles() {
let items = create_signal(vec![1u32, 2, 3]);
let watched = create_signal(0u32);
let (mut h, runs) = rows_with_an_effect(items, watched);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let second = h.children()[1];
items.set(vec![1, 3]);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
watched.set(1);
frame_at(&mut h, after(t0, 220), 400.0, 400.0);
frame_at(&mut h, after(t0, 230), 400.0, 400.0);
assert!(!h.tree.contains(second), "the exit has settled");
watched.set(2);
assert_eq!(runs.get(), 1, "a disposed row's effect never runs again");
}
#[test]
fn a_slide_changed_twice_within_one_exit_leaves_the_first_where_it_was_going() {
use crate::layout::ZStack;
let step = create_signal(0u32);
let mut h = H::new(container().layout(ZStack::new()).child(move || {
step.get();
leaving_card()
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let first = h.children()[0];
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
step.set(2);
frame_at(&mut h, after(t0, 110), 400.0, 400.0);
frame_at(&mut h, after(t0, 160), 400.0, 400.0);
let x = painted_x(&h.paint(), first).expect("still leaving");
assert!(
(x + 75.0).abs() < 0.5,
"three quarters of the way out, as if nothing had happened: got {x}"
);
assert_eq!(h.children().len(), 3, "two leaving, one arriving");
}
#[test]
fn an_exit_takes_what_the_property_takes() {
let angle = create_signal(0.0f32);
let _ = container()
.rotate(45.0f32.transition(200.0).exiting_to(0i32))
.scale(Scale::uniform(1.0).transition(200.0).exiting_to(0.0f64))
.width(120.0f32.transition(200.0).exiting_to(0u16))
.corners(4.0.transition(200.0).exiting_to(move || angle.get()))
.padding(
Padding::all(2.0)
.transition(200.0)
.exiting_to(Padding::all(0.0)),
)
.shadow(Shadow::none().transition(200.0).exiting_to(Shadow::none()));
let _ = container().rotate(0.0f32.transition(200.0).exiting_to(angle));
}
#[test]
#[should_panic(expected = "a timeline plays a sequence of its own")]
fn an_exit_on_a_timeline_refuses() {
let plays = create_signal(0u32);
let _ = 0.0f32
.timeline(Keyframes::new(100.0).at(1.0, 1.0).played_by(plays))
.exiting_to(0.0);
}
#[test]
fn an_ancestor_taken_away_takes_a_leaving_child_with_it() {
let outer = create_signal(true);
let step = create_signal(0u32);
let mut h = H::new(container().child(move || {
outer.get().then(|| {
container().child(move || {
step.get();
leaving_card()
})
})
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let host = h.children()[0];
let first = h.tree.get_children(host)[0];
step.set(1);
frame_at(&mut h, after(t0, 10), 400.0, 400.0);
assert!(h.tree.contains(first), "leaving");
outer.set(false);
frame_at(&mut h, after(t0, 20), 400.0, 400.0);
assert!(!h.tree.contains(host));
assert!(
!h.tree.contains(first),
"taken with the container it was in"
);
}
fn blur_radii(node: &RenderNode) -> Vec<f32> {
node.commands
.iter()
.filter_map(|c| match &**c {
DrawCommand::BackdropBlur { radius, .. } => Some(*radius),
_ => None,
})
.collect()
}
fn gradient_ends(node: &RenderNode) -> Option<(Color, Color)> {
node.commands.iter().find_map(|c| match &**c {
DrawCommand::RoundedRect {
gradient: Some(gradient),
..
} => Some((gradient.start_color, gradient.end_color)),
_ => None,
})
}
#[test]
fn a_gradient_keeps_its_shadow() {
let mut h = H::new(
container()
.width(40.0)
.height(20.0)
.shadow(LOW)
.gradient(LinearGradient::horizontal(Color::RED, Color::BLUE)),
);
h.fit(100.0, 100.0);
let node = h.paint();
assert_eq!(gradient_ends(&node), Some((Color::RED, Color::BLUE)));
assert!(
drawn_shadow(&node).is_some(),
"the gradient still casts its shadow"
);
}
#[test]
fn a_border_with_nothing_to_show_draws_nothing() {
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.border(2.0, Color::TRANSPARENT),
);
h.fit(100.0, 100.0);
assert_eq!(borders(&h.paint()), Vec::new(), "an invisible colour");
let mut h = H::new(container().width(20.0).height(20.0).border(0.0, Color::RED));
h.fit(100.0, 100.0);
assert_eq!(borders(&h.paint()), Vec::new(), "a zero width");
}
#[test]
fn a_border_appears_when_its_colour_does() {
let visible = create_signal(false);
let mut h = H::new(container().width(20.0).height(20.0).border(2.0, move || {
if visible.get() {
Color::RED
} else {
Color::TRANSPARENT
}
}));
h.fit(100.0, 100.0);
assert_eq!(borders(&h.paint()), Vec::new());
visible.set(true);
assert_eq!(borders(&h.paint()), vec![(2.0, Color::RED)]);
}
fn clip_host(clipped: Signal<bool>, clicked: RwSignal<i32>) -> Container {
container()
.width(40.0)
.height(20.0)
.layout(Flex::row())
.overflow(move || {
if clipped.get() {
Overflow::Hidden
} else {
Overflow::Visible
}
})
.child(box_of(40.0, 20.0))
.child(
container()
.width(40.0)
.height(20.0)
.on_click(move || clicked.update(|c| *c += 1)),
)
}
#[test]
fn hidden_overflow_stops_events_reaching_a_clipped_child() {
let clicked = create_signal(0);
let clipped = create_signal(true);
let mut h = H::new(clip_host(clipped.into(), clicked));
h.fit(500.0, 500.0);
h.paint();
for event in click_at(60.0, 10.0) {
h.send(event);
}
assert_eq!(clicked.get_untracked(), 0, "clipped away, so not clickable");
clipped.set(false);
pump(&mut h);
h.fit(500.0, 500.0);
h.paint();
for event in click_at(60.0, 10.0) {
h.send(event);
}
assert_eq!(clicked.get_untracked(), 1, "unclipped, so clickable");
}
#[test]
fn events_use_the_clip_of_the_frame_on_screen() {
let clicked = create_signal(0);
let clipped = create_signal(false);
let mut h = H::new(clip_host(clipped.into(), clicked));
h.fit(500.0, 500.0);
h.paint();
clipped.set(true);
for event in click_at(60.0, 10.0) {
h.send(event);
}
assert_eq!(
clicked.get_untracked(),
1,
"the pill the user can see is still clickable"
);
pump(&mut h);
h.fit(500.0, 500.0);
h.paint();
for event in click_at(60.0, 10.0) {
h.send(event);
}
assert_eq!(clicked.get_untracked(), 1);
}
#[test]
fn the_damage_reach_covers_the_shadow_a_hover_can_reach() {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(Shadow::none().transition(Transition::new(80.0, TimingFunction::Linear)))
.when_hovered(|s| s.shadow(LIFTED)),
);
h.fit(100.0, 100.0);
let reach = h.tree.paint_overflow(h.root);
let lifted = LIFTED.extent();
assert!(
reach >= lifted && lifted > 0.0,
"reach {reach} must cover the hovered shadow {lifted}"
);
let mut flat = H::new(container().width(40.0).height(40.0).background(Color::RED));
flat.fit(100.0, 100.0);
assert_eq!(flat.tree.paint_overflow(flat.root), 0.0);
}
#[test]
fn a_transform_that_is_not_a_number_reaches_nowhere() {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.translate((f32::NAN, f32::NAN)),
);
h.fit(200.0, 200.0);
let reach = h.tree.paint_overflow(h.root);
assert!(
reach.is_finite(),
"a translate nobody can compute put {reach} in the damage rect"
);
assert_eq!(reach, 0.0, "and it is coerced away, not merely made finite");
}
#[test]
fn a_layer_declaring_one_transform_alone_still_counts_towards_the_reach() {
let reach_of = |decorate: fn(StateStyle) -> StateStyle| {
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.when_hovered(decorate),
);
h.fit(200.0, 200.0);
h.tree.paint_overflow(h.root)
};
assert!(
reach_of(|s| s.translate(Translate::new(30.0, 0.0))) > 0.0,
"a layer that only translates still moves the box"
);
assert!(
reach_of(|s| s.rotate(45.0)) > 0.0,
"a layer that only rotates still sweeps the corners out"
);
assert!(
reach_of(|s| s.scale(Scale::uniform(2.0))) > 0.0,
"a layer that only scales still grows the box"
);
assert_eq!(
reach_of(|s| s.background(Color::BLUE)),
0.0,
"a layer that only recolours reaches no further than the box"
);
}
#[test]
fn the_damage_reach_allows_for_a_spring_overshooting() {
let bouncy = |transition: Transition| {
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(Shadow::none().transition(transition))
.when_hovered(|s| s.shadow(LIFTED))
};
let mut eased = H::new(bouncy(Transition::new(80.0, TimingFunction::Linear)));
eased.fit(100.0, 100.0);
let without_bounce = eased.tree.paint_overflow(eased.root);
let mut sprung = H::new(bouncy(Transition::spring(SpringConfig::BOUNCY)));
sprung.fit(100.0, 100.0);
let with_bounce = sprung.tree.paint_overflow(sprung.root);
assert!(
with_bounce > without_bounce,
"a bouncy spring has to reach further than an ease: {with_bounce} vs {without_bounce}"
);
assert!(
with_bounce >= LIFTED.extent() * 1.17 - 0.01,
"and far enough for BOUNCY's ~17%, got {with_bounce}"
);
}
type ShadowChannel = (&'static str, fn(&Shadow) -> f32);
const LIFTED: Shadow = Shadow::new((0.0, 6.0), 10.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.22));
const LOW: Shadow = Shadow::new((0.0, 2.0), 4.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.16));
const FAINT: Shadow = Shadow::new((0.0, 1.0), 3.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.001));
fn drawn_shadow(node: &RenderNode) -> Option<Shadow> {
node.commands.iter().find_map(|c| match &**c {
DrawCommand::RoundedRect {
shadow: Some(shadow),
..
} => Some(*shadow),
_ => None,
})
}
fn shadows_in(node: &RenderNode) -> Vec<Shadow> {
let mut found = drawn_shadow(node).into_iter().collect::<Vec<_>>();
for child in &node.children {
found.extend(shadows_in(child));
}
found
}
#[test]
fn every_field_of_a_shadow_reaches_the_renderer() {
let declared = Shadow::new((12.0, -4.0), 8.0, 3.0, Color::rgba(1.0, 0.0, 0.0, 0.5));
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(declared),
);
h.fit(100.0, 100.0);
let painted = drawn_shadow(&h.paint()).expect("a container with a shadow casts one");
assert_eq!(painted.offset, (12.0, -4.0), "sideways, and upward");
assert_eq!(painted.blur, 8.0);
assert_eq!(
painted.spread, 3.0,
"the field the table always left at zero"
);
assert_eq!(painted.color, Color::rgba(1.0, 0.0, 0.0, 0.5), "not black");
}
#[test]
fn a_declared_shadow_eases_only_where_the_declaration_carries_a_motion() {
let lifted = create_signal(false);
let flat = Shadow::new((6.0, 2.0), 4.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.4));
let deep = Shadow::new((-10.0, 12.0), 24.0, 6.0, Color::rgba(0.0, 0.0, 0.0, 0.55));
let pick = move || if lifted.get() { deep } else { flat };
let mut h = H::new(
container()
.width(120.0)
.height(120.0)
.background(Color::RED)
.shadow(pick.transition(Transition::new(60_000.0, TimingFunction::Linear)))
.child(
container()
.width(40.0)
.height(40.0)
.background(Color::BLUE)
.shadow(pick),
),
);
h.fit(200.0, 200.0);
h.paint();
assert_eq!(shadows_in(&h.paint()), vec![flat, flat], "both at rest");
lifted.set(true);
h.fit(200.0, 200.0);
assert_eq!(
shadows_in(&h.paint())[1],
deep,
"no motion declared, so it is simply there"
);
std::thread::sleep(std::time::Duration::from_millis(20));
pump(&mut h);
h.fit(200.0, 200.0);
h.paint();
let mut seen = Vec::new();
for _ in 0..2 {
std::thread::sleep(std::time::Duration::from_millis(20));
pump(&mut h);
h.fit(200.0, 200.0);
seen.push(shadows_in(&h.paint())[0]);
}
let channels: [ShadowChannel; 5] = [
("offset.x", |s| s.offset.0),
("offset.y", |s| s.offset.1),
("blur", |s| s.blur),
("spread", |s| s.spread),
("alpha", |s| s.color.a),
];
for (name, read) in channels {
let (from, to) = (read(&flat), read(&deep));
let (first, second) = (read(&seen[0]), read(&seen[1]));
let moving = if to > from {
from < first && first < second && second < to
} else {
from > first && first > second && second > to
};
assert!(
moving,
"{name} has to be on its way from {from} to {to} and still moving, \
got {first} then {second}"
);
}
assert_eq!(
shadows_in(&h.paint())[1],
deep,
"and the one beside it has been there all along"
);
}
#[test]
fn a_falling_shadow_animates_down_instead_of_snapping() {
let falling = |ramp_ms: f32| {
let declared = create_signal(LIFTED);
let h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(
(move || declared.get())
.transition(Transition::new(ramp_ms, TimingFunction::Linear)),
),
);
(h, declared)
};
let (mut h, declared) = falling(60_000.0);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_some(),
"a lifted card casts a shadow"
);
declared.set(Shadow::none());
for step in 0..5 {
std::thread::sleep(std::time::Duration::from_millis(4));
pump(&mut h);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_some(),
"frame {step} of the descent drew no shadow at all"
);
}
let (mut h, declared) = falling(60.0);
h.fit(100.0, 100.0);
h.paint();
declared.set(Shadow::none());
pump(&mut h);
settle(&mut h, 200);
assert!(drawn_shadow(&h.paint()).is_none(), "and settles flat");
}
#[test]
fn a_shadow_going_flat_damages_the_ring_it_gave_up() {
let declared = create_signal(LIFTED);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(move || declared.get()),
);
h.fit(100.0, 100.0);
h.paint();
let lifted = h.tree.paint_overflow(h.root);
assert!(lifted > 0.0, "a lifted card has to reach past its box");
let ring = h.tree.get_bounds(h.root).expect("laid out").outset(lifted);
let _ = h.tree.take_damage(h.root);
declared.set(Shadow::none());
pump(&mut h);
h.fit(100.0, 100.0);
assert_eq!(
h.tree.paint_overflow(h.root),
0.0,
"the shadow is what this frame gives up"
);
match h.tree.take_damage(h.root) {
crate::tree::DamageRegion::Partial(rect) => assert_eq!(
ring.outset_beyond(rect),
0.0,
"the frame that flattens the shadow damages {rect:?}, so the ring \
it cast at {ring:?} is left on screen"
),
other => panic!("expected partial damage, got {other:?}"),
}
}
#[test]
fn hover_flicker_cannot_push_a_shadow_outside_its_damage_rect() {
let pumpable = SpringConfig {
mass: 1.0,
stiffness: 3600.0,
damping: 12.0,
};
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(Shadow::none().transition(Transition::spring(pumpable)))
.when_hovered(|s| s.shadow(LOW)),
);
h.fit(100.0, 100.0);
h.paint();
let reach = h.tree.paint_overflow(h.root);
let mut inside = false;
let mut worst: f32 = 0.0;
let mut last_flip = std::time::Instant::now();
for step in 0..120 {
if last_flip.elapsed() >= std::time::Duration::from_millis(52) {
inside = !inside;
set_hover(&mut h, inside);
last_flip = std::time::Instant::now();
}
std::thread::sleep(std::time::Duration::from_millis(4));
pump(&mut h);
h.fit(100.0, 100.0);
let node = h.paint();
if let Some(extent) = drawn_shadow(&node).map(|s| s.extent()) {
worst = worst.max(extent);
assert!(
extent <= reach + 0.01,
"step {step} drew a shadow reaching {extent} outside a damage rect of {reach}"
);
}
}
assert!(worst > 0.0, "the flicker has to actually raise a shadow");
}
#[test]
fn a_spring_between_pulled_in_shadows_is_never_rescaled() {
let deep_inside = Shadow::new((0.0, 0.0), 2.0, -20.0, Color::BLACK);
let near_edge = Shadow::new((0.0, 0.0), 2.0, -4.2, Color::BLACK);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(deep_inside.transition(Transition::spring(SpringConfig::BOUNCY)))
.when_hovered(|s| s.shadow(near_edge)),
);
h.fit(100.0, 100.0);
h.paint();
set_hover(&mut h, true);
let step_travelled = near_edge.signed_extent() - deep_inside.signed_extent();
let headroom =
near_edge.signed_extent() + step_travelled * SpringConfig::BOUNCY.peak_overshoot();
let t0 = std::time::Instant::now();
let mut furthest = f32::NEG_INFINITY;
for step in 1..=60 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(5 * step),
100.0,
100.0,
);
let reach = h.tree.paint_overflow(h.root);
assert!(
reach <= headroom + 0.05,
"step {step} reserved {reach} past the box, the bounce reaches {headroom}"
);
let node = h.paint();
let Some(drawn) = drawn_shadow(&node) else {
continue;
};
furthest = furthest.max(drawn.spread);
assert!(
(drawn.blur - 2.0).abs() < 1e-3,
"step {step} drew {drawn:?}, scaled away from the declared blur of 2"
);
assert!(
drawn.extent() <= reach + 0.01,
"step {step} drew a shadow reaching {} outside a damage rect of {reach}",
drawn.extent()
);
}
assert!(
furthest > -4.0,
"the spring has to carry the shadow past the edge, got a spread of {furthest}"
);
}
#[test]
fn a_timeline_between_pulled_in_shadows_is_never_rescaled() {
let plays = create_signal(0u32);
let deep_inside = Shadow::new((0.0, 0.0), 2.0, -20.0, Color::BLACK);
let near_edge = Shadow::new((0.0, 0.0), 2.0, -4.2, Color::BLACK);
let overshooting = TimingFunction::CubicBezier(0.5, -0.6, 0.5, 1.2);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(
deep_inside.timeline(
Keyframes::new(200.0)
.at_with(0.0, deep_inside, overshooting.clone())
.at(1.0, near_edge)
.played_by(plays),
),
),
);
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 100.0, 100.0);
h.paint();
plays.set(1);
let mut furthest = f32::NEG_INFINITY;
for step in 1..=40 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(5 * step),
100.0,
100.0,
);
let reach = h.tree.paint_overflow(h.root);
let node = h.paint();
let Some(drawn) = drawn_shadow(&node) else {
continue;
};
furthest = furthest.max(drawn.spread);
assert!(
(drawn.blur - 2.0).abs() < 1e-3,
"step {step} drew {drawn:?}, scaled away from the declared blur of 2"
);
assert!(
drawn.extent() <= reach + 0.01,
"step {step} drew a shadow reaching {} outside a damage rect of {reach}",
drawn.extent()
);
}
assert!(
furthest > -4.0,
"the easing has to carry the shadow past the edge, got a spread of {furthest}"
);
}
#[test]
fn a_shadow_timeline_is_drawn_at_the_depth_its_keyframes_reach() {
let plays = create_signal(0u32);
let deep = Shadow::new((0.0, 0.0), 20.0, 0.0, Color::BLACK);
let shallow = Shadow::new((0.0, 0.0), 2.0, 0.0, Color::BLACK);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(
shallow.timeline(
Keyframes::new(200.0)
.at(0.0, shallow)
.at(1.0, deep)
.played_by(plays),
),
),
);
h.fit(100.0, 100.0);
h.paint();
let reach = h.tree.paint_overflow(h.root);
assert!(
reach >= deep.extent() - 0.01,
"the reach has to cover the deepest keyframe ({}), got {reach}",
deep.extent()
);
plays.set(1);
pump(&mut h);
let mut deepest: f32 = 0.0;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
pump(&mut h);
h.fit(100.0, 100.0);
let node = h.paint();
if let Some(extent) = drawn_shadow(&node).map(|s| s.extent()) {
deepest = deepest.max(extent);
assert!(
extent <= reach + 0.01,
"drew a shadow reaching {extent} outside a damage rect of {reach}"
);
}
}
assert!(
deepest > shallow.extent() + 1.0,
"the sequence has to actually deepen the shadow past the declared \
{} it was clamped back to, got {deepest}",
shallow.extent()
);
}
#[test]
fn a_declared_shadow_change_invalidates_the_reach() {
let declared = create_signal(Shadow::none());
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(move || declared.get()),
);
h.fit(100.0, 100.0);
h.paint();
assert_eq!(h.tree.paint_overflow(h.root), 0.0);
let queued = h.jobs_from(|| declared.set(LOW));
assert!(queued.contains(&JobType::Layout), "got {queued:?}");
declared.set(LIFTED);
pump(&mut h);
h.fit(100.0, 100.0);
assert!(h.tree.paint_overflow(h.root) > 0.0);
}
#[test]
fn a_clipping_container_does_not_carry_its_content_s_overhang() {
let rows: Vec<_> = (0..20)
.map(|_| container().width(100.0).height(50.0).background(Color::RED))
.collect();
let mut clipped = H::new(
container()
.width(100.0)
.height(100.0)
.scroll(Scroll::vertical())
.layout(Flex::column())
.children(rows),
);
clipped.fit(400.0, 400.0);
assert_eq!(
clipped.tree.paint_overflow(clipped.root),
0.0,
"a scroller reported the height of everything inside it as paint that \
lands outside its own box"
);
}
#[test]
fn a_clipping_container_keeps_not_carrying_it_when_a_child_moves() {
let lift = create_signal(0.0f32);
let mut rows: Vec<_> = (0..20)
.map(|_| container().width(100.0).height(50.0).background(Color::RED))
.collect();
rows[0] = container()
.width(100.0)
.height(50.0)
.background(Color::BLUE)
.translate(move || Translate::new(0.0, -lift.get()));
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.scroll(Scroll::vertical())
.layout(Flex::column())
.children(rows),
);
h.fit(400.0, 400.0);
h.paint();
assert_eq!(h.tree.paint_overflow(h.root), 0.0);
lift.set(20.0);
pump(&mut h);
assert_eq!(
h.tree.paint_overflow(h.root),
0.0,
"one row moved and the scroller went back to claiming it paints the \
whole column outside its own box"
);
}
fn lifted_box(lift: RwSignal<f32>) -> Container {
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.translate(move || Translate::new(0.0, -lift.get()))
}
#[test]
fn a_declared_transform_change_invalidates_the_reach_without_a_layout() {
let lift = create_signal(0.0f32);
let mut h = H::new(lifted_box(lift));
h.fit(100.0, 100.0);
h.paint();
assert_eq!(h.tree.paint_overflow(h.root), 0.0);
lift.set(30.0);
let queued = jobs::queued_job_types(h.root);
assert!(
!queued.contains(&JobType::Layout),
"a transform asked for a layout: {queued:?}"
);
pump(&mut h);
assert_eq!(
h.tree.paint_overflow(h.root),
30.0,
"the reach did not follow the transform through its Paint job"
);
}
#[test]
fn a_reach_that_grows_under_a_relayout_boundary_still_reaches_the_parent() {
let lift = create_signal(0.0f32);
let mut h = H::new(container().layout(Flex::column()).child(lifted_box(lift)));
h.fit(200.0, 200.0);
h.paint();
assert_eq!(h.tree.children_reach(h.root), 0.0);
lift.set(70.0);
pump(&mut h);
assert_eq!(
h.tree.children_reach(h.root),
70.0,
"the row moved under a relayout boundary and its parent never heard, so \
the search that narrows it would drop a row that is on screen"
);
}
#[test]
fn a_reach_that_shrinks_lets_the_parent_narrow_again() {
let lift = create_signal(70.0f32);
let mut h = H::new(container().layout(Flex::column()).child(lifted_box(lift)));
h.fit(200.0, 200.0);
h.paint();
assert_eq!(h.tree.children_reach(h.root), 70.0);
lift.set(0.0);
pump(&mut h);
assert_eq!(
h.tree.children_reach(h.root),
0.0,
"the parent stayed widened by a transform that has come to rest"
);
}
#[test]
fn switching_a_compositor_blur_off_withdraws_it_on_that_frame() {
let frosted = create_signal(true);
let mut h = H::new(
container()
.width(40.0)
.height(20.0)
.backdrop_blur(move || if frosted.get() { 24.0 } else { 0.0 }),
);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "frosted");
frosted.set(false);
assert_eq!(
h.frame(100.0, 100.0).blur,
Vec::new(),
"the frame that turns it off is the frame that stops publishing it"
);
assert_eq!(h.frame(100.0, 100.0).blur, Vec::new());
frosted.set(true);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "and back on");
}
#[test]
fn a_surface_only_blur_publishes_no_compositor_region() {
let mut h = H::new(
container()
.width(40.0)
.height(20.0)
.backdrop_blur(BackdropBlur::new(24.0).sources(BackdropSources::SURFACE)),
);
let frame = h.frame(100.0, 100.0);
assert_eq!(frame.blur, Vec::new());
assert_eq!(blur_radii(&frame.node), vec![24.0], "but it draws one");
}
#[test]
fn a_compositor_only_blur_is_published_without_a_radius() {
let mut h = H::new(
container()
.width(40.0)
.height(20.0)
.backdrop_blur(BackdropBlur::new(0.0).sources(BackdropSources::COMPOSITOR)),
);
let frame = h.frame(100.0, 100.0);
assert!(
!frame.blur.is_empty(),
"the compositor's half does not read the radius"
);
assert_eq!(
blur_radii(&frame.node),
vec![0.0],
"and the command carries the zero, so the surface half draws nothing"
);
}
#[test]
fn an_emptied_source_set_asks_for_nothing_at_any_radius() {
for radius in [0.0, 24.0] {
let mut h = H::new(
container()
.width(40.0)
.height(20.0)
.backdrop_blur(BackdropBlur::new(radius).sources(BackdropSources::empty())),
);
let frame = h.frame(100.0, 100.0);
assert_eq!(frame.blur, Vec::new(), "radius {radius} publishes nothing");
assert_eq!(
blur_radii(&frame.node),
Vec::<f32>::new(),
"radius {radius} draws nothing"
);
}
}
#[test]
fn hiding_a_panel_withdraws_the_blur_of_everything_inside_it() {
let open = create_signal(true);
let mut h = H::new(
container()
.width(60.0)
.height(40.0)
.visible(move || open.get())
.child(container().width(40.0).height(20.0).backdrop_blur(24.0)),
);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "open");
open.set(false);
assert_eq!(
h.frame(100.0, 100.0).blur,
Vec::new(),
"a hidden panel blurs nothing, descendants included"
);
assert_eq!(h.frame(100.0, 100.0).blur, Vec::new(), "and stays that way");
open.set(true);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "and back");
}
#[test]
fn a_blur_served_from_the_paint_cache_is_still_published() {
let open = create_signal(true);
let mut h = H::new(
container()
.width(60.0)
.height(40.0)
.visible(move || open.get())
.child(container().width(40.0).height(20.0).backdrop_blur(24.0)),
);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "open");
open.set(false);
assert_eq!(h.frame(100.0, 100.0).blur, Vec::new(), "hidden");
open.set(true);
assert!(
!h.frame(100.0, 100.0).blur.is_empty(),
"a blur that came back from the cache still has to be published"
);
assert!(!h.frame(100.0, 100.0).blur.is_empty(), "and stays");
}
#[test]
fn scrolling_a_frosted_card_away_withdraws_its_blur() {
let mut rows: Vec<crate::widgets::AnyWidget> = vec![
container()
.width(40.0)
.height(20.0)
.backdrop_blur(24.0)
.into_any(),
];
rows.extend((0..60).map(|_| box_of(40.0, 20.0).into_any()));
let mut h = H::new(
container()
.width(40.0)
.height(30.0)
.scroll(Scroll::vertical())
.children(rows),
);
assert!(
!h.frame(40.0, 30.0).blur.is_empty(),
"visible at the top of the scroll"
);
h.send(Event::scroll(20.0, 15.0, 0.0, 600.0, ScrollSource::Wheel));
h.frame(40.0, 30.0);
assert_eq!(
h.frame(40.0, 30.0).blur,
Vec::new(),
"scrolled away, so it blurs nothing"
);
}
#[test]
fn a_gradient_can_be_switched_off_and_the_background_returns() {
let expanded = create_signal(true);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.background(Color::GRAY)
.gradient(move || {
expanded
.get()
.then(|| LinearGradient::horizontal(Color::RED, Color::BLUE))
}),
);
h.fit(100.0, 100.0);
assert_eq!(gradient_ends(&h.paint()), Some((Color::RED, Color::BLUE)));
expanded.set(false);
let node = h.paint();
assert_eq!(gradient_ends(&node), None, "no gradient");
assert_eq!(
rects(&node).first().map(|(_, color)| *color),
Some(Color::GRAY),
"and the background it was covering is drawn again"
);
}
#[test]
fn a_shadow_too_faint_to_see_is_not_drawn() {
let declared = create_signal(FAINT);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.background(Color::RED)
.shadow(move || declared.get()),
);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_none(),
"invisible, so not drawn"
);
declared.set(LOW);
h.fit(100.0, 100.0);
assert!(
drawn_shadow(&h.paint()).is_some(),
"and drawn once it can be seen"
);
}
#[test]
fn a_clipped_blur_publishes_only_what_is_on_show() {
let card = || container().width(40.0).height(40.0).backdrop_blur(24.0);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.overflow(Overflow::Hidden)
.layout(Flex::row())
.child(card())
.child(card()),
);
let frame = h.frame(200.0, 200.0);
let widest = frame
.blur
.iter()
.map(|r| r.x + r.width)
.max()
.expect("the visible card still blurs");
assert!(
widest <= 40,
"the row is 80 wide inside a 40-wide clip, so no region may reach {widest}"
);
}
#[test]
fn a_transformed_blur_publishes_the_shape_it_is_drawn_as() {
let card = |c: Container| c.width(40.0).height(40.0).backdrop_blur(24.0);
let plain = H::new(card(container())).frame(200.0, 200.0).blur;
let scaled = H::new(card(container()).scale(2.0))
.frame(200.0, 200.0)
.blur;
let turned = H::new(card(container()).rotate(45.0))
.frame(200.0, 200.0)
.blur;
let span = |rects: &[crate::region::RegionRect]| {
let left = rects.iter().map(|r| r.x).min().expect("a region");
let right = rects.iter().map(|r| r.x + r.width).max().expect("a region");
right - left
};
let (plain, scaled, turned) = (span(&plain), span(&scaled), span(&turned));
assert!(
scaled >= plain * 2 - 2,
"twice the card is twice the region: {scaled} against {plain}"
);
assert!(
turned > plain && turned < scaled,
"turned on its corner it covers its diagonal, {turned} against {plain}"
);
}
#[test]
fn an_island_declares_its_corners_and_not_its_bounding_box() {
let takes = |x: i32, y: i32, ops: &[crate::region::InputRegionOp]| {
ops.iter().any(|op| {
op.takes
&& x >= op.rect.x
&& y >= op.rect.y
&& x < op.rect.x + op.rect.width
&& y < op.rect.y + op.rect.height
})
};
let square = H::new(box_of(40.0, 40.0).takes_input(true))
.frame(200.0, 200.0)
.input;
let round = H::new(box_of(40.0, 40.0).corners(20.0).takes_input(true))
.frame(200.0, 200.0)
.input;
assert!(takes(1, 1, &square), "a square island reaches its corner");
assert!(
!takes(1, 1, &round),
"and a round one does not, so the click there goes where the pixel does"
);
assert!(takes(20, 20, &round), "its middle is still its own");
}
#[test]
fn a_clipped_declaration_reaches_only_what_is_on_show() {
let island = || box_of(40.0, 40.0).takes_input(true);
let mut h = H::new(
container()
.width(40.0)
.height(40.0)
.overflow(Overflow::Hidden)
.layout(Flex::row())
.child(island())
.child(island()),
);
let widest = h
.frame(200.0, 200.0)
.input
.iter()
.map(|op| op.rect.x + op.rect.width)
.max()
.expect("the visible island still takes input");
assert!(
widest <= 40,
"the row is 80 wide inside a 40-wide clip, so nothing may take input at {widest}"
);
}
#[test]
fn a_fully_transparent_gradient_draws_nothing() {
let clear = Color::rgba(1.0, 0.0, 0.0, 0.0);
let mut h = H::new(
container()
.width(20.0)
.height(20.0)
.gradient(LinearGradient::horizontal(clear, clear)),
);
h.fit(100.0, 100.0);
assert_eq!(rects(&h.paint()), Vec::new(), "nothing to draw");
let mut half = H::new(
container()
.width(20.0)
.height(20.0)
.gradient(LinearGradient::horizontal(clear, Color::BLUE)),
);
half.fit(100.0, 100.0);
assert_eq!(
gradient_ends(&half.paint()),
Some((clear, Color::BLUE)),
"fading to transparent is a gradient"
);
let mut over = H::new(
container()
.width(20.0)
.height(20.0)
.background(Color::GRAY)
.gradient(LinearGradient::horizontal(clear, clear)),
);
over.fit(100.0, 100.0);
assert_eq!(
rects(&over.paint()).first().map(|(_, c)| *c),
Some(Color::GRAY)
);
}
#[test]
fn a_click_does_not_reach_a_handler_below_a_disabled_container() {
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.enabled(false)
.child(box_of(50.0, 20.0).on_click(move || counter.set(counter.get() + 1))),
);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
assert_eq!(h.send(event), EventResponse::Ignored);
}
assert_eq!(clicks.get(), 0, "the handler is below a disabled container");
}
#[test]
fn a_descendant_declaring_itself_enabled_stays_disabled() {
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container().width(100.0).height(100.0).enabled(false).child(
container()
.width(80.0)
.height(80.0)
.enabled(true)
.child(box_of(50.0, 20.0).on_click(move || counter.set(counter.get() + 1))),
),
);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 0, "an ancestor said no");
let inner = h.children()[0];
assert!(
h.tree
.nearest_control(inner)
.expect("declaring `enabled` makes a container an interaction unit")
.is_disabled(),
"the inner unit answers for its ancestors as well as itself"
);
}
#[test]
fn a_reactive_ancestor_still_folds_with_a_descendants_own_answer() {
let above = create_signal(false);
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container().width(100.0).height(100.0).enabled(above).child(
container()
.width(80.0)
.height(80.0)
.enabled(true)
.child(box_of(50.0, 20.0).on_click(move || counter.set(counter.get() + 1))),
),
);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 0, "a reactive ancestor saying no still wins");
let inner = h.children()[0];
assert!(
h.tree
.nearest_control(inner)
.expect("declaring `enabled` makes a container an interaction unit")
.is_disabled(),
"and the inner unit answers for the ancestor it cannot see"
);
above.set(true);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 1, "both say yes, so the click lands");
}
#[test]
fn a_reactive_descendant_under_a_constant_ancestor_folds_too() {
let own = create_signal(true);
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container().width(100.0).height(100.0).enabled(false).child(
container()
.width(80.0)
.height(80.0)
.enabled(own)
.child(box_of(50.0, 20.0).on_click(move || counter.set(counter.get() + 1))),
),
);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 0, "the constant ancestor's no wins");
own.set(false);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 0, "and saying no itself changes nothing");
}
#[test]
fn re_enabling_lets_the_next_click_through_and_costs_no_layout() {
let enabled = create_signal(false);
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.enabled(enabled)
.child(box_of(50.0, 20.0).on_click(move || counter.set(counter.get() + 1))),
);
h.fit(500.0, 500.0);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 0);
let jobs = h.jobs_from(|| enabled.set(true));
assert!(
!jobs.contains(&JobType::Layout),
"enabling changes what an event does, not where anything sits: {jobs:?}"
);
for event in click_at(5.0, 5.0) {
h.send(event);
}
assert_eq!(clicks.get(), 1, "the gate is open again");
}
#[test]
fn a_container_disabled_while_hovered_is_no_longer_hovered() {
let enabled = create_signal(true);
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.control()
.enabled(enabled)
.when_hovered(|s| s.background(Color::BLUE)),
);
h.fit(500.0, 500.0);
let control = h.tree.nearest_control(h.root).expect("a control");
set_hover(&mut h, true);
assert!(control.is_hovered());
enabled.set(false);
assert!(
!control.is_hovered(),
"a unit that refuses the pointer is not under it"
);
set_hover(&mut h, false);
enabled.set(true);
assert!(
!control.is_hovered(),
"the pointer is somewhere else entirely"
);
}
#[test]
fn a_text_below_a_disabled_container_styles_itself_from_it() {
let enabled = create_signal(true);
let mut h = H::new(
container()
.width(100.0)
.height(40.0)
.enabled(enabled)
.child(
crate::widgets::text("Label")
.color(Color::WHITE)
.when_disabled(|s| s.color(Color::GRAY)),
),
);
assert_eq!(painted_text_color(&mut h), Color::WHITE);
enabled.set(false);
assert_eq!(painted_text_color(&mut h), Color::GRAY);
enabled.set(true);
assert_eq!(painted_text_color(&mut h), Color::WHITE);
}
#[test]
fn a_disabled_scroller_does_not_scroll() {
let enabled = create_signal(false);
let rows: Vec<crate::widgets::AnyWidget> =
(0..10).map(|_| box_of(40.0, 20.0).into_any()).collect();
let mut h = H::new(
container()
.width(40.0)
.height(30.0)
.enabled(enabled)
.scroll(Scroll::vertical())
.children(rows),
);
h.fit(40.0, 30.0);
let wheel = || Event::scroll(20.0, 15.0, 0.0, 60.0, ScrollSource::Wheel);
assert_eq!(h.send(wheel()), EventResponse::Ignored);
enabled.set(true);
assert_eq!(
h.send(wheel()),
EventResponse::Handled,
"the same scroller, enabled, takes the wheel"
);
}
#[test]
fn a_value_that_is_not_a_number_lets_the_surface_go_idle() {
let angle = create_signal(0.0f32);
let mut h = H::new(box_of(50.0, 50.0).rotate(angle.transition(200.0)));
h.fit(200.0, 200.0);
h.paint();
angle.set(f32::NAN);
let t0 = std::time::Instant::now();
for step in 0..6 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(step * 200),
200.0,
200.0,
);
}
assert!(
!pump(&mut h),
"the surface is still animating towards a value that will never arrive"
);
}
#[test]
fn a_value_that_is_not_a_number_reads_as_if_it_were_not_declared() {
let angle = create_signal(45.0f32);
let mut h = H::new(box_of(50.0, 50.0).rotate(angle));
h.fit(200.0, 200.0);
assert!(
!h.paint().local_transform.is_identity(),
"the control turns before the bad number arrives"
);
angle.set(f32::NAN);
assert!(
h.paint().local_transform.is_identity(),
"a rotation nobody can compute is a rotation nobody asked for"
);
}
#[test]
fn a_property_follows_its_signal_again_once_the_signal_recovers() {
let poisoned = create_signal(0.0f32);
let clean = create_signal(0.0f32);
let mut h = H::new(
container()
.layout(Flex::row())
.child(box_of(50.0, 50.0).rotate(poisoned.transition(200.0)))
.child(box_of(50.0, 50.0).rotate(clean.transition(200.0))),
);
h.fit(400.0, 200.0);
h.paint();
let t0 = std::time::Instant::now();
poisoned.set(f32::NAN);
frame_at(&mut h, t0, 400.0, 200.0);
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(50),
400.0,
200.0,
);
poisoned.set(90.0);
clean.set(90.0);
for step in 1..=4 {
frame_at(
&mut h,
t0 + std::time::Duration::from_millis(step * 100),
400.0,
200.0,
);
}
let painted = h.paint();
let turn = |t: Transform| [t.a(), t.b(), t.c(), t.d()];
assert_eq!(
turn(painted.children[0].local_transform),
turn(painted.children[1].local_transform),
"a property given one bad number never followed its signal again"
);
}
#[test]
fn a_pivot_that_is_not_a_number_does_not_swallow_a_click() {
let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
let counter = clicks.clone();
let mut h = H::new(
box_of(50.0, 50.0)
.rotate(45.0)
.pivot(move || Pivot::percent(50.0, f32::NAN))
.on_click(move || counter.set(counter.get() + 1)),
);
h.fit(200.0, 200.0);
for event in click_at(25.0, 25.0) {
h.send(event);
}
assert_eq!(
clicks.get(),
1,
"a pivot nobody can compute stopped the container being clicked"
);
}
#[cfg(debug_assertions)]
#[test]
fn a_value_that_is_not_a_number_is_reported_once() {
use crate::reactive::diagnostics::report_count;
let angle = create_signal(f32::NAN);
let mut h = H::new(box_of(50.0, 50.0).rotate(angle));
h.fit(200.0, 200.0);
let before = report_count();
for _ in 0..5 {
h.paint();
}
let reports = report_count() - before;
assert_eq!(reports, 1, "five paints, and it should have said so once");
}
#[test]
fn every_declared_property_is_resolved_to_a_finite_value() {
let nan = f32::NAN;
let declared = || {
box_of(50.0, 50.0)
.width(nan)
.height(nan)
.padding(nan)
.corners(nan)
.border(nan, Color::rgba(nan, 0.0, 0.0, 1.0))
.rotate(nan)
.scale(nan)
.translate((nan, nan))
.pivot(Pivot::percent(nan, 50.0))
.background(Color::rgba(nan, 0.0, 0.0, 1.0))
.shadow(Shadow::new((nan, nan), nan, nan, Color::BLACK))
};
let mut h = H::new(declared());
h.fit(200.0, 200.0);
let id = h.root;
let c = declared();
let finite = |name: &str, channels: Vec<f32>| {
assert!(
channels.iter().all(|c| c.is_finite()),
"{name} resolved to a value that is not finite: {channels:?}"
);
};
{
finite(
"background",
c.effective_background_target(id).channels().to_vec(),
);
finite(
"border_color",
c.effective_border_color_target(id).channels().to_vec(),
);
finite("border_width", vec![c.effective_border_width_target(id)]);
finite(
"corners",
c.effective_corners_target(id).channels().to_vec(),
);
finite("shadow", c.effective_shadow_target(id).channels().to_vec());
finite(
"padding",
c.effective_padding_target(id).channels().to_vec(),
);
finite(
"translate",
c.effective_translate_target(id).channels().to_vec(),
);
finite("rotate", vec![c.effective_rotate_target(id)]);
finite("scale", c.effective_scale_target(id).channels().to_vec());
let pivot = c.resolved_pivot(id);
let (px, py) = pivot.resolve(Rect::new(0.0, 0.0, 50.0, 50.0));
finite("pivot", vec![px, py]);
let lengths = c.read_box_lengths(
&mut crate::tree::LayoutCtx::detached(&mut Tree::new()),
id,
Constraints::new(0.0, 0.0, 200.0, 200.0),
);
finite("padding (laid out)", lengths.padding.channels().to_vec());
let numbers = |l: crate::layout::Length| {
[l.min(), l.max(), l.extent().declared_size()]
.into_iter()
.flatten()
.collect::<Vec<f32>>()
};
finite("width", numbers(lengths.width));
finite("height", numbers(lengths.height));
finite("shadow extent", vec![c.max_shadow_extent(id)]);
}
let hovered = container()
.width(50.0)
.height(50.0)
.background(Color::RED)
.when_hovered(|s| s.background(Color::rgba(nan, 0.0, 0.0, 1.0)));
let mut h = H::new(hovered);
h.fit(200.0, 200.0);
set_hover(&mut h, true);
assert_eq!(
rects(&h.paint()).first().map(|(_, c)| *c),
Some(Color::RED),
"a hover layer nobody can compute replaced the background anyway"
);
}
fn painted_style(
node: &RenderNode,
) -> (
Color,
CornerRadii,
Option<crate::renderer::Border>,
Option<Shadow>,
) {
node.commands
.iter()
.find_map(|c| match &**c {
DrawCommand::RoundedRect {
color,
radius,
border,
shadow,
..
} => Some((*color, *radius, *border, *shadow)),
_ => None,
})
.expect("the container draws a rounded rect")
}
fn turned_degrees(node: &RenderNode) -> f32 {
let m = node.local_transform.data;
m[3].atan2(m[0]).to_degrees()
}
fn jobs_anywhere(h: &mut H, write: impl FnOnce()) -> Vec<JobType> {
h.drain_jobs();
write();
let roots = h.roots();
jobs::distribute_jobs(&h.tree, &roots);
let drained = jobs::drain_surface_jobs(h.root);
let mut types: Vec<JobType> = drained.iter().map(|job| job.job_type).collect();
types.sort_by_key(|t| format!("{t:?}"));
types.dedup();
jobs::recycle_job_buffer(drained);
types
}
fn run_to(
h: &mut H,
start: std::time::Instant,
span: std::time::Duration,
probe: impl Fn(&mut H) -> f32,
) -> f32 {
let step = std::time::Duration::from_millis(10);
let mut at = start;
while at < start + span {
at += step;
frame_at(h, at.min(start + span), 400.0, 400.0);
}
probe(h)
}
fn probe_of(h: &mut H, id: WidgetId, probe: impl Fn(&mut H) -> f32) -> f32 {
let root = std::mem::replace(&mut h.root, id);
let read = probe(h);
h.root = root;
read
}
macro_rules! woken_by {
(none, $queued:expr) => {
!$queued.is_empty()
};
($target:ident, $queued:expr) => {
$queued.contains(&JobType::Animation)
};
}
macro_rules! emit_timeline_test {
(no, $name:ident, $value:expr, $declared_as:expr, $probe:expr, $from:expr, $to:expr,
$base:expr) => {};
(yes, $name:ident, $value:expr, $declared_as:expr, $probe:expr, $from:expr,
$to:expr, $base:expr) => {
#[test]
fn its_timeline_plays_when_the_trigger_moves() {
let value = $value;
let probe = $probe;
let plays = create_signal(0u32);
let mut h = H::new(($declared_as)(
value($base).timeline(
Keyframes::new(100.0)
.at(0.0, value($from))
.at(1.0, value($to))
.played_by(plays),
),
));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let at_rest = probe(&mut h);
assert!(
(at_rest - $base).abs() < 0.01,
"{}: a sequence nobody asked for has not played, so the property \
sits at the value it was declared with: got {at_rest}, wanted {}",
stringify!($name),
$base,
);
plays.set(1);
let t1 = t0 + std::time::Duration::from_millis(100);
frame_at(&mut h, t1, 400.0, 400.0);
let halfway = run_to(&mut h, t1, HALF, probe);
let midpoint = ($from + $to) / 2.0;
assert!(
(halfway - midpoint).abs() < 0.01,
"{}: half a linear sequence is half the distance: got {halfway}, \
wanted {midpoint}",
stringify!($name),
);
let queued = jobs_anywhere(&mut h, || plays.set(2));
assert!(
queued.contains(&JobType::Animation),
"{}: a play has to wake the container that would show it, got \
{queued:?}",
stringify!($name),
);
}
};
}
macro_rules! emit_property_tests {
($($name:ident: $anim:ty as $decl:ty, target: $target:tt, layout: $layout:ident,
timeline: $timeline:ident,
test {
value: $value:expr,
declared_as: $declared_as:expr,
probe: $probe:expr,
from: $from:expr, to: $to:expr, then: $then:expr, base: $base:expr,
};)*) => {
$(
mod $name {
use super::*;
const HALF: std::time::Duration = std::time::Duration::from_millis(50);
#[test]
fn it_enters_eases_and_follows_a_write_underneath_it() {
let value = $value;
let probe = $probe;
let declared = create_signal($to);
let mut h = H::new(($declared_as)((move || value(declared.get()))
.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(value($from))));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let opened = probe(&mut h);
assert!(
(opened - $from).abs() < 0.01,
"{}: an enter starts where it was declared to, not at its \
target: got {opened}, wanted {}",
stringify!($name),
$from
);
let halfway = run_to(&mut h, t0, HALF, probe);
let midpoint = ($from + $to) / 2.0;
assert!(
(halfway - midpoint).abs() < 0.01,
"{}: half of a linear hundred milliseconds is half the \
distance: got {halfway}, wanted {midpoint}",
stringify!($name),
);
run_to(&mut h, t0, HALF * 2, probe);
declared.set($then);
let t1 = t0 + HALF * 4;
frame_at(&mut h, t1, 400.0, 400.0);
let adopted = run_to(&mut h, t1, HALF, probe);
let midpoint = ($to + $then) / 2.0;
assert!(
(adopted - midpoint).abs() < 0.01,
"{}: a write underneath has to be adopted and eased to: \
got {adopted}, wanted {midpoint}",
stringify!($name),
);
let queued = jobs_anywhere(&mut h, || declared.set($from));
assert!(
woken_by!($target, queued),
"{}: a write underneath has to wake the container that \
would show it, got {queued:?}",
stringify!($name),
);
}
#[test]
fn a_constant_claims_no_slot_where_a_closure_claims_one() {
use crate::reactive::storage::slot_count;
let value = $value;
drop(($declared_as)(value($from)));
let mark = slot_count();
drop(($declared_as)(value($from)));
let constant = slot_count() - mark;
let mark = slot_count();
drop(($declared_as)(move || value($from)));
let reactive = slot_count() - mark;
assert_eq!(
constant,
reactive - 1,
"{}: a constant declaration must claim no slot, and a \
closure must claim one — got {constant} against \
{reactive}",
stringify!($name),
);
}
#[test]
fn it_leaves_to_where_its_exit_was_declared() {
let value = $value;
let probe = $probe;
let shown = create_signal(true);
let mut h = H::new(container().child(move || {
shown.get().then(|| {
($declared_as)(value($to)
.transition(Transition::new(100.0, TimingFunction::Linear))
.exiting_to(value($from)))
})
}));
let t0 = std::time::Instant::now();
frame_at(&mut h, t0, 400.0, 400.0);
let leaving = h.children()[0];
shown.set(false);
frame_at(&mut h, t0, 400.0, 400.0);
let halfway = run_to(&mut h, t0, HALF, |h| probe_of(h, leaving, probe));
let midpoint = ($to + $from) / 2.0;
assert!(
(halfway - midpoint).abs() < 0.01,
"{}: half of a linear hundred milliseconds is half the \
way out: got {halfway}, wanted {midpoint}",
stringify!($name),
);
run_to(&mut h, t0 + HALF, HALF, |_| 0.0);
assert!(
!h.tree.contains(leaving),
"{}: an exit that has landed is disposed",
stringify!($name),
);
}
emit_timeline_test!(
$timeline, $name, $value, $declared_as, $probe, $from, $to, $base
);
}
)*
};
}
crate::widgets::container::animated_properties::animated_properties!(emit_property_tests);
#[test]
fn a_container_of_constants_costs_what_an_empty_one_costs() {
use crate::reactive::storage::slot_count;
use crate::widgets::{Color, Corners, Padding};
let furnish = || {
container()
.width(200.0)
.height(100.0)
.padding(Padding::all(8.0))
.background(Color::RED)
.corners(Corners::rounded(4.0))
.border(2.0, Color::BLACK)
};
drop(furnish());
let mark = slot_count();
drop(container());
let bare = slot_count() - mark;
let mark = slot_count();
drop(furnish());
let furnished = slot_count() - mark;
assert_eq!(
furnished,
bare,
"seven constant properties claimed {} slots between them",
furnished - bare
);
}
#[test]
fn a_container_and_a_literal_text_claim_no_slot() {
use crate::reactive::storage::slot_count;
use crate::widgets::text;
drop(container());
drop(text("x"));
let mark = slot_count();
drop(container());
assert_eq!(slot_count() - mark, 0, "a bare container claimed a slot");
let mark = slot_count();
drop(text("x"));
assert_eq!(slot_count() - mark, 0, "a literal text claimed a slot");
}
#[test]
fn a_container_declaring_no_decoration_allocates_no_decoration() {
assert!(
size_of::<Container>() <= 392,
"72 bytes of gradient and shadow should be one 8-byte pointer: {} bytes",
size_of::<Container>()
);
assert!(
container().decoration.is_none(),
"a plain container has nothing to decorate and allocates nothing for it"
);
assert!(
container().background(Color::RED).decoration.is_none(),
"nor does one that is merely painted"
);
assert!(
container().shadow(LIFTED).decoration.is_some(),
"declaring a shadow is what allocates the box"
);
assert!(
container()
.gradient(LinearGradient::horizontal(Color::RED, Color::BLUE))
.decoration
.is_some(),
"and so is declaring a gradient"
);
}
#[test]
fn an_unbounded_axis_survives_a_length_that_declares_no_maximum() {
let mut h = H::new(
container()
.height(20.0)
.child(container().width(fill()).height(4.0)),
);
h.layout(Constraints::new(0.0, 0.0, f32::INFINITY, 100.0));
let bar = h.children()[0];
let width = h.tree.cached_size(bar).unwrap().width;
assert_eq!(
width, 0.0,
"a fill of an unbounded axis sizes to content; any other number means \
the infinity became a finite one on the way down"
);
let mut h = H::new(
container()
.height(20.0)
.child(container().width(fraction(0.5)).height(4.0)),
);
h.layout(Constraints::new(0.0, 0.0, f32::INFINITY, 100.0));
let bar = h.children()[0];
let width = h.tree.cached_size(bar).unwrap().width;
assert_eq!(
width, 0.0,
"a fraction of an unbounded axis sizes to content: there is no share \
to take of a space nobody has measured"
);
}
#[test]
fn an_entering_opacity_fades_the_whole_subtree() {
const FILL: Color = Color::rgb(0.9, 0.1, 0.1);
const INSIDE: Color = Color::rgb(0.1, 0.1, 0.9);
let t0 = std::time::Instant::now();
let mut h = H::new(
container()
.width(100.0)
.height(100.0)
.background(FILL)
.opacity(
0.0.transition(Transition::new(100.0, TimingFunction::Linear))
.entering_from(1.0),
)
.child(container().width(10.0).height(10.0).background(INSIDE)),
);
let drawn = |h: &mut H| -> f32 {
let node = h.paint();
let (mut commands, mut layers) = (Vec::new(), Vec::new());
let _ = crate::renderer::flatten_root_into(
&node,
&mut commands,
&mut layers,
&mut crate::renderer::FlattenScratch::default(),
);
let at = |fill: Color| {
commands
.iter()
.find(|c| matches!(&*c.command, DrawCommand::RoundedRect { color, .. } if *color == fill))
.expect("the box is drawn")
.opacity
};
assert_eq!(
at(FILL),
at(INSIDE),
"the box inside is drawn at the container's opacity"
);
at(INSIDE)
};
frame_at(&mut h, t0, 400.0, 400.0);
assert_eq!(
drawn(&mut h),
1.0,
"it enters from where it was declared to"
);
let halfway = run_to(&mut h, t0, std::time::Duration::from_millis(50), drawn);
assert!(
(halfway - 0.5).abs() < 0.01,
"half faded at half the time: {halfway}"
);
let done = run_to(&mut h, t0, std::time::Duration::from_millis(100), drawn);
assert_eq!(done, 0.0, "and gone at the end");
}
#[test]
fn a_widgets_drawn_bounds_are_where_paint_and_flatten_put_it() {
let mut h = H::new(
container().translate((15.0, 5.0)).padding(10.0).child(
container()
.width(200.0)
.height(150.0)
.scroll(Scroll::both().visibility(ScrollbarVisibility::Hidden))
.child(
container()
.layout(Flex::column())
.child(box_of(300.0, 100.0).background(Color::RED))
.child(
container()
.padding(6.0)
.rotate(30.0)
.pivot(Pivot::TOP_LEFT)
.child(box_of(40.0, 20.0).scale(1.5).background(Color::GREEN)),
)
.child(box_of(80.0, 400.0).background(Color::BLUE)),
),
),
);
h.frame(300.0, 300.0);
let first = h.tree.get_children(h.children()[0])[0];
let first = h.tree.get_children(first)[0];
let before = h.tree.get_surface_relative_bounds(first).expect("laid out");
h.send(Event::scroll(
60.0,
60.0,
30.0,
50.0,
crate::widgets::widget::ScrollSource::Wheel,
));
let frame = h.frame(300.0, 300.0);
let after = h.tree.get_surface_relative_bounds(first).expect("laid out");
assert_eq!(
(after.x, after.y),
(before.x - 30.0, before.y - 50.0),
"the wheel scrolled the content 30 sideways and 50 down"
);
fn worlds(node: &RenderNode, parent: Transform, out: &mut Vec<(u64, Rect)>) {
let world = parent.then(&node.local_transform.about(node.pivot, node.bounds));
out.push((node.id, world.map_rect(node.bounds)));
for child in &node.children {
worlds(child, world, out);
}
}
let mut drawn = Vec::new();
worlds(&frame.node, Transform::IDENTITY, &mut drawn);
let mut ids = vec![h.root];
let mut compared = 0;
while let Some(id) = ids.pop() {
ids.extend_from_slice(h.tree.get_children(id));
let Some(&(_, world)) = drawn.iter().find(|(node, _)| *node == id.as_u64()) else {
continue;
};
let reported = h.tree.get_surface_relative_bounds(id).expect("laid out");
let near = |a: f32, b: f32| (a - b).abs() < 1e-3;
assert!(
near(reported.x, world.x)
&& near(reported.y, world.y)
&& near(reported.width, world.width)
&& near(reported.height, world.height),
"widget {id:?}: the query says {reported:?}, paint and flatten put it at {world:?}"
);
compared += 1;
}
assert_eq!(
compared, 7,
"every widget of the scene was painted and compared"
);
let column = h.tree.get_children(h.children()[0])[0];
let rows = h.tree.get_children(column).to_vec();
let scaled = h.tree.get_children(rows[1])[0];
let commands = &h.last.as_ref().expect("a frame was flattened").1;
for (id, color) in [
(rows[0], Color::RED),
(scaled, Color::GREEN),
(rows[2], Color::BLUE),
] {
let flattened = commands
.iter()
.find_map(|flattened| match &*flattened.command {
DrawCommand::RoundedRect {
rect, color: fill, ..
} if *fill == color => Some(flattened.world_transform.map_rect(*rect)),
_ => None,
})
.expect("the box drew");
let reported = h.tree.get_surface_relative_bounds(id).expect("laid out");
let near = |a: f32, b: f32| (a - b).abs() < 1e-3;
assert!(
near(reported.x, flattened.x)
&& near(reported.y, flattened.y)
&& near(reported.width, flattened.width)
&& near(reported.height, flattened.height),
"{color:?}: the query says {reported:?}, flatten drew it at {flattened:?}"
);
}
}