use super::support::*;
use crate::tree::viewport;
use crate::viewport::{FitPolicy, PanBounds, ViewportBehavior, ViewportRequest, ViewportView};
const R: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
const ORIGIN: (f32, f32) = (0.0, 0.0);
fn approx(a: f32, b: f32) {
assert!((a - b).abs() < 0.05, "{a} != {b}");
}
fn vp_tree(w: f32, h: f32) -> El {
viewport([button("x")
.key("box")
.width(Size::Fixed(w))
.height(Size::Fixed(h))])
.key("vp")
}
fn vp_id(tree: &El) -> String {
find_id(tree, "vp").expect("viewport id")
}
#[test]
fn transform_bakes_into_descendant_rects() {
let mut tree = vp_tree(100.0, 80.0);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R); let content = find_rect(&tree, "box").expect("box rect");
let view = ViewportView {
pan: (40.0, 20.0),
zoom: 2.0,
};
s.set_viewport_view(vp_id(&tree), view);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box rect");
let (ex, ey) = view.project((content.x, content.y), ORIGIN);
approx(after.x, ex);
approx(after.y, ey);
approx(after.w, content.w * 2.0);
approx(after.h, content.h * 2.0);
}
#[test]
fn identity_view_leaves_rects_untouched() {
let mut a = vp_tree(120.0, 90.0);
let mut sa = UiState::new();
assign_ids(&mut a);
layout(&mut a, &mut sa, R);
let ra = find_rect(&a, "box").expect("box");
let mut b = vp_tree(120.0, 90.0);
let mut sb = UiState::new();
assign_ids(&mut b);
layout(&mut b, &mut sb, R);
let rb = find_rect(&b, "box").expect("box");
approx(ra.x, rb.x);
approx(ra.w, rb.w);
}
#[test]
fn hit_test_follows_the_transform() {
let mut tree = vp_tree(100.0, 80.0);
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (60.0, 30.0),
zoom: 1.5,
},
);
layout(&mut tree, &mut s, R);
let box_rect = find_rect(&tree, "box").expect("box");
let hit = hit_test(&tree, &s, (box_rect.center_x(), box_rect.center_y()));
assert_eq!(hit.as_deref(), Some("box"));
}
#[test]
fn fit_content_frames_and_centers() {
let mut tree = vp_tree(200.0, 100.0);
let mut s = UiState::new();
assign_ids(&mut tree);
s.push_viewport_requests(vec![ViewportRequest::FitContent {
key: "vp".into(),
padding: 20.0,
behavior: ViewportBehavior::Instant,
}]);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
approx(after.w, 200.0 * 1.8);
approx(after.h, 100.0 * 1.8);
approx(after.center_x(), R.center_x());
approx(after.center_y(), R.center_y());
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
}
#[test]
fn reset_view_request_restores_identity() {
let mut tree = vp_tree(100.0, 80.0);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let content = find_rect(&tree, "box").expect("box");
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (90.0, 70.0),
zoom: 3.0,
},
);
s.push_viewport_requests(vec![ViewportRequest::ResetView {
key: "vp".into(),
behavior: ViewportBehavior::Instant,
}]);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
approx(after.x, content.x);
approx(after.w, content.w);
let v = s.viewport_view(&vp_id(&tree));
approx(v.zoom, 1.0);
approx(v.pan.0, 0.0);
}
#[test]
fn pan_is_clamped_so_oversized_content_cannot_leave_a_gutter() {
let mut tree = vp_tree(800.0, 600.0);
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (1000.0, 1000.0),
zoom: 1.0,
},
);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
assert!(after.x <= 0.05, "left edge not clamped: {}", after.x);
assert!(
after.right() >= R.right() - 0.05,
"right edge not clamped: {}",
after.right()
);
assert!(after.y <= 0.05, "top edge not clamped: {}", after.y);
assert!(after.bottom() >= R.bottom() - 0.05);
}
#[test]
fn center_bounds_let_any_node_reach_mid_frame() {
let mut tree = vp_tree(800.0, 600.0).pan_bounds(PanBounds::Center);
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (5000.0, 5000.0),
zoom: 1.0,
},
);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
approx(after.x, R.center_x());
approx(after.y, R.center_y());
}
#[test]
fn free_bounds_leave_pan_unclamped() {
let mut tree = vp_tree(800.0, 600.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (5000.0, 5000.0),
zoom: 1.0,
},
);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
approx(after.x, 5000.0);
approx(after.y, 5000.0);
}
#[test]
fn smaller_content_is_kept_inside_the_viewport() {
let mut tree = vp_tree(100.0, 80.0);
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (-9000.0, -9000.0),
zoom: 1.0,
},
);
layout(&mut tree, &mut s, R);
let after = find_rect(&tree, "box").expect("box");
assert!(after.x >= -0.05, "left: {}", after.x);
assert!(
after.right() <= R.right() + 0.05,
"right: {}",
after.right()
);
}
#[test]
fn wheel_zoom_is_cursor_anchored() {
let mut tree = vp_tree(200.0, 150.0);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
let cursor = (300.0, 220.0);
let before = s.viewport_view(&id).unproject(cursor, ORIGIN);
assert!(s.viewport_wheel_zoom(&tree, cursor.0, cursor.1, -1.0));
let v = s.viewport_view(&id);
assert!(v.zoom > 1.0, "zoom should increase, was {}", v.zoom);
let after = v.unproject(cursor, ORIGIN);
approx(before.0, after.0);
approx(before.1, after.1);
}
#[test]
fn paint_scales_descendant_font_size_and_line_height_with_zoom() {
use crate::ir::DrawOp;
use crate::paint::draw_ops::draw_ops;
let mut tree = viewport([crate::text("zoom me")
.font_size(20.0)
.line_height(28.0)
.key("label")])
.key("vp");
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (0.0, 0.0),
zoom: 0.5,
},
);
layout(&mut tree, &mut s, R);
let label_id = find_id(&tree, "label").expect("label id");
let glyph = draw_ops(&tree, &s).into_iter().find_map(|op| match op {
DrawOp::GlyphRun {
id,
size,
line_height,
..
} if *id == label_id => Some((size, line_height)),
_ => None,
});
let (size, line_height) = glyph.expect("glyph run for label");
approx(size, 10.0);
approx(line_height, 14.0);
}
#[test]
fn paint_scales_attributed_paragraph_with_zoom() {
use crate::ir::DrawOp;
use crate::paint::draw_ops::draw_ops;
use crate::tree::text_runs;
let mut tree = viewport([text_runs([
crate::text("hello ").font_size(20.0).line_height(28.0),
crate::text("world").font_size(20.0).line_height(28.0),
])
.key("para")])
.key("vp");
let mut s = UiState::new();
assign_ids(&mut tree);
s.set_viewport_view(
vp_id(&tree),
ViewportView {
pan: (0.0, 0.0),
zoom: 0.5,
},
);
layout(&mut tree, &mut s, R);
let para_id = find_id(&tree, "para").expect("para id");
let metrics = draw_ops(&tree, &s).into_iter().find_map(|op| match op {
DrawOp::AttributedText {
id,
size,
line_height,
..
} if *id == para_id => Some((size, line_height)),
_ => None,
});
let (size, line_height) = metrics.expect("attributed text for para");
approx(size, 10.0);
approx(line_height, 14.0);
}
fn hover_envelope(tree: &mut El, state: &mut UiState, key: &str) -> Option<f32> {
state.set_animation_mode(AnimationMode::Settled);
state.hovered = Some(target(tree, key));
state.apply_to_state();
state.tick_visual_animations(tree, Instant::now(), &Palette::default());
envelope_for(tree, state, key, EnvelopeKind::Hover)
}
#[test]
fn keyed_node_tracks_hover_envelope_by_default() {
let mut tree = column([button("x").key("normal")]);
let mut s = UiState::new();
layout(&mut tree, &mut s, R);
assert_eq!(hover_envelope(&mut tree, &mut s, "normal"), Some(1.0));
}
#[test]
fn no_hover_suppresses_the_hover_envelope() {
let mut tree = column([button("x").key("quiet").no_hover()]);
let mut s = UiState::new();
layout(&mut tree, &mut s, R);
assert_eq!(hover_envelope(&mut tree, &mut s, "quiet"), Some(0.0));
}
#[test]
fn viewport_does_not_track_hover_envelope() {
let mut tree = viewport([button("x").key("box")]).key("vp");
let mut s = UiState::new();
layout(&mut tree, &mut s, R);
assert_eq!(hover_envelope(&mut tree, &mut s, "vp"), Some(0.0));
}
#[test]
fn wheel_zoom_clamps_to_max() {
let mut tree = vp_tree(100.0, 100.0).max_zoom(2.0);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
for _ in 0..50 {
s.viewport_wheel_zoom(&tree, 200.0, 150.0, -1.0);
}
approx(s.viewport_view(&id).zoom, 2.0);
}
#[test]
fn wheel_over_modal_overlay_does_not_zoom_the_viewport_underneath() {
let mut tree = crate::overlays(
vp_tree(800.0, 600.0),
[Some(crate::modal(
"detail",
"Detail",
[crate::scroll([button("body")
.key("modal_body")
.width(Size::Fixed(300.0))
.height(Size::Fixed(200.0))])],
))],
);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let body = find_rect(&tree, "modal_body").expect("modal body rect");
let over_panel = (body.x + body.w * 0.5, body.y + body.h * 0.5);
assert!(
!s.viewport_wheel_zoom(&tree, over_panel.0, over_panel.1, -1.0),
"wheel over the modal panel must yield to scroll routing, not zoom the canvas"
);
assert!(
!s.viewport_view(&vp_id(&tree)).pan.0.is_nan(),
"viewport view exists"
);
let corner = (4.0, 4.0);
assert!(
s.viewport_wheel_zoom(&tree, corner.0, corner.1, -1.0),
"wheel on the bare canvas still zooms"
);
}
#[test]
fn wheel_over_viewport_inside_a_modal_still_zooms() {
let mut tree = crate::overlays(
button("bg").key("bg"),
[Some(crate::modal(
"canvas",
"Canvas",
[vp_tree(800.0, 600.0)],
))],
);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let vp_rect = find_rect(&tree, "vp").expect("viewport rect");
let center = (vp_rect.x + vp_rect.w * 0.5, vp_rect.y + vp_rect.h * 0.5);
assert!(
s.viewport_wheel_zoom(&tree, center.0, center.1, -1.0),
"a viewport inside the modal is not occluded by the modal — it zooms"
);
}
#[test]
fn unmounted_viewport_stops_claiming_gestures() {
let mut tree = vp_tree(100.0, 80.0);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
assert!(
s.viewport_at(200.0, 150.0).is_some(),
"mounted: claims point"
);
let mut plain = button("solo").key("solo");
assign_ids(&mut plain);
layout(&mut plain, &mut s, R);
assert!(
s.viewport_at(200.0, 150.0).is_none(),
"unmounted viewport must not keep claiming gestures"
);
assert!(
!s.viewport_wheel_zoom(&plain, 200.0, 150.0, -1.0),
"wheel over the old rect falls through to scroll routing"
);
s.set_viewport_view(
id.clone(),
ViewportView {
pan: (7.0, 3.0),
zoom: 2.0,
},
);
let mut remounted = vp_tree(100.0, 80.0);
assign_ids(&mut remounted);
layout(&mut remounted, &mut s, R);
approx(s.viewport_view(&id).zoom, 2.0);
}
fn hug_column(count: usize) -> El {
column(
(0..count)
.map(|_| {
El::new(Kind::Group)
.width(Size::Fixed(100.0))
.height(Size::Fixed(50.0))
})
.collect::<Vec<_>>(),
)
.gap(0.0)
.padding(0.0)
.key("c")
}
#[test]
fn viewport_hug_content_is_not_clamped_to_the_frame() {
let mut tree = viewport([hug_column(8)]).key("vp");
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let c = find_rect(&tree, "c").expect("content rect");
approx(c.h, 400.0); assert!(
c.h > R.h,
"content extends past the frame so it can be panned: {} !> {}",
c.h,
R.h
);
}
#[test]
fn overlay_hug_content_still_clamps_to_the_frame() {
let mut tree = crate::overlay([hug_column(8)]);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let c = find_rect(&tree, "c").expect("content rect");
approx(c.h, R.h); }
fn vp_tree_fit(w: f32, h: f32, policy: FitPolicy) -> El {
viewport([button("x")
.key("box")
.width(Size::Fixed(w))
.height(Size::Fixed(h))])
.key("vp")
.fit_policy(policy)
}
#[test]
fn contain_fits_on_mount_and_refits_on_resize() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
let r = find_rect(&tree, "box").expect("box");
approx(r.center_x(), R.center_x());
approx(r.center_y(), R.center_y());
let big = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, big);
approx(s.viewport_view(&vp_id(&tree)).zoom, 3.8);
let r = find_rect(&tree, "box").expect("box");
approx(r.center_x(), big.center_x());
}
#[test]
fn contain_tracks_content_growth() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
let mut tree = vp_tree_fit(400.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 0.9);
}
#[test]
fn contain_releases_on_user_zoom() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
assert!(s.viewport_wheel_zoom(&tree, 200.0, 150.0, -1.0));
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
let big = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, big);
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8 * 1.1);
}
#[test]
fn contain_releases_on_pan_drag() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
s.begin_viewport_pan(id.clone(), PointerButton::Primary, 200.0, 150.0);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
assert!(s.drag_viewport_to(215.0, 150.0));
s.end_viewport_pan(PointerButton::Primary);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
let fitted = s.viewport_view(&id);
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&id).zoom, fitted.zoom);
assert!((s.viewport_view(&id).pan.0 - fitted.pan.0).abs() < 0.05);
}
#[test]
fn reset_request_rearms_contain() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
assert!(s.viewport_wheel_zoom(&tree, 200.0, 150.0, -1.0));
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
s.push_viewport_requests(vec![ViewportRequest::ResetView {
key: "vp".into(),
behavior: ViewportBehavior::Instant,
}]);
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
}
#[test]
fn center_on_takes_over_contain() {
let mut s = UiState::new();
let mut tree =
vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 }).pan_bounds(PanBounds::Free);
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
s.push_viewport_requests(vec![ViewportRequest::CenterOn {
key: "vp".into(),
point: (0.0, 0.0),
behavior: ViewportBehavior::Instant,
}]);
let mut tree =
vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 }).pan_bounds(PanBounds::Free);
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
let view = s.viewport_view(&vp_id(&tree));
let (sx, sy) = view.project((0.0, 0.0), ORIGIN);
approx(sx, R.center_x());
approx(sy, R.center_y());
}
#[test]
fn lock_always_fits_and_suppresses_gestures() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Lock { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
assert!(!s.viewport_wheel_zoom(&tree, 200.0, 150.0, -1.0));
assert!(s.viewport_at(200.0, 150.0).is_none());
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
s.set_viewport_view(vp_id(&tree), ViewportView::default());
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Lock { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
approx(s.viewport_view(&vp_id(&tree)).zoom, 1.8);
}
#[test]
fn seeded_view_is_not_stomped_by_contain() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
let seeded = ViewportView {
pan: (10.0, 5.0),
zoom: 2.5,
};
s.set_viewport_view(vp_id(&tree), seeded);
layout(&mut tree, &mut s, R);
approx(s.viewport_view(&vp_id(&tree)).zoom, 2.5);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
}
#[test]
fn content_bounds_by_key_reads_content_space() {
let mut s = UiState::new();
let mut tree = vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 });
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let b = s.viewport_content_bounds_by_key("vp").expect("bounds");
approx(b.w, 200.0);
approx(b.h, 100.0);
assert!(s.viewport_content_bounds_by_key("nope").is_none());
}
#[test]
fn frame_rect_frames_the_region() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R); let content = find_rect(&tree, "box").expect("box");
let half = Rect::new(
content.x + content.w * 0.5,
content.y,
content.w * 0.5,
content.h,
);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: half,
padding: 20.0,
behavior: ViewportBehavior::Instant,
}]);
layout(&mut tree, &mut s, R);
let v = s.viewport_view(&vp_id(&tree));
approx(v.zoom, 2.6);
let c = v.project((half.center_x(), half.center_y()), ORIGIN);
approx(c.0, R.center_x());
approx(c.1, R.center_y());
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
}
#[test]
fn frame_rect_degenerate_centers_at_current_zoom() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: Rect::new(30.0, 40.0, 0.0, 0.0),
padding: 20.0,
behavior: ViewportBehavior::Instant,
}]);
layout(&mut tree, &mut s, R);
let v = s.viewport_view(&vp_id(&tree));
approx(v.zoom, 1.0); let c = v.project((30.0, 40.0), ORIGIN);
approx(c.0, R.center_x());
approx(c.1, R.center_y());
}
#[test]
fn smooth_frame_rect_flies_and_lands() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
let start = s.viewport_view(&id);
let target_rect = Rect::new(150.0, 25.0, 50.0, 50.0);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: target_rect,
padding: 20.0,
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id), "flight begins at consumption");
assert_eq!(s.viewport_in_flight_by_key("vp"), Some(true));
assert_eq!(s.viewport_view(&id), start);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
s.viewport.clock_override = Some(t0 + std::time::Duration::from_millis(100));
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id));
let mid = s.viewport_view(&id);
assert!(mid != start, "moved off the start framing");
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
layout(&mut tree, &mut s, R);
assert!(!s.viewport_in_flight(&id), "flight retired on arrival");
let v = s.viewport_view(&id);
assert!(mid != v, "mid-flight was between the endpoints");
approx(v.zoom, 5.0);
let c = v.project((target_rect.center_x(), target_rect.center_y()), ORIGIN);
approx(c.0, R.center_x());
approx(c.1, R.center_y());
}
#[test]
fn gesture_cancels_flight_where_it_is() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: Rect::new(150.0, 25.0, 50.0, 50.0),
padding: 20.0,
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
s.viewport.clock_override = Some(t0 + std::time::Duration::from_millis(100));
layout(&mut tree, &mut s, R);
let mid = s.viewport_view(&id);
assert!(s.viewport_in_flight(&id));
s.begin_viewport_pan(id.clone(), PointerButton::Primary, 200.0, 150.0);
assert!(s.drag_viewport_to(210.0, 150.0));
assert!(!s.viewport_in_flight(&id), "gesture grounds the flight");
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
layout(&mut tree, &mut s, R);
let after = s.viewport_view(&id);
approx(after.pan.0, mid.pan.0 + 10.0);
approx(after.zoom, mid.zoom);
}
#[test]
fn smooth_reset_rearms_contain_on_arrival() {
let mut tree =
vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 }).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
let home = s.viewport_view(&id); approx(home.zoom, 1.8);
s.set_viewport_view(
id.clone(),
ViewportView {
pan: (90.0, 70.0),
zoom: 3.0,
},
);
layout(&mut tree, &mut s, R);
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
s.push_viewport_requests(vec![ViewportRequest::ResetView {
key: "vp".into(),
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id));
s.viewport.clock_override = Some(t0 + std::time::Duration::from_millis(100));
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id));
assert_eq!(s.viewport_at_home_by_key("vp"), Some(false));
let mid = s.viewport_view(&id);
assert!(mid != home, "not yet arrived");
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
layout(&mut tree, &mut s, R);
assert!(!s.viewport_in_flight(&id));
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
let v = s.viewport_view(&id);
approx(v.zoom, home.zoom);
approx(v.pan.0, home.pan.0);
approx(v.pan.1, home.pan.1);
layout(&mut tree, &mut s, R);
assert_eq!(s.viewport_view(&id), v);
}
#[test]
fn settled_mode_snaps_smooth_requests() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
s.set_animation_mode(crate::state::AnimationMode::Settled);
assign_ids(&mut tree);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
let half = Rect::new(100.0, 0.0, 100.0, 100.0);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: half,
padding: 20.0,
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
assert!(!s.viewport_in_flight(&id), "settled mode never flies");
let v = s.viewport_view(&id);
approx(v.zoom, 2.6);
let c = v.project((half.center_x(), half.center_y()), ORIGIN);
approx(c.0, R.center_x());
approx(c.1, R.center_y());
}
#[test]
fn expired_unsampled_flight_does_not_pin_redraw() {
let mut tree = vp_tree(200.0, 100.0).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
s.push_viewport_requests(vec![ViewportRequest::FrameRect {
key: "vp".into(),
rect: Rect::new(150.0, 25.0, 50.0, 50.0),
padding: 20.0,
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id));
s.viewport.clock_override = Some(t0 + std::time::Duration::from_millis(100));
assert!(s.viewport.any_flight_animating(t0));
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
assert!(!s.viewport.any_flight_animating(t0));
assert!(s.viewport_in_flight(&id), "entry survives until sampled");
layout(&mut tree, &mut s, R);
assert!(!s.viewport_in_flight(&id));
approx(s.viewport_view(&id).zoom, 5.0);
}
#[test]
fn clamped_flight_keeps_center_on_path() {
let mut tree = vp_tree(200.0, 100.0)
.pan_bounds(PanBounds::Free)
.min_zoom(0.95);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
s.push_viewport_requests(vec![ViewportRequest::CenterOn {
key: "vp".into(),
point: (2_000.0, 150.0),
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
let start_cx = 200.0; let mut last_cx = start_cx;
for step in 1..=9 {
s.viewport.clock_override = Some(t0 + std::time::Duration::from_millis(step * 80));
layout(&mut tree, &mut s, R);
let v = s.viewport_view(&id);
assert!(v.zoom >= 0.94, "zoom respects min_zoom: {}", v.zoom);
let c = v.unproject((R.center_x(), R.center_y()), ORIGIN);
assert!(
(c.1 - 150.0).abs() < 1.0,
"step {step}: cy off-path: {}",
c.1
);
assert!(
c.0 >= last_cx - 0.5 && c.0 <= 2_000.5,
"step {step}: cx {} left the segment [{last_cx}, 2000]",
c.0
);
last_cx = c.0;
}
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
layout(&mut tree, &mut s, R);
let v = s.viewport_view(&id);
approx(v.zoom, 1.0);
let c = v.unproject((R.center_x(), R.center_y()), ORIGIN);
approx(c.0, 2_000.0);
approx(c.1, 150.0);
}
#[test]
fn arrival_lands_on_live_policy_fit_after_content_change() {
let mut tree =
vp_tree_fit(200.0, 100.0, FitPolicy::Contain { padding: 20.0 }).pan_bounds(PanBounds::Free);
let mut s = UiState::new();
assign_ids(&mut tree);
let t0 = web_time::Instant::now();
s.viewport.clock_override = Some(t0);
layout(&mut tree, &mut s, R);
let id = vp_id(&tree);
s.set_viewport_view(
id.clone(),
ViewportView {
pan: (90.0, 70.0),
zoom: 3.0,
},
);
s.push_viewport_requests(vec![ViewportRequest::ResetView {
key: "vp".into(),
behavior: ViewportBehavior::Smooth,
}]);
layout(&mut tree, &mut s, R);
assert!(s.viewport_in_flight(&id));
let mut tree2 =
vp_tree_fit(300.0, 150.0, FitPolicy::Contain { padding: 20.0 }).pan_bounds(PanBounds::Free);
assign_ids(&mut tree2);
s.viewport.clock_override = Some(t0 + std::time::Duration::from_secs(2));
layout(&mut tree2, &mut s, R);
assert!(!s.viewport_in_flight(&id));
assert_eq!(s.viewport_at_home_by_key("vp"), Some(true));
let v = s.viewport_view(&id);
approx(v.zoom, 1.2);
layout(&mut tree2, &mut s, R);
assert_eq!(s.viewport_view(&id), v);
}