use super::support::*;
use crate::tree::viewport;
use crate::viewport::{FitPolicy, PanBounds, 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,
}]);
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() }]);
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"
);
}
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(), 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();
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() }]);
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),
}]);
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());
}