use egui::{Pos2, Rect, Vec2, emath::TSTransform, vec2};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Dir4 {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Navigable {
pub scale: f32,
pub offset: [f32; 2],
pub min_scale: f32,
pub max_scale: f32,
}
impl Default for Navigable {
fn default() -> Self {
Self { scale: 1.0, offset: [0.0, 0.0], min_scale: 0.05, max_scale: 64.0 }
}
}
impl Navigable {
pub fn transform(&self) -> TSTransform {
TSTransform::new(Vec2::new(self.offset[0], self.offset[1]), self.scale)
}
pub fn pan(&mut self, delta: Vec2) {
self.offset[0] += delta.x;
self.offset[1] += delta.y;
}
pub fn zoom_to(&mut self, k: f32, screen_pivot: Pos2) {
let new_scale = (self.scale * k).clamp(self.min_scale, self.max_scale);
let actual_k = new_scale / self.scale;
self.offset[0] = screen_pivot.x - (screen_pivot.x - self.offset[0]) * actual_k;
self.offset[1] = screen_pivot.y - (screen_pivot.y - self.offset[1]) * actual_k;
self.scale = new_scale;
}
pub fn fit(&mut self, scene_bbox: Rect, viewport: Rect, margin: f32) {
if scene_bbox.width() <= 0.0 || scene_bbox.height() <= 0.0 {
return;
}
let m = margin.clamp(0.0, 0.45);
let avail = viewport.size() * (1.0 - 2.0 * m);
let sx = avail.x / scene_bbox.width();
let sy = avail.y / scene_bbox.height();
let s = sx.min(sy).clamp(self.min_scale, self.max_scale);
self.scale = s;
let bbox_center_scaled = scene_bbox.center().to_vec2() * s;
let vp_center = viewport.center().to_vec2();
self.offset = [vp_center.x - bbox_center_scaled.x, vp_center.y - bbox_center_scaled.y];
}
pub fn to_screen(&self, scene: Pos2) -> Pos2 {
self.transform().mul_pos(scene)
}
}
pub fn nearest_in_direction(current: Rect, candidates: &[Rect], dir: Dir4) -> Option<usize> {
let from = current.center();
let mut best: Option<(usize, f32)> = None;
for (i, &r) in candidates.iter().enumerate() {
let to = r.center();
let d = to - from;
let (along, across) = match dir {
Dir4::Left => (-d.x, d.y.abs()),
Dir4::Right => (d.x, d.y.abs()),
Dir4::Up => (-d.y, d.x.abs()),
Dir4::Down => (d.y, d.x.abs()),
};
if along <= 0.5 {
continue; }
let cost = along + across * 2.0;
if best.map(|(_, c)| cost < c).unwrap_or(true) {
best = Some((i, cost));
}
}
best.map(|(i, _)| i)
}
pub fn hint_anchors(rects: &[Rect]) -> Vec<Pos2> {
rects.iter().map(|r| r.left_top() + vec2(4.0, 4.0)).collect()
}
#[cfg(test)]
mod tests {
use egui::pos2;
use super::*;
#[test]
fn zoom_keeps_the_pivot_point_fixed() {
let mut nav = Navigable::default();
let pivot = pos2(200.0, 150.0);
let before = inverse(&nav, pivot);
nav.zoom_to(2.0, pivot);
let after = inverse(&nav, pivot);
assert!((before - after).length() < 1e-3, "scene point under cursor must stay put");
assert_eq!(nav.scale, 2.0);
}
fn inverse(nav: &Navigable, screen: Pos2) -> Pos2 {
pos2((screen.x - nav.offset[0]) / nav.scale, (screen.y - nav.offset[1]) / nav.scale)
}
#[test]
fn zoom_clamps_to_range() {
let mut nav = Navigable::default();
for _ in 0..100 {
nav.zoom_to(2.0, pos2(0.0, 0.0));
}
assert!(nav.scale <= nav.max_scale + 1e-3);
for _ in 0..100 {
nav.zoom_to(0.5, pos2(0.0, 0.0));
}
assert!(nav.scale >= nav.min_scale - 1e-3);
}
#[test]
fn fit_centres_and_scales_a_bbox() {
let mut nav = Navigable::default();
let bbox = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0));
let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 400.0));
nav.fit(bbox, vp, 0.1);
let c = nav.to_screen(bbox.center());
assert!((c - vp.center()).length() < 1.0, "bbox centre → viewport centre");
assert!((nav.scale - 3.2).abs() < 0.1, "fit scale, got {}", nav.scale);
}
#[test]
fn spatial_focus_picks_nearest_in_direction_not_tab_order() {
let current = Rect::from_center_size(pos2(100.0, 100.0), vec2(40.0, 20.0));
let right = Rect::from_center_size(pos2(180.0, 105.0), vec2(40.0, 20.0));
let up = Rect::from_center_size(pos2(100.0, 20.0), vec2(40.0, 20.0));
let cands = [up, right];
assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1));
assert_eq!(nearest_in_direction(current, &cands, Dir4::Up), Some(0));
assert_eq!(nearest_in_direction(current, &cands, Dir4::Left), None);
}
#[test]
fn spatial_focus_prefers_on_axis_over_diagonal() {
let current = Rect::from_center_size(pos2(0.0, 0.0), vec2(10.0, 10.0));
let straight = Rect::from_center_size(pos2(100.0, 0.0), vec2(10.0, 10.0));
let diagonal = Rect::from_center_size(pos2(90.0, 90.0), vec2(10.0, 10.0));
let cands = [diagonal, straight];
assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1), "straight beats diagonal");
}
}