gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
use crate::canvas::view::SceneView;
use core::f32;
use egui::containers::DragPanButtons;
use egui::emath::{GuiRounding, TSTransform};
use egui::{
    Event, Id, InnerResponse, LayerId, MouseWheelUnit, PointerButton, Pos2, Rangef, Rect, Response,
    Sense, Ui, UiBuilder, Vec2,
};

/// A zoomable, pannable scene, after `egui::Scene`: the view is a centre and a
/// zoom kept by the caller, the zoom stops once the whole scene is visible,
/// and the view stays within [`pan_bounds`].
#[derive(Clone, Debug)]
#[must_use = "You should call .show()"]
pub(crate) struct SceneModded {
    /// The scene's own extent. The zoom never goes below what shows all of it,
    /// and that minimum is worked out from the rectangle the scene is actually
    /// given — measuring the available space beforehand disagrees with it on
    /// any pass where egui is still deciding the layout, and the scene is then
    /// drawn larger than the canvas holding it.
    scene_size: Vec2,
    max_zoom: f32,
}

/// The buttons that pan by dragging: all but the primary one, which belongs
/// to the canvas.
fn drag_pan_buttons() -> DragPanButtons {
    DragPanButtons::all() - DragPanButtons::PRIMARY
}

/// How far past the scene the view may travel, as a fraction of its size.
pub(crate) const PAN_MARGIN: f32 = 0.5;

/// The rectangle the view has to stay inside: the scene with [`PAN_MARGIN`] of
/// itself added on every side.
pub(crate) fn pan_bounds(scene: Rect) -> Rect {
    scene.expand2(scene.size() * PAN_MARGIN)
}

/// `value` held within `radius` of `middle`. A negative radius leaves only
/// `middle`, so a view with no room to move sits in the middle of its bounds.
fn clamp_symmetric(value: f32, middle: f32, radius: f32) -> f32 {
    value.clamp(middle - radius.max(0.0), middle + radius.max(0.0))
}

/// Whether the scroll in flight came from a mouse wheel rather than a
/// touchpad. egui smooths a wheel notch out over several frames, so the device
/// is remembered until the next wheel event says otherwise.
fn scroll_is_a_wheel(ui: &Ui) -> bool {
    let remembered = Id::new("scene scroll device");
    let seen = ui.input(|input| {
        input.events.iter().rev().find_map(|event| match event {
            Event::MouseWheel { unit, .. } => Some(*unit != MouseWheelUnit::Point),
            _ => None,
        })
    });
    match seen {
        Some(wheel) => {
            ui.ctx()
                .memory_mut(|memory| memory.data.insert_temp(remembered, wheel));
            wheel
        }
        None => ui
            .ctx()
            .memory(|memory| memory.data.get_temp(remembered).unwrap_or(false)),
    }
}

/// The zoom a wheel scroll of `delta` asks for, on the curve egui uses for
/// ctrl+scroll.
fn wheel_zoom(delta: Vec2, speed: f32) -> f32 {
    (speed * (delta.x + delta.y)).exp()
}

impl Default for SceneModded {
    fn default() -> Self {
        Self {
            scene_size: Vec2::splat(1000.0),
            max_zoom: 1.0,
        }
    }
}

impl SceneModded {
    #[inline]
    pub(crate) fn new() -> Self {
        Default::default()
    }

    /// How big the scene is. Zooming out stops once all of it is visible, and
    /// the ui inside is no bigger.
    #[inline]
    pub(crate) fn scene_size(mut self, scene_size: Vec2) -> Self {
        self.scene_size = scene_size;
        self
    }

    /// How far in the user may zoom.
    #[inline]
    pub(crate) fn max_zoom(mut self, max_zoom: f32) -> Self {
        self.max_zoom = max_zoom;
        self
    }

    /// `view` is where the canvas is looking, and is mutated by any panning or
    /// zooming the user does. You need to store it between frames.
    ///
    /// A view is a centre and a zoom rather than a visible rectangle: a
    /// rectangle would have to be re-derived whenever the canvas changes size,
    /// which turns every measuring pass into a jump.
    pub(crate) fn show<R>(
        &self,
        parent_ui: &mut Ui,
        view: &mut SceneView,
        add_contents: impl FnOnce(&mut Ui) -> R,
    ) -> InnerResponse<R> {
        let (outer_rect, _outer_response) =
            parent_ui.allocate_exact_size(parent_ui.available_size_before_wrap(), Sense::hover());

        let fitted = self.fitted_zoom(outer_rect.size());
        let zoom_range = Rangef::new(fitted, self.max_zoom.max(fitted));
        let zoom = self.zoom_of(outer_rect.size(), view);
        let center = if view.center.is_finite() {
            view.center
        } else {
            self.scene_rect().center()
        };

        let mut to_global =
            TSTransform::from_translation(outer_rect.center().to_vec2() - zoom * center.to_vec2())
                * TSTransform::from_scaling(zoom);

        let ret = self.show_global_transform(
            parent_ui,
            outer_rect,
            &mut to_global,
            zoom_range,
            add_contents,
        );

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

        ret
    }

    /// The scene's extent in scene units; it always starts at the origin.
    pub(crate) fn scene_rect(&self) -> Rect {
        Rect::from_min_size(Pos2::ZERO, self.scene_size)
    }

    /// The zoom that shows all of the scene in `canvas`.
    ///
    /// It comes from the rectangle the scene is actually given, so the fit and
    /// the canvas cannot disagree.
    pub(crate) fn fitted_zoom(&self, canvas: Vec2) -> f32 {
        (canvas / self.scene_size).min_elem()
    }

