Add layer transforms, interaction in layer (#3906)

⚠️ Removes `Context::translate_layer`, replacing it with a sticky
`set_transform_layer`

Adds the capability to scale layers.
Allows interaction with scaled and transformed widgets inside
transformed layers.

I've also added a demo of how to have zooming and panning in a window
(see the video below).

This probably closes #1811. Having a panning and zooming container would
just be creating a new
`Area` with a new id, and applying zooming and panning with
`ctx.transform_layer`.

I've run the github workflow scripts in my repository, so hopefully the
formatting and `cargo cranky` is satisfied.

I'm not sure if all call sites where transforms would be relevant have
been handled. This might also be missing are transforming clipping
rects, but I'm not sure where / how to accomplish that. In the demo, the
clipping rect is transformed to match, which seems to work.


https://github.com/emilk/egui/assets/70821802/77e7e743-cdfe-402f-86e3-7744b3ee7b0f

---------

Co-authored-by: tweoss <fchua@puffer5.stanford.edu>
Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
Francis Chua 2024-02-17 02:02:56 -08:00 committed by GitHub
parent f7fc3b0154
commit 069d7a634d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 441 additions and 35 deletions

View File

@ -331,7 +331,7 @@ impl Area {
);
if movable && move_response.dragged() {
state.pivot_pos += ctx.input(|i| i.pointer.delta());
state.pivot_pos += move_response.drag_delta();
}
if (move_response.dragged() || move_response.clicked())

View File

@ -247,6 +247,14 @@ impl SidePanel {
.ctx()
.layer_id_at(pointer)
.map_or(true, |top_layer_id| top_layer_id == ui.layer_id());
let pointer = if let Some(transform) = ui
.ctx()
.memory(|m| m.layer_transforms.get(&ui.layer_id()).cloned())
{
transform.inverse() * pointer
} else {
pointer
};
let resize_x = side.opposite().side_x(panel_rect);
let mouse_over_resize_line = we_are_on_top
@ -708,6 +716,14 @@ impl TopBottomPanel {
.ctx()
.layer_id_at(pointer)
.map_or(true, |top_layer_id| top_layer_id == ui.layer_id());
let pointer = if let Some(transform) = ui
.ctx()
.memory(|m| m.layer_transforms.get(&ui.layer_id()).cloned())
{
transform.inverse() * pointer
} else {
pointer
};
let resize_y = side.opposite().side_y(panel_rect);
let mouse_over_resize_line = we_are_on_top

View File

@ -3,7 +3,9 @@
use std::{borrow::Cow, cell::RefCell, panic::Location, sync::Arc, time::Duration};
use ahash::HashMap;
use epaint::{mutex::*, stats::*, text::Fonts, util::OrderedFloat, TessellationOptions, *};
use epaint::{
emath::TSTransform, mutex::*, stats::*, text::Fonts, util::OrderedFloat, TessellationOptions, *,
};
use crate::{
animation_manager::AnimationManager,
@ -1245,6 +1247,12 @@ impl Context {
let is_interacted_with = res.is_pointer_button_down_on || clicked || res.drag_released;
if is_interacted_with {
res.interact_pointer_pos = input.pointer.interact_pos();
if let (Some(transform), Some(pos)) = (
memory.layer_transforms.get(&res.layer_id),
&mut res.interact_pointer_pos,
) {
*pos = transform.inverse() * *pos;
}
}
if input.pointer.any_down() && !res.is_pointer_button_down_on {
@ -1958,7 +1966,9 @@ impl ContextImpl {
}
}
let shapes = viewport.graphics.drain(self.memory.areas().order());
let shapes = viewport
.graphics
.drain(self.memory.areas().order(), &self.memory.layer_transforms);
let mut repaint_needed = false;
@ -2266,13 +2276,19 @@ impl Context {
}
impl Context {
/// Move all the graphics at the given layer.
/// Transform the graphics of the given layer.
///
/// Can be used to implement drag-and-drop (see relevant demo).
pub fn translate_layer(&self, layer_id: LayerId, delta: Vec2) {
if delta != Vec2::ZERO {
self.graphics_mut(|g| g.entry(layer_id).translate(delta));
}
/// This is a sticky setting, remembered from one frame to the next.
///
/// Can be used to implement pan and zoom (see relevant demo).
pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) {
self.memory_mut(|m| {
if transform == TSTransform::IDENTITY {
m.layer_transforms.remove(&layer_id)
} else {
m.layer_transforms.insert(layer_id, transform)
}
});
}
/// Top-most layer at the given position.
@ -2302,6 +2318,12 @@ impl Context {
///
/// See also [`Response::contains_pointer`].
pub fn rect_contains_pointer(&self, layer_id: LayerId, rect: Rect) -> bool {
let rect =
if let Some(transform) = self.memory(|m| m.layer_transforms.get(&layer_id).cloned()) {
transform * rect
} else {
rect
};
if !rect.is_positive() {
return false;
}
@ -2348,6 +2370,12 @@ impl Context {
let mut blocking_widget = None;
self.write(|ctx| {
let transform = ctx
.memory
.layer_transforms
.get(&layer_id)
.cloned()
.unwrap_or_default();
let viewport = ctx.viewport();
// We add all widgets here, even non-interactive ones,
@ -2367,6 +2395,8 @@ impl Context {
if contains_pointer {
let pointer_pos = viewport.input.pointer.interact_pos();
if let Some(pointer_pos) = pointer_pos {
// Apply the inverse transformation of this layer to the pointer pos.
let pointer_pos = transform.inverse() * pointer_pos;
if let Some(rects) = viewport.layer_rects_prev_frame.by_layer.get(&layer_id) {
// Iterate backwards, i.e. topmost widgets first.
for blocking in rects.iter().rev() {

View File

@ -2,7 +2,7 @@
//! are sometimes painted behind or in front of other things.
use crate::{Id, *};
use epaint::{ClippedShape, Shape};
use epaint::{emath::TSTransform, ClippedShape, Shape};
/// Different layer categories
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
@ -158,11 +158,11 @@ impl PaintList {
self.0[idx.0].shape = Shape::Noop;
}
/// Translate each [`Shape`] and clip rectangle by this much, in-place
pub fn translate(&mut self, delta: Vec2) {
/// Transform each [`Shape`] and clip rectangle by this much, in-place
pub fn transform(&mut self, transform: TSTransform) {
for ClippedShape { clip_rect, shape } in &mut self.0 {
*clip_rect = clip_rect.translate(delta);
shape.translate(delta);
*clip_rect = transform.mul_rect(*clip_rect);
shape.transform(transform);
}
}
@ -194,7 +194,11 @@ impl GraphicLayers {
self.0[layer_id.order as usize].get_mut(&layer_id.id)
}
pub fn drain(&mut self, area_order: &[LayerId]) -> Vec<ClippedShape> {
pub fn drain(
&mut self,
area_order: &[LayerId],
transforms: &ahash::HashMap<LayerId, TSTransform>,
) -> Vec<ClippedShape> {
crate::profile_function!();
let mut all_shapes: Vec<_> = Default::default();
@ -211,6 +215,12 @@ impl GraphicLayers {
for layer_id in area_order {
if layer_id.order == order {
if let Some(list) = order_map.get_mut(&layer_id.id) {
if let Some(transform) = transforms.get(layer_id) {
for clipped_shape in &mut list.0 {
clipped_shape.clip_rect = *transform * clipped_shape.clip_rect;
clipped_shape.shape.transform(*transform);
}
}
all_shapes.append(&mut list.0);
}
}

View File

@ -1,5 +1,8 @@
#![warn(missing_docs)] // Let's keep this file well-documented.` to memory.rs
use ahash::HashMap;
use epaint::emath::TSTransform;
use crate::{
area, vec2,
window::{self, WindowInteraction},
@ -85,6 +88,9 @@ pub struct Memory {
#[cfg_attr(feature = "persistence", serde(skip))]
everything_is_visible: bool,
/// Transforms per layer
pub layer_transforms: HashMap<LayerId, TSTransform>,
// -------------------------------------------------
// Per-viewport:
areas: ViewportIdMap<Areas>,
@ -107,6 +113,7 @@ impl Default for Memory {
viewport_id: Default::default(),
window_interactions: Default::default(),
areas: Default::default(),
layer_transforms: Default::default(),
popup: Default::default(),
everything_is_visible: Default::default(),
};
@ -672,7 +679,8 @@ impl Memory {
/// Top-most layer at the given position.
pub fn layer_id_at(&self, pos: Pos2, resize_interact_radius_side: f32) -> Option<LayerId> {
self.areas().layer_id_at(pos, resize_interact_radius_side)
self.areas()
.layer_id_at(pos, resize_interact_radius_side, &self.layer_transforms)
}
/// An iterator over all layers. Back-to-front. Top is last.
@ -948,7 +956,12 @@ impl Areas {
}
/// Top-most layer at the given position.
pub fn layer_id_at(&self, pos: Pos2, resize_interact_radius_side: f32) -> Option<LayerId> {
pub fn layer_id_at(
&self,
pos: Pos2,
resize_interact_radius_side: f32,
layer_transforms: &HashMap<LayerId, TSTransform>,
) -> Option<LayerId> {
for layer in self.order.iter().rev() {
if self.is_visible(layer) {
if let Some(state) = self.areas.get(&layer.id) {
@ -959,6 +972,10 @@ impl Areas {
rect = rect.expand(resize_interact_radius_side);
}
if let Some(transform) = layer_transforms.get(layer) {
rect = *transform * rect;
}
if rect.contains(pos) {
return Some(*layer);
}

View File

@ -330,7 +330,14 @@ impl Response {
#[inline]
pub fn drag_delta(&self) -> Vec2 {
if self.dragged() {
self.ctx.input(|i| i.pointer.delta())
let mut delta = self.ctx.input(|i| i.pointer.delta());
if let Some(scaling) = self
.ctx
.memory(|m| m.layer_transforms.get(&self.layer_id).map(|t| t.scaling))
{
delta /= scaling;
}
delta
} else {
Vec2::ZERO
}
@ -395,7 +402,14 @@ impl Response {
#[inline]
pub fn hover_pos(&self) -> Option<Pos2> {
if self.hovered() {
self.ctx.input(|i| i.pointer.hover_pos())
let mut pos = self.ctx.input(|i| i.pointer.hover_pos())?;
if let Some(transform) = self
.ctx
.memory(|m| m.layer_transforms.get(&self.layer_id).cloned())
{
pos = transform * pos;
}
Some(pos)
} else {
None
}

View File

@ -2176,7 +2176,8 @@ impl Ui {
if let Some(pointer_pos) = self.ctx().pointer_interact_pos() {
let delta = pointer_pos - response.rect.center();
self.ctx().translate_layer(layer_id, delta);
self.ctx()
.set_transform_layer(layer_id, emath::TSTransform::from_translation(delta));
}
InnerResponse::new(inner, response)

View File

@ -32,6 +32,7 @@ impl Default for Demos {
Box::<super::MiscDemoWindow>::default(),
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::default(),
Box::<super::pan_zoom::PanZoom>::default(),
Box::<super::panels::Panels>::default(),
Box::<super::plot_demo::PlotDemo>::default(),
Box::<super::scrolling::Scrolling>::default(),

View File

@ -19,6 +19,7 @@ pub mod misc_demo_window;
pub mod multi_touch;
pub mod paint_bezier;
pub mod painting;
pub mod pan_zoom;
pub mod panels;
pub mod password;
pub mod plot_demo;

View File

@ -0,0 +1,136 @@
use egui::emath::TSTransform;
#[derive(Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PanZoom {
transform: TSTransform,
drag_value: f32,
}
impl Eq for PanZoom {}
impl super::Demo for PanZoom {
fn name(&self) -> &'static str {
"🗖 Pan Zoom"
}
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
use super::View as _;
let window = egui::Window::new("Pan Zoom")
.default_width(300.0)
.default_height(300.0)
.vscroll(false)
.open(open);
window.show(ctx, |ui| self.ui(ui));
}
}
impl super::View for PanZoom {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.label(
"Pan, zoom in, and zoom out with scrolling (see the plot demo for more instructions). \
Double click on the background to reset.",
);
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file!());
});
ui.separator();
let (id, rect) = ui.allocate_space(ui.available_size());
let response = ui.interact(rect, id, egui::Sense::click_and_drag());
// Allow dragging the background as well.
if response.dragged() {
self.transform.translation += response.drag_delta();
}
// Plot-like reset
if response.double_clicked() {
self.transform = TSTransform::default();
}
let transform =
TSTransform::from_translation(ui.min_rect().left_top().to_vec2()) * self.transform;
if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) {
// Note: doesn't catch zooming / panning if a button in this PanZoom container is hovered.
if response.hovered() {
let pointer_in_layer = transform.inverse() * pointer;
let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
// Zoom in on pointer:
self.transform = self.transform
* TSTransform::from_translation(pointer_in_layer.to_vec2())
* TSTransform::from_scaling(zoom_delta)
* TSTransform::from_translation(-pointer_in_layer.to_vec2());
// Pan:
self.transform = TSTransform::from_translation(pan_delta) * self.transform;
}
}
for (id, pos, callback) in [
(
"a",
egui::Pos2::new(0.0, 0.0),
Box::new(|ui: &mut egui::Ui, _: &mut Self| ui.button("top left!"))
as Box<dyn Fn(&mut egui::Ui, &mut Self) -> egui::Response>,
),
(
"b",
egui::Pos2::new(0.0, 120.0),
Box::new(|ui: &mut egui::Ui, _| ui.button("bottom left?")),
),
(
"c",
egui::Pos2::new(120.0, 120.0),
Box::new(|ui: &mut egui::Ui, _| ui.button("right bottom :D")),
),
(
"d",
egui::Pos2::new(120.0, 0.0),
Box::new(|ui: &mut egui::Ui, _| ui.button("right top ):")),
),
(
"e",
egui::Pos2::new(60.0, 60.0),
Box::new(|ui, state| {
use egui::epaint::*;
// Smiley face.
let painter = ui.painter();
painter.add(CircleShape::filled(pos2(0.0, -10.0), 1.0, Color32::YELLOW));
painter.add(CircleShape::filled(pos2(10.0, -10.0), 1.0, Color32::YELLOW));
painter.add(QuadraticBezierShape::from_points_stroke(
[pos2(0.0, 0.0), pos2(5.0, 3.0), pos2(10.0, 0.0)],
false,
Color32::TRANSPARENT,
Stroke::new(1.0, Color32::YELLOW),
));
ui.add(egui::Slider::new(&mut state.drag_value, 0.0..=100.0).text("My value"))
}),
),
] {
let id = egui::Area::new(id)
.default_pos(pos)
// Need to cover up the pan_zoom demo window,
// but may also cover over other windows.
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
ui.set_clip_rect(transform.inverse() * rect);
egui::Frame::default()
.rounding(egui::Rounding::same(4.0))
.inner_margin(egui::Margin::same(8.0))
.stroke(ui.ctx().style().visuals.window_stroke)
.fill(ui.style().visuals.panel_fill)
.show(ui, |ui| {
ui.style_mut().wrap = Some(false);
callback(ui, self)
});
})
.response
.layer_id;
ui.ctx().set_transform_layer(id, transform);
}
}
}

View File

@ -36,6 +36,7 @@ mod rect;
mod rect_transform;
mod rot2;
pub mod smart_aim;
mod ts_transform;
mod vec2;
mod vec2b;
@ -48,6 +49,7 @@ pub use {
rect::*,
rect_transform::*,
rot2::*,
ts_transform::*,
vec2::*,
vec2b::*,
};

View File

@ -0,0 +1,146 @@
use crate::{Pos2, Rect, Vec2};
/// Linearly transforms positions via a translation, then a scaling.
///
/// [`TSTransform`] first scales points with the scaling origin at `0, 0`
/// (the top left corner), then translates them.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))]
pub struct TSTransform {
/// Scaling applied first, scaled around (0, 0).
pub scaling: f32,
/// Translation amount, applied after scaling.
pub translation: Vec2,
}
impl Eq for TSTransform {}
impl Default for TSTransform {
#[inline]
fn default() -> Self {
Self::IDENTITY
}
}
impl TSTransform {
pub const IDENTITY: Self = Self {
translation: Vec2::ZERO,
scaling: 1.0,
};
#[inline]
/// Creates a new translation that first scales points around
/// `(0, 0)`, then translates them.
pub fn new(translation: Vec2, scaling: f32) -> Self {
Self {
translation,
scaling,
}
}
#[inline]
pub fn from_translation(translation: Vec2) -> Self {
Self::new(translation, 1.0)
}
#[inline]
pub fn from_scaling(scaling: f32) -> Self {
Self::new(Vec2::ZERO, scaling)
}
/// Inverts the transform.
///
/// ```
/// # use emath::{pos2, vec2, TSTransform};
/// let p1 = pos2(2.0, 3.0);
/// let p2 = pos2(12.0, 5.0);
/// let ts = TSTransform::new(vec2(2.0, 3.0), 2.0);
/// let inv = ts.inverse();
/// assert_eq!(inv.mul_pos(p1), pos2(0.0, 0.0));
/// assert_eq!(inv.mul_pos(p2), pos2(5.0, 1.0));
///
/// assert_eq!(ts.inverse().inverse(), ts);
/// ```
#[inline]
pub fn inverse(&self) -> Self {
Self::new(-self.translation / self.scaling, 1.0 / self.scaling)
}
/// Transforms the given coordinate.
///
/// ```
/// # use emath::{pos2, vec2, TSTransform};
/// let p1 = pos2(0.0, 0.0);
/// let p2 = pos2(5.0, 1.0);
/// let ts = TSTransform::new(vec2(2.0, 3.0), 2.0);
/// assert_eq!(ts.mul_pos(p1), pos2(2.0, 3.0));
/// assert_eq!(ts.mul_pos(p2), pos2(12.0, 5.0));
/// ```
#[inline]
pub fn mul_pos(&self, pos: Pos2) -> Pos2 {
self.scaling * pos + self.translation
}
/// Transforms the given rectangle.
///
/// ```
/// # use emath::{pos2, vec2, Rect, TSTransform};
/// let rect = Rect::from_min_max(pos2(5.0, 5.0), pos2(15.0, 10.0));
/// let ts = TSTransform::new(vec2(1.0, 0.0), 3.0);
/// let transformed = ts.mul_rect(rect);
/// assert_eq!(transformed.min, pos2(16.0, 15.0));
/// assert_eq!(transformed.max, pos2(46.0, 30.0));
/// ```
#[inline]
pub fn mul_rect(&self, rect: Rect) -> Rect {
Rect {
min: self.mul_pos(rect.min),
max: self.mul_pos(rect.max),
}
}
}
/// Transforms the position.
impl std::ops::Mul<Pos2> for TSTransform {
type Output = Pos2;
#[inline]
fn mul(self, pos: Pos2) -> Pos2 {
self.mul_pos(pos)
}
}
/// Transforms the rectangle.
impl std::ops::Mul<Rect> for TSTransform {
type Output = Rect;
#[inline]
fn mul(self, rect: Rect) -> Rect {
self.mul_rect(rect)
}
}
impl std::ops::Mul<Self> for TSTransform {
type Output = Self;
#[inline]
/// Applies the right hand side transform, then the left hand side.
///
/// ```
/// # use emath::{TSTransform, vec2};
/// let ts1 = TSTransform::new(vec2(1.0, 0.0), 2.0);
/// let ts2 = TSTransform::new(vec2(-1.0, -1.0), 3.0);
/// let ts_combined = TSTransform::new(vec2(2.0, -1.0), 6.0);
/// assert_eq!(ts_combined, ts2 * ts1);
/// ```
fn mul(self, rhs: Self) -> Self::Output {
// Apply rhs first.
Self {
scaling: self.scaling * rhs.scaling,
translation: self.translation + self.scaling * rhs.translation,
}
}
}

View File

@ -278,6 +278,13 @@ impl Mesh {
}
}
/// Transform the mesh in-place with the given transform.
pub fn transform(&mut self, transform: TSTransform) {
for v in &mut self.vertices {
v.pos = transform * v.pos;
}
}
/// Rotate by some angle about an origin, in-place.
///
/// Origin is a position in screen space.

View File

@ -356,48 +356,70 @@ impl Shape {
}
/// Move the shape by this many points, in-place.
pub fn translate(&mut self, delta: Vec2) {
///
/// If using a [`PaintCallback`], note that only the rect is scaled as opposed
/// to other shapes where the stroke is also scaled.
pub fn transform(&mut self, transform: TSTransform) {
match self {
Self::Noop => {}
Self::Vec(shapes) => {
for shape in shapes {
shape.translate(delta);
shape.transform(transform);
}
}
Self::Circle(circle_shape) => {
circle_shape.center += delta;
circle_shape.center = transform * circle_shape.center;
circle_shape.radius *= transform.scaling;
circle_shape.stroke.width *= transform.scaling;
}
Self::LineSegment { points, .. } => {
Self::LineSegment { points, stroke } => {
for p in points {
*p += delta;
*p = transform * *p;
}
stroke.width *= transform.scaling;
}
Self::Path(path_shape) => {
for p in &mut path_shape.points {
*p += delta;
*p = transform * *p;
}
path_shape.stroke.width *= transform.scaling;
}
Self::Rect(rect_shape) => {
rect_shape.rect = rect_shape.rect.translate(delta);
rect_shape.rect = transform * rect_shape.rect;
rect_shape.stroke.width *= transform.scaling;
}
Self::Text(text_shape) => {
text_shape.pos += delta;
text_shape.pos = transform * text_shape.pos;
// Scale text:
let galley = Arc::make_mut(&mut text_shape.galley);
for row in &mut galley.rows {
row.visuals.mesh_bounds = transform.scaling * row.visuals.mesh_bounds;
for v in &mut row.visuals.mesh.vertices {
v.pos = Pos2::new(transform.scaling * v.pos.x, transform.scaling * v.pos.y);
}
}
galley.mesh_bounds = transform.scaling * galley.mesh_bounds;
galley.rect = transform.scaling * galley.rect;
}
Self::Mesh(mesh) => {
mesh.translate(delta);
mesh.transform(transform);
}
Self::QuadraticBezier(bezier_shape) => {
bezier_shape.points[0] += delta;
bezier_shape.points[1] += delta;
bezier_shape.points[2] += delta;
bezier_shape.points[0] = transform * bezier_shape.points[0];
bezier_shape.points[1] = transform * bezier_shape.points[1];
bezier_shape.points[2] = transform * bezier_shape.points[2];
bezier_shape.stroke.width *= transform.scaling;
}
Self::CubicBezier(cubic_curve) => {
for p in &mut cubic_curve.points {
*p += delta;
*p = transform * *p;
}
cubic_curve.stroke.width *= transform.scaling;
}
Self::Callback(shape) => {
shape.rect = shape.rect.translate(delta);
shape.rect = transform * shape.rect;
}
}
}

View File

@ -428,7 +428,10 @@ fn drag_source<R>(
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
let delta = pointer_pos - res.response.rect.center();
ui.ctx().translate_layer(layer_id, delta);
ui.ctx().set_transform_layer(
layer_id,
eframe::emath::TSTransform::from_translation(delta),
);
}
res