#![warn(missing_docs)]
use crate::event::{KeyModifiers, PointerButton};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PanTrigger {
pub button: PointerButton,
pub modifiers: KeyModifiers,
}
impl Default for PanTrigger {
fn default() -> Self {
Self {
button: PointerButton::Primary,
modifiers: KeyModifiers::default(),
}
}
}
impl PanTrigger {
pub fn matches(self, button: PointerButton, modifiers: KeyModifiers) -> bool {
self.button == button && self.modifiers == modifiers
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PanBounds {
#[default]
Contain,
Center,
Free,
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum FitPolicy {
#[default]
Manual,
Contain {
padding: f32,
},
Lock {
padding: f32,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewportConfig {
pub min_zoom: f32,
pub max_zoom: f32,
pub pan_trigger: PanTrigger,
pub pan_bounds: PanBounds,
pub fit: FitPolicy,
}
impl Default for ViewportConfig {
fn default() -> Self {
Self {
min_zoom: 0.2,
max_zoom: 5.0,
pan_trigger: PanTrigger::default(),
pan_bounds: PanBounds::default(),
fit: FitPolicy::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewportView {
pub pan: (f32, f32),
pub zoom: f32,
}
impl Default for ViewportView {
fn default() -> Self {
Self {
pan: (0.0, 0.0),
zoom: 1.0,
}
}
}
impl ViewportView {
pub fn project(self, p: (f32, f32), origin: (f32, f32)) -> (f32, f32) {
(
origin.0 + self.pan.0 + self.zoom * (p.0 - origin.0),
origin.1 + self.pan.1 + self.zoom * (p.1 - origin.1),
)
}
pub fn unproject(self, s: (f32, f32), origin: (f32, f32)) -> (f32, f32) {
(
origin.0 + (s.0 - origin.0 - self.pan.0) / self.zoom,
origin.1 + (s.1 - origin.1 - self.pan.1) / self.zoom,
)
}
pub fn zoom_about(self, new_zoom: f32, anchor: (f32, f32), origin: (f32, f32)) -> Self {
let r = new_zoom / self.zoom;
let dx = anchor.0 - origin.0;
let dy = anchor.1 - origin.1;
Self {
pan: (
dx * (1.0 - r) + self.pan.0 * r,
dy * (1.0 - r) + self.pan.1 * r,
),
zoom: new_zoom,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ViewportBehavior {
#[default]
Instant,
Smooth,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ViewportRequest {
FitContent {
key: String,
padding: f32,
behavior: ViewportBehavior,
},
ResetView {
key: String,
behavior: ViewportBehavior,
},
CenterOn {
key: String,
point: (f32, f32),
behavior: ViewportBehavior,
},
FrameRect {
key: String,
rect: crate::tree::Rect,
padding: f32,
behavior: ViewportBehavior,
},
}
impl ViewportRequest {
pub fn key(&self) -> &str {
match self {
ViewportRequest::FitContent { key, .. }
| ViewportRequest::ResetView { key, .. }
| ViewportRequest::CenterOn { key, .. }
| ViewportRequest::FrameRect { key, .. } => key,
}
}
pub fn behavior(&self) -> ViewportBehavior {
match self {
ViewportRequest::FitContent { behavior, .. }
| ViewportRequest::ResetView { behavior, .. }
| ViewportRequest::CenterOn { behavior, .. }
| ViewportRequest::FrameRect { behavior, .. } => *behavior,
}
}
}
const ZOOM_PATH_RHO: f64 = std::f64::consts::SQRT_2;
const ZOOM_PATH_MIN_TRANSLATION: f64 = 1e-3;
#[derive(Clone, Copy, Debug)]
pub struct ZoomPath {
start: (f32, f32, f32),
end: (f32, f32, f32),
kind: PathKind,
length: f32,
}
#[derive(Clone, Copy, Debug)]
enum PathKind {
Blend,
Arc {
d1: f64,
r0: f64,
cosh_r0: f64,
sinh_r0: f64,
},
}
impl ZoomPath {
pub fn new(start: (f32, f32, f32), end: (f32, f32, f32)) -> Self {
let w0 = f64::from(start.2).max(1e-6);
let w1 = f64::from(end.2).max(1e-6);
let dx = f64::from(end.0) - f64::from(start.0);
let dy = f64::from(end.1) - f64::from(start.1);
let d2 = dx * dx + dy * dy;
let d1 = d2.sqrt();
let rho = ZOOM_PATH_RHO;
let blend = |start, end| Self {
start,
end,
kind: PathKind::Blend,
length: ((w1 / w0).ln().abs() / rho) as f32,
};
if d1 < ZOOM_PATH_MIN_TRANSLATION.max(1e-4 * w0.max(w1)) {
return blend(start, end);
}
let rho2 = rho * rho;
let b0 = (w1 * w1 - w0 * w0 + rho2 * rho2 * d2) / (2.0 * w0 * rho2 * d1);
let b1 = (w1 * w1 - w0 * w0 - rho2 * rho2 * d2) / (2.0 * w1 * rho2 * d1);
let r0 = -b0.asinh();
let r1 = -b1.asinh();
let length = ((r1 - r0) / rho) as f32;
if !(length.is_finite() && length > 0.0) {
return blend(start, end);
}
Self {
start,
end,
kind: PathKind::Arc {
d1,
r0,
cosh_r0: r0.cosh(),
sinh_r0: r0.sinh(),
},
length,
}
}
pub fn length(&self) -> f32 {
self.length
}
pub fn sample(&self, t: f32) -> (f32, f32, f32) {
if t <= 0.0 || self.length == 0.0 {
return self.start;
}
if t >= 1.0 {
return self.end;
}
let (cx0, cy0, w0) = (
f64::from(self.start.0),
f64::from(self.start.1),
f64::from(self.start.2).max(1e-6),
);
let (cx1, cy1, w1) = (
f64::from(self.end.0),
f64::from(self.end.1),
f64::from(self.end.2).max(1e-6),
);
let t = f64::from(t);
match self.kind {
PathKind::Blend => {
let w = w0 * (w1 / w0).powf(t);
(
(cx0 + t * (cx1 - cx0)) as f32,
(cy0 + t * (cy1 - cy0)) as f32,
w as f32,
)
}
PathKind::Arc {
d1,
r0,
cosh_r0,
sinh_r0,
} => {
let rho = ZOOM_PATH_RHO;
let s = t * f64::from(self.length);
let r = rho * s + r0;
let u = w0 / (rho * rho * d1) * (cosh_r0 * r.tanh() - sinh_r0);
let w = w0 * cosh_r0 / r.cosh();
(
(cx0 + u * (cx1 - cx0)) as f32,
(cy0 + u * (cy1 - cy0)) as f32,
w as f32,
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: (f32, f32), b: (f32, f32)) {
assert!(
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
"{a:?} != {b:?}"
);
}
#[test]
fn project_unproject_roundtrip() {
let origin = (100.0, 50.0);
let v = ViewportView {
pan: (30.0, -20.0),
zoom: 2.5,
};
let p = (140.0, 75.0);
approx(v.unproject(v.project(p, origin), origin), p);
}
#[test]
fn identity_is_a_noop() {
let origin = (10.0, 10.0);
let v = ViewportView::default();
approx(v.project((200.0, 300.0), origin), (200.0, 300.0));
}
#[test]
fn zoom_about_keeps_cursor_point_fixed() {
let origin = (0.0, 0.0);
let v = ViewportView {
pan: (15.0, 40.0),
zoom: 1.0,
};
let cursor = (250.0, 120.0);
let before = v.unproject(cursor, origin);
let zoomed = v.zoom_about(3.0, cursor, origin);
let after = zoomed.unproject(cursor, origin);
approx(before, after);
approx(zoomed.project(before, origin), cursor);
}
#[test]
fn pan_trigger_matches_exactly() {
let t = PanTrigger::default();
assert!(t.matches(PointerButton::Primary, KeyModifiers::default()));
assert!(!t.matches(PointerButton::Middle, KeyModifiers::default()));
let shift = KeyModifiers {
shift: true,
..Default::default()
};
assert!(!t.matches(PointerButton::Primary, shift));
}
#[test]
fn zoom_path_endpoints_are_bit_exact() {
let a = (100.0, 200.0, 800.0);
let b = (5000.0, -300.0, 120.0);
let p = ZoomPath::new(a, b);
assert_eq!(p.sample(0.0), a);
assert_eq!(p.sample(1.0), b);
assert_eq!(p.sample(-0.5), a);
assert_eq!(p.sample(2.0), b);
assert!(p.length() > 0.0);
}
#[test]
fn zoom_path_arcs_out_for_long_translations() {
let p = ZoomPath::new((0.0, 0.0, 800.0), (10_000.0, 0.0, 800.0));
let (_, _, w_mid) = p.sample(0.5);
assert!(w_mid > 800.0 * 1.5, "mid-flight width: {w_mid}");
let (cx_mid, _, _) = p.sample(0.5);
assert!((cx_mid - 5_000.0).abs() < 1.0, "mid center: {cx_mid}");
}
#[test]
fn zoom_path_center_progress_is_monotone() {
let p = ZoomPath::new((0.0, 0.0, 400.0), (3_000.0, 1_500.0, 900.0));
let mut last = f32::NEG_INFINITY;
for i in 0..=20 {
let (cx, _, w) = p.sample(i as f32 / 20.0);
assert!(cx >= last - 1e-3, "center backtracked at step {i}: {cx}");
assert!(w > 0.0);
last = cx;
}
}
#[test]
fn zoom_path_pure_zoom_is_geometric() {
let p = ZoomPath::new((50.0, 50.0, 100.0), (50.0, 50.0, 1_600.0));
let (cx, cy, w) = p.sample(0.5);
assert!((cx - 50.0).abs() < 1e-3 && (cy - 50.0).abs() < 1e-3);
assert!((w - 400.0).abs() < 0.5, "geometric mid width: {w}");
assert!(p.length() > 0.0);
}
#[test]
fn zoom_path_noop_has_zero_length() {
let a = (10.0, 20.0, 300.0);
let p = ZoomPath::new(a, a);
assert_eq!(p.length(), 0.0);
assert_eq!(p.sample(0.5), a);
}
#[test]
fn zoom_path_near_pure_zoom_stays_finite() {
let p = ZoomPath::new((16_384.0, 8_000.0, 800.0), (16_384.002, 8_000.0, 30_000.0));
assert!(p.length().is_finite() && p.length() > 0.0);
for i in 0..=10 {
let (cx, cy, w) = p.sample(i as f32 / 10.0);
assert!(
cx.is_finite() && cy.is_finite() && w.is_finite() && w > 0.0,
"sample {i}: ({cx}, {cy}, {w})"
);
}
}
#[test]
fn zoom_path_extreme_width_ratio_stays_finite() {
let p = ZoomPath::new((0.0, 0.0, 800.0), (6_000.0, 0.0, 5.0e7));
assert!(p.length().is_finite() && p.length() > 0.0);
for i in 0..=10 {
let (cx, cy, w) = p.sample(i as f32 / 10.0);
assert!(
cx.is_finite() && cy.is_finite() && w.is_finite() && w > 0.0,
"sample {i}: ({cx}, {cy}, {w})"
);
}
}
#[test]
fn zoom_path_is_reversible() {
let a = (0.0, 0.0, 500.0);
let b = (2_000.0, 800.0, 250.0);
let fwd = ZoomPath::new(a, b);
let back = ZoomPath::new(b, a);
assert!((fwd.length() - back.length()).abs() < 1e-4);
for i in 1..10 {
let t = i as f32 / 10.0;
let (fx, fy, fw) = fwd.sample(t);
let (bx, by, bw) = back.sample(1.0 - t);
assert!((fx - bx).abs() < 0.1, "x at t={t}: {fx} vs {bx}");
assert!((fy - by).abs() < 0.1, "y at t={t}: {fy} vs {by}");
assert!((fw - bw).abs() < 0.1, "w at t={t}: {fw} vs {bw}");
}
}
}