    /// The zoom a view is showing at. No zoom of its own means "show me all of
    /// it", whatever size the canvas happens to be.
    pub(crate) fn zoom_of(&self, canvas: Vec2, view: &SceneView) -> f32 {
        let fitted = self.fitted_zoom(canvas);
        view.zoom
            .map_or(fitted, |zoom| zoom.clamp(fitted, self.max_zoom.max(fitted)))
    }

    /// What the view covers, in scene units.
    pub(crate) fn visible(&self, canvas: Rect, view: &SceneView) -> Rect {
        Rect::from_center_size(
            view.center,
            canvas.size() / self.zoom_of(canvas.size(), view),
        )
    }

    /// Holds the view inside [`pan_bounds`].
    ///
    /// The centre is clamped, not the pan: the limit is the same every frame,
    /// so a drag stops at the edge instead of being pulled back next frame.
    /// Once the view is wider than the bounds there is nowhere left to go and
    /// the range collapses onto the middle, which is where it already is.
    fn clamp_pan(&self, outer_rect: Rect, to_global: &mut TSTransform) {
        let bounds = pan_bounds(self.scene_rect());
        let visible = to_global.inverse() * outer_rect;
        let center = visible.center();
        let slack = (bounds.size() - visible.size()) * 0.5;
        let clamped = Pos2::new(
            clamp_symmetric(center.x, bounds.center().x, slack.x),
            clamp_symmetric(center.y, bounds.center().y, slack.y),
        );
        to_global.translation += to_global.scaling * (center - clamped);
    }

    fn show_global_transform<R>(
        &self,
        parent_ui: &mut Ui,
        outer_rect: Rect,
        to_global: &mut TSTransform,
        zoom_range: Rangef,
        add_contents: impl FnOnce(&mut Ui) -> R,
    ) -> InnerResponse<R> {
        // Create a new egui paint layer, where we can draw our contents:
        let scene_layer_id = LayerId::new(
            parent_ui.layer_id().order,
            parent_ui.id().with("scene_area"),
        );

        // Put the layer directly on-top of the main layer of the ui:
        parent_ui
            .ctx()
            .set_sublayer(parent_ui.layer_id(), scene_layer_id);

        let mut local_ui = parent_ui.new_child(
            UiBuilder::new()
                .layer_id(scene_layer_id)
                .max_rect(self.scene_rect())
                .sense(Sense::click_and_drag()),
        );

        let mut pan_response = local_ui.response();

        // Update the `to_global` transform based on use interaction:
        self.register_pan_and_zoom(&local_ui, &mut pan_response, to_global, zoom_range);
        // before anything is painted, so the view never shows a frame outside
        // its bounds and never has to jump back
        self.clamp_pan(outer_rect, to_global);

        // Set a correct global clip rect:
        local_ui.set_clip_rect(to_global.inverse() * outer_rect);

        // Tell egui to apply the transform on the layer:
        local_ui
            .ctx()
            .set_transform_layer(scene_layer_id, *to_global);

        // Add the actual contents to the area:
        let ret = add_contents(&mut local_ui);

        // The ui covers everything visible, so clicks, drags and pans are
        // caught anywhere on the background. egui::Scene forces its min rect
        // here; growing it to the same rect has the same effect.
        let min_rect = (to_global.inverse() * outer_rect).round_ui();
        local_ui.expand_to_include_rect(min_rect);

        InnerResponse {
            response: pan_response,
            inner: ret,
        }
    }

    /// Pans and zooms `to_global` by what the pointer did on `resp`.
    fn register_pan_and_zoom(
        &self,
        ui: &Ui,
        resp: &mut Response,
        to_global: &mut TSTransform,
        zoom_range: Rangef,
    ) {
        let dragged = drag_pan_buttons().iter().any(|button| match button {
            DragPanButtons::SECONDARY => resp.dragged_by(PointerButton::Secondary),
            DragPanButtons::MIDDLE => resp.dragged_by(PointerButton::Middle),
            DragPanButtons::EXTRA_1 => resp.dragged_by(PointerButton::Extra1),
            DragPanButtons::EXTRA_2 => resp.dragged_by(PointerButton::Extra2),
            _ => false,
        });
        if dragged {
            to_global.translation += to_global.scaling * resp.drag_delta();
            resp.mark_changed();
        }

        if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos())
            && resp.contains_pointer()
        {
            let pointer_in_scene = to_global.inverse() * mouse_pos;
            let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
            let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
            // A wheel zooms: panning is the middle button's job, and a wheel
            // only has the one axis to give. A touchpad goes on panning.
            let (zoom_delta, pan_delta) = if scroll_is_a_wheel(ui) {
                let speed = ui
                    .ctx()
                    .options(|options| options.input_options.scroll_zoom_speed);
                (zoom_delta * wheel_zoom(pan_delta, speed), Vec2::ZERO)
            } else {
                (zoom_delta, pan_delta)
            };

            // Most of the time we can return early. This is also important to
            // avoid `ui_from_scene` to change slightly due to floating point errors.
            if zoom_delta == 1.0 && pan_delta == Vec2::ZERO {
                return;
            }

            if zoom_delta != 1.0 {
                // Zoom in on pointer, but only if we are not zoomed in or out too far.
                let zoom_delta = zoom_delta.clamp(
                    zoom_range.min / to_global.scaling,
                    zoom_range.max / to_global.scaling,
                );

                *to_global = *to_global
                    * TSTransform::from_translation(pointer_in_scene.to_vec2())
                    * TSTransform::from_scaling(zoom_delta)
                    * TSTransform::from_translation(-pointer_in_scene.to_vec2());

                // Clamp to exact zoom range.
                to_global.scaling = zoom_range.clamp(to_global.scaling);
            }

            // Pan:
            *to_global = TSTransform::from_translation(pan_delta) * *to_global;
            resp.mark_changed();
        }
    }
}

#[cfg(test)]
mod tests;