gled-ui 2.33.0

gled's egui interface, shared by the desktop app and the browser
Documentation
use super::*;
use gled_model::patching::layout::scene_bounds;

/// A wheel notch away from the user zooms in, towards them zooms out, and
/// a wheel that stands still leaves the zoom where it is.
#[test]
fn a_wheel_notch_zooms_both_ways() {
    let speed = 1.0 / 200.0;

    assert_eq!(wheel_zoom(Vec2::ZERO, speed), 1.0);
    assert!(wheel_zoom(Vec2::new(0.0, 50.0), speed) > 1.0);
    assert!(wheel_zoom(Vec2::new(0.0, -50.0), speed) < 1.0);
    // and a notch each way comes back to where it started
    let there = wheel_zoom(Vec2::new(0.0, 50.0), speed);
    let back = wheel_zoom(Vec2::new(0.0, -50.0), speed);
    assert!((there * back - 1.0).abs() < 1e-5);
}

/// What `show` does with a view, per pass.
fn fit(view: &mut SceneView, canvas: Vec2, scene: Vec2, max_zoom: f32) -> f32 {
    let fitted = (canvas / scene).min_elem();
    let scene_modded = SceneModded::new().scene_size(scene).max_zoom(max_zoom);
    let zoom = view
        .zoom
        .map_or(fitted, |zoom| zoom.clamp(fitted, max_zoom.max(fitted)));
    let outer_rect = Rect::from_min_size(Pos2::ZERO, canvas);
    let mut to_global =
        TSTransform::from_translation(outer_rect.center().to_vec2() - zoom * view.center.to_vec2())
            * TSTransform::from_scaling(zoom);

    scene_modded.clamp_pan(outer_rect, &mut to_global);

    view.center = to_global.inverse() * outer_rect.center();
    view.zoom = (to_global.scaling > fitted).then_some(to_global.scaling);
    zoom
}

/// Where the view ends up looking, in scene units.
fn visible(center: Pos2, canvas: Vec2, zoom: f32) -> Rect {
    let scene = scene_bounds().size();
    let scene_modded = SceneModded::new().scene_size(scene);
    let outer_rect = Rect::from_min_size(Pos2::ZERO, canvas);
    let mut to_global =
        TSTransform::from_translation(outer_rect.center().to_vec2() - zoom * center.to_vec2())
            * TSTransform::from_scaling(zoom);
    scene_modded.clamp_pan(outer_rect, &mut to_global);
    to_global.inverse() * outer_rect
}

/// Regression: the zoom floor was measured from the space available before
/// the scene was laid out. On any pass where egui was still deciding the
/// layout the two disagreed, and the scene came out larger than the canvas
/// holding it — which is also what made it jump about.
#[test]
fn the_scene_never_outgrows_the_canvas() {
    let scene = scene_bounds().size();
    let mut view = SceneView::centred_on(scene_bounds().center());

    for canvas in [
        egui::vec2(914.0, 709.0),
        egui::vec2(912.0, 609.0),
        egui::vec2(400.0, 900.0),
        egui::vec2(1400.0, 300.0),
        egui::vec2(1140.0, 834.8),
    ] {
        let zoom = fit(&mut view, canvas, scene, 8.0);

        assert!(
            scene.x * zoom <= canvas.x + 0.5 && scene.y * zoom <= canvas.y + 0.5,
            "scene drawn at {:?} in a canvas of {canvas:?}",
            scene * zoom
        );
    }
}

/// A zoom the user chose is theirs to keep, and then panning is theirs too.
#[test]
fn a_chosen_zoom_and_centre_survive_a_resize() {
    let scene = scene_bounds().size();
    let mut view = SceneView {
        center: egui::pos2(400.0, 300.0),
        zoom: Some(2.0),
    };

    assert_eq!(fit(&mut view, egui::vec2(900.0, 700.0), scene, 8.0), 2.0);
    assert_eq!(view.center, egui::pos2(400.0, 300.0));
}

/// Regression: the view used to be free while zoomed in and then snap to
/// the middle, immovable, the moment it reached the fit.
#[test]
fn a_fitted_view_can_still_be_moved() {
    let canvas = egui::vec2(900.0, 900.0);
    let zoom = (canvas / scene_bounds().size()).min_elem();

    let moved = visible(
        scene_bounds().center() + egui::vec2(300.0, 0.0),
        canvas,
        zoom,
    );

    assert!(
        moved.center().x > scene_bounds().center().x + 290.0,
        "a fitted view keeps the centre it was given: {moved:?}"
    );
}

/// However far the view is pushed, it stops at the bounds.
#[test]
fn the_view_stays_inside_the_pan_bounds() {
    let bounds = pan_bounds(scene_bounds());
    let canvas = egui::vec2(900.0, 700.0);

    for zoom in [0.45, 0.6, 1.0, 2.0, 8.0] {
        for away in [
            egui::vec2(50_000.0, 0.0),
            egui::vec2(-50_000.0, 0.0),
            egui::vec2(0.0, 50_000.0),
            egui::vec2(0.0, -50_000.0),
            egui::vec2(-9_000.0, 9_000.0),
        ] {
            let shown = visible(scene_bounds().center() + away, canvas, zoom);

            assert!(
                bounds.expand(0.5).contains_rect(shown),
                "at zoom {zoom} the view reached {shown:?}, outside {bounds:?}"
            );
        }
    }
}

/// A view too big for the bounds has nowhere to go, so it sits in the
/// middle of them.
#[test]
fn a_view_wider_than_its_bounds_sits_in_the_middle() {
    let canvas = egui::vec2(900.0, 700.0);
    // the bounds are four scenes wide, so this shows more than all of them
    let zoom = canvas.x / (pan_bounds(scene_bounds()).width() * 1.5);

    let shown = visible(
        scene_bounds().center() + egui::vec2(5_000.0, 0.0),
        canvas,
        zoom,
    );

    assert!(
        (shown.center().x - pan_bounds(scene_bounds()).center().x).abs() < 0.5,
        "{shown:?}"
    );
}

/// The limit has to arrive gradually, or the view would jump the moment it
/// ran out of room — which is the twitch this replaced. The view may drift
/// as the edge it is held against moves, and no faster.
#[test]
fn the_bounds_tighten_without_a_jump() {
    let canvas = egui::vec2(900.0, 900.0);
    let pushed = scene_bounds().center() + egui::vec2(10_000.0, 0.0);
    let mut previous: Option<(f32, f32)> = None;

    // zooming out through the point where the room runs out
    for step in 0..=400 {
        let zoom = 0.9 * 0.985_f32.powi(step);
        let shown = visible(pushed, canvas, zoom);
        let half = shown.width() * 0.5;
        if let Some((center, previous_half)) = previous {
            assert!(
                (shown.center().x - center).abs() <= (half - previous_half).abs() + 0.5,
                "the centre moved {} while its edge moved {} at zoom {zoom}",
                shown.center().x - center,
                half - previous_half
            );
        }
        previous = Some((shown.center().x, half));
    }
}