Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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());
}