facett-graphview/tests/nav_modes.rs
//! **📌 Stay / 🎯 Focus and the fly-to**, rendered: real egui frames, a real pointer
//! click on a node's painted chip, the UI clock stepping at 60 fps.
//!
//! * Stay: a click never moves the camera — the clicked node keeps its screen spot.
//! * Focus: a click flies the node to the canvas centre (±2 px) at the focus zoom, on a
//! continuous path, and a drag mid-flight takes the camera from where it is.
//! * ⊙ fit flies to the whole graph, centred.
//! * The viewer's choice is remembered (UI memory) and a new pane starts in it.
use egui::{Pos2, Rect, Vec2};
use facett_core::fly::NavMode;
use facett_core::Facet;
use facett_graphview::decorated_view::{Msg, FOCUS_ZOOM};
use facett_graphview::model::{Color, GraphEdge, GraphModel, GraphNode, Pos};
use facett_graphview::DecoratedGraphView;
const SCREEN: Vec2 = Vec2::new(1200.0, 800.0);
const DT: f64 = 1.0 / 60.0;
fn node(id: &str, x: f32, y: f32) -> GraphNode {
GraphNode { id: id.into(), label: id.into(), fill: Color::rgb(60, 90, 160), stroke: Color::WHITE, pos: Pos::new(x, y) }
}
fn edge(a: &str, b: &str) -> GraphEdge {
GraphEdge { from: a.into(), to: b.into(), color: Color::rgb(200, 200, 220), dashed: false, label: None }
}
/// a → b → c, with `b` far off to the right and down.
fn model() -> GraphModel {
GraphModel {
nodes: vec![node("a", 0.0, 0.0), node("b", 1400.0, 700.0), node("c", 1636.0, 700.0), node("d", -300.0, 200.0)],
edges: vec![edge("a", "b"), edge("b", "c"), edge("a", "d")],
}
}
struct Rig {
ctx: egui::Context,
t: f64,
}
impl Rig {
fn new() -> Self {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.0);
Self { ctx, t: 1.0 }
}
fn frame(&mut self, v: &mut DecoratedGraphView, events: Vec<egui::Event>) {
self.frame_in(v, events, None);
}
/// A frame whose canvas is `wide` px wide — wider than the screen (a scroll area's
/// content): only its left part is visible.
fn frame_in(&mut self, v: &mut DecoratedGraphView, events: Vec<egui::Event>, wide: Option<f32>) {
self.t += DT;
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)),
time: Some(self.t),
events,
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| match wide {
None => v.ui(ui),
Some(w) => {
let r = Rect::from_min_size(ui.max_rect().min, Vec2::new(w, ui.max_rect().height()));
ui.scope_builder(egui::UiBuilder::new().max_rect(r), |ui| v.ui(ui));
}
});
});
out.textures_delta.clear();
}
/// A left click at `p` (move, press, release over three frames) + one settle frame.
fn click(&mut self, v: &mut DecoratedGraphView, p: Pos2) {
self.frame(v, vec![egui::Event::PointerMoved(p)]);
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
self.frame(v, vec![button(true)]);
self.frame(v, vec![button(false)]);
}
/// Frames until the camera stops flying (at most 2 s), the camera after each.
fn land(&mut self, v: &mut DecoratedGraphView) -> Vec<(f32, f32, f32)> {
let mut path = Vec::new();
for _ in 0..120 {
self.frame(v, Vec::new());
let s = v.state();
path.push((s.zoom, s.pan_x, s.pan_y));
if !v.flying() {
break;
}
}
self.frame(v, Vec::new());
path
}
}
fn screen_of(v: &DecoratedGraphView, id: &str) -> Pos2 {
let n = v.state().model.nodes.iter().find(|n| n.id == id).unwrap();
v.world_to_screen(n.pos).unwrap()
}
fn view(mode: NavMode) -> DecoratedGraphView {
let mut v = DecoratedGraphView::new("nav").with_model(model());
let _ = v.update(Msg::SetNavMode(mode));
v.set_camera(0.5, -150.0, -60.0);
v
}
/// 📌 Stay: the click selects, the camera does not move, the node keeps its spot.
#[test]
fn stay_a_click_never_moves_the_camera() {
let mut rig = Rig::new();
let mut v = view(NavMode::Stay);
rig.frame(&mut v, Vec::new());
let before = screen_of(&v, "d");
let cam = (v.state().zoom, v.state().pan_x, v.state().pan_y);
rig.click(&mut v, before);
rig.land(&mut v);
assert_eq!(v.state().selected.as_deref(), Some("d"), "the click landed on d");
assert_eq!((v.state().zoom, v.state().pan_x, v.state().pan_y), cam, "the camera did not move");
assert_eq!(screen_of(&v, "d"), before, "the clicked node kept its screen spot");
assert!(!v.flying());
}
/// 🎯 Focus: the click flies the node to the canvas centre (±2 px) at the focus zoom,
/// continuously (no frame moves the node more than a quarter of the way).
#[test]
fn focus_a_click_flies_the_node_to_the_centre() {
let mut rig = Rig::new();
let mut v = view(NavMode::Focus);
rig.frame(&mut v, Vec::new());
let canvas = v.last_paint_rect().unwrap();
let start = screen_of(&v, "b");
assert!(canvas.contains(start), "b is on screen to click: {start:?} in {canvas:?}");
rig.click(&mut v, start);
assert!(v.flying(), "the click started a flight");
let path = rig.land(&mut v);
assert!(path.len() >= 20, "a real flight (~0.4–0.6 s at 60 fps), not a jump: {} frames", path.len());
let end = screen_of(&v, "b");
let c = canvas.center();
assert!((end - c).length() <= 2.0, "b lands at the canvas centre: {end:?} vs {c:?}");
assert!((v.state().zoom - FOCUS_ZOOM).abs() < 1e-4, "at the focus zoom: {}", v.state().zoom);
// continuous: b's screen position, frame to frame
let world = v.state().model.nodes.iter().find(|n| n.id == "b").unwrap().pos;
let total = (c - start).length();
let pts: Vec<Pos2> = path.iter().map(|&(z, px, py)| c + Vec2::new(px, py) + Vec2::new(world.x, world.y) * z).collect();
for w in pts.windows(2) {
assert!((w[1] - w[0]).length() <= 0.25 * total, "a frame jumped {} of {total}", (w[1] - w[0]).length());
}
}
/// A drag mid-flight takes the camera from where it is (no jump back, no fight).
#[test]
fn a_drag_mid_flight_takes_over() {
let mut rig = Rig::new();
let mut v = view(NavMode::Focus);
rig.frame(&mut v, Vec::new());
let p = screen_of(&v, "b");
rig.click(&mut v, p);
for _ in 0..8 {
rig.frame(&mut v, Vec::new());
}
assert!(v.flying());
let mid = (v.state().pan_x, v.state().pan_y);
let _ = v.update(Msg::PanBy(10.0, 0.0));
assert!(!v.flying(), "the drag cancels the flight");
assert_eq!((v.state().pan_x, v.state().pan_y), (mid.0 + 10.0, mid.1), "from where the camera was");
}
/// ⊙ fit flies to the whole graph, CENTRED in the canvas (not world origin at the
/// centre, the graph off to the right).
#[test]
fn fit_flies_to_the_whole_graph_centred() {
let mut rig = Rig::new();
let mut v = view(NavMode::Stay);
v.set_camera(1.0, 0.0, 0.0); // world origin at the centre: the graph hangs right
rig.frame(&mut v, Vec::new());
let canvas = v.last_paint_rect().unwrap();
let _ = v.update(Msg::FitView { now: rig.t, viewport: (canvas.width(), canvas.height()), offset: (0.0, 0.0) });
assert!(v.flying(), "⊙ fit flies");
rig.land(&mut v);
let pts: Vec<Pos2> = ["a", "b", "c", "d"].iter().map(|id| screen_of(&v, id)).collect();
let (min, max) = pts.iter().fold((Pos2::new(f32::MAX, f32::MAX), Pos2::new(f32::MIN, f32::MIN)), |(a, b), p| (a.min(*p), b.max(*p)));
let mid = Pos2::new((min.x + max.x) * 0.5, (min.y + max.y) * 0.5);
assert!((mid - canvas.center()).length() <= 2.0, "the graph's box is centred: {mid:?} vs {:?}", canvas.center());
assert!(pts.iter().all(|p| canvas.contains(*p)), "all of it on the canvas");
}
/// The viewer's choice is remembered in the UI memory: a new pane on the same UI
/// starts in it.
#[test]
fn the_choice_is_remembered_for_the_viewer() {
let mut rig = Rig::new();
let mut v = DecoratedGraphView::new("one").with_model(model());
rig.frame(&mut v, Vec::new());
assert_eq!(v.nav_mode(), NavMode::Stay, "Stay by default");
NavMode::Focus.remember(&rig.ctx);
let mut w = DecoratedGraphView::new("two").with_model(model());
rig.frame(&mut w, Vec::new());
assert_eq!(w.nav_mode(), NavMode::Focus, "a new pane starts in the remembered mode");
assert_eq!(w.state_json()["nav_mode"], "Focus");
}
/// 🎯 Focus in a canvas WIDER than the screen (inside a scroll area): the node lands at
/// the centre of what the viewer SEES, not of the canvas (whose centre is off-screen to
/// the right — the "opens 10 cm to the right" class).
#[test]
fn focus_centres_on_the_visible_part_of_a_wider_canvas() {
let mut rig = Rig::new();
let mut v = view(NavMode::Focus);
v.set_camera(0.5, -2400.0, 0.0); // the graph sits in the visible left part
rig.frame_in(&mut v, Vec::new(), Some(5000.0));
let p = screen_of(&v, "a");
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
assert!(screen.contains(p), "a is visible to click: {p:?}");
rig.frame_in(&mut v, vec![egui::Event::PointerMoved(p)], Some(5000.0));
let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
rig.frame_in(&mut v, vec![b(true)], Some(5000.0));
rig.frame_in(&mut v, vec![b(false)], Some(5000.0));
for _ in 0..80 {
rig.frame_in(&mut v, Vec::new(), Some(5000.0));
}
let canvas = v.last_paint_rect().unwrap();
let seen = canvas.intersect(screen);
let at = screen_of(&v, "a");
assert!(canvas.width() > 4000.0, "the canvas really is wider than the screen: {canvas:?}");
assert!((at.x - seen.center().x).abs() <= 2.0, "a at the visible centre x: {at:?} vs {:?}", seen.center());
